Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions ios/WayfinderIOS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,27 @@ This target is the standalone native mobile shell governed by
`WF-ROADMAP-0016`. It embeds the authoritative Rust routing core through
`WayfinderRoutingBridge`; it does not require a Mac or localhost gateway.

The current shell routes through the embedded core and can execute a pinned
GPT-5.6 destination directly against the OpenAI Platform API. The generic
OpenAI-compatible executor owns bounded JSON requests, fragmented SSE parsing,
ordered deltas, cancellation, timeouts, sanitized provider errors, and
request/response size limits. Tests continue to use deterministic network-free
providers and an injected streaming client.
The current shell routes through the embedded core and can execute pinned
OpenAI Platform GPT-5.6, Moonshot/Kimi Platform Kimi K2.6, and OpenRouter Auto
destinations directly from iOS. The generic OpenAI-compatible executor owns
bounded JSON requests, fragmented SSE parsing, ordered deltas, cancellation,
timeouts, sanitized provider errors, and request/response size limits. Tests
continue to use deterministic network-free providers and an injected streaming
client.

Stop, interruption recovery, failure, retry, threads, drafts, terminal message
states, and compact route receipts persist locally through the
`ConversationStore` boundary and a versioned SwiftData implementation.
API keys can be added, replaced, and removed through the native Settings flow.
Secrets remain in the device-only iOS Keychain; app-visible state contains only
configured/not-configured snapshots. The executor reads the key only inside the
provider boundary. Adding a key makes the direct destination ready but does not
silently add it to Automatic; select GPT-5.6 explicitly in Chat.
provider boundary. Adding a key makes only that provider's destinations ready
but does not silently add any destination to Automatic; select a direct
destination explicitly in Chat.

Additional provider presets, model discovery, Apple Foundation Models, and
optional Mac pairing land in later review boundaries. This compatibility slice
uses Chat Completions; an OpenAI-specific Responses API adapter remains a
separate provider-preset decision.
Dynamic model discovery, Apple Foundation Models, and optional Mac pairing land
in later review boundaries. These presets use Chat Completions; an
OpenAI-specific Responses API adapter remains a separate provider decision.

## Build

Expand Down
20 changes: 12 additions & 8 deletions ios/WayfinderIOS/WayfinderIOS/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1135,21 +1135,25 @@ extension [RoutingDestination] {
]

static let liveDirectProviders: [RoutingDestination] = [
OpenAICompatibleConfiguration.openAIPlatform,
OpenAICompatibleConfiguration.moonshotPlatform,
OpenAICompatibleConfiguration.openRouter,
].map { configuration in
RoutingDestination(
id: OpenAICompatibleConfiguration.openAIPlatform.destinationID,
providerID: OpenAICompatibleConfiguration.openAIPlatform.providerID,
providerName: "OpenAI Platform",
modelID: OpenAICompatibleConfiguration.openAIPlatform.modelID,
displayName: OpenAICompatibleConfiguration.openAIPlatform.displayName,
id: configuration.destinationID,
providerID: configuration.providerID,
providerName: configuration.providerName,
modelID: configuration.modelID,
displayName: configuration.displayName,
detail: "Direct API · API key required",
routeTier: "cloud",
boundary: .hosted,
boundaryLabel: "Hosted cloud",
billingClass: .apiMetered,
readiness: .signedOut,
automaticEligible: false,
contextWindow: nil,
credentialID: OpenAICompatibleConfiguration.openAIPlatform.credentialID
contextWindow: configuration.contextWindow,
credentialID: configuration.credentialID
)
]
}
}
56 changes: 52 additions & 4 deletions ios/WayfinderIOS/WayfinderIOS/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,50 @@ struct OpenAICompatibleConfiguration: Equatable, Sendable {
static let openAIPlatform = OpenAICompatibleConfiguration(
destinationID: "openai-platform:gpt-5.6",
providerID: "openai-platform",
providerName: "OpenAI Platform",
displayName: "GPT-5.6",
modelID: "gpt-5.6",
endpoint: URL(string: "https://api.openai.com/v1/chat/completions")!,
credentialID: CredentialID(rawValue: "openai-platform.api-key")
credentialID: CredentialID(rawValue: "openai-platform.api-key"),
contextWindow: nil
)

static let moonshotPlatform = OpenAICompatibleConfiguration(
destinationID: "moonshot-platform:kimi-k2.6",
providerID: "moonshot-platform",
providerName: "Moonshot / Kimi Platform",
displayName: "Kimi K2.6",
modelID: "kimi-k2.6",
endpoint: URL(string: "https://api.moonshot.ai/v1/chat/completions")!,
credentialID: CredentialID(rawValue: "moonshot-platform.api-key"),
contextWindow: 262_144
)

static let openRouter = OpenAICompatibleConfiguration(
destinationID: "openrouter:openrouter-auto",
providerID: "openrouter",
providerName: "OpenRouter",
displayName: "OpenRouter Auto",
modelID: "openrouter/auto",
endpoint: URL(string: "https://openrouter.ai/api/v1/chat/completions")!,
credentialID: CredentialID(rawValue: "openrouter.api-key"),
contextWindow: nil
)

static let supported = [
openAIPlatform,
moonshotPlatform,
openRouter,
]

let destinationID: String
let providerID: String
let providerName: String
let displayName: String
let modelID: String
let endpoint: URL
let credentialID: CredentialID
let contextWindow: UInt64?
}

private struct OpenAIChatRequest: Encodable {
Expand Down Expand Up @@ -199,7 +231,7 @@ actor OpenAICompatibleProvider: ProviderExecutor {
static let maximumRequestBytes = 1_048_576
static let maximumResponseBytes = 4_194_304

private let configuration: OpenAICompatibleConfiguration
private let configurations: [String: OpenAICompatibleConfiguration]
private let credentialStore: any CredentialStore
private let httpClient: any HTTPStreamingClient
private var tasks: [UUID: Task<Void, Never>] = [:]
Expand All @@ -209,7 +241,21 @@ actor OpenAICompatibleProvider: ProviderExecutor {
credentialStore: any CredentialStore,
httpClient: any HTTPStreamingClient = URLSessionHTTPStreamingClient()
) {
self.configuration = configuration
configurations = [configuration.destinationID: configuration]
self.credentialStore = credentialStore
self.httpClient = httpClient
}

init(
configurations: [OpenAICompatibleConfiguration],
credentialStore: any CredentialStore,
httpClient: any HTTPStreamingClient = URLSessionHTTPStreamingClient()
) {
self.configurations = Dictionary(
uniqueKeysWithValues: configurations.map {
($0.destinationID, $0)
}
)
self.credentialStore = credentialStore
self.httpClient = httpClient
}
Expand Down Expand Up @@ -261,7 +307,7 @@ actor OpenAICompatibleProvider: ProviderExecutor {
Error
>.Continuation
) async throws {
guard request.destinationID == configuration.destinationID else {
guard let configuration = configurations[request.destinationID] else {
throw ProviderExecutionError.rejected(
"The selected destination is not available."
)
Expand All @@ -276,6 +322,7 @@ actor OpenAICompatibleProvider: ProviderExecutor {
}

let urlRequest = try makeRequest(
configuration: configuration,
messages: request.messages,
apiKey: apiKey
)
Expand Down Expand Up @@ -324,6 +371,7 @@ actor OpenAICompatibleProvider: ProviderExecutor {
}

private func makeRequest(
configuration: OpenAICompatibleConfiguration,
messages: [ProviderExecutionMessage],
apiKey: String
) throws -> URLRequest {
Expand Down
2 changes: 1 addition & 1 deletion ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct WayfinderIOSApp: App {
init() {
let credentialStore = KeychainCredentialStore()
let provider = OpenAICompatibleProvider(
configuration: .openAIPlatform,
configurations: OpenAICompatibleConfiguration.supported,
credentialStore: credentialStore
)

Expand Down
46 changes: 46 additions & 0 deletions ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,52 @@ final class AppModelTests: XCTestCase {
XCTAssertNil(model.selectedDestinationID)
}

func testEveryCompiledDirectPresetStartsPinnedAndUsesItsOwnCredential() {
XCTAssertEqual(
Set(
[RoutingDestination].liveDirectProviders.map(\.id)
),
Set(OpenAICompatibleConfiguration.supported.map(\.destinationID))
)
XCTAssertTrue(
[RoutingDestination].liveDirectProviders.allSatisfy {
!$0.automaticEligible && $0.readiness == .signedOut
}
)
XCTAssertEqual(
Set(
[RoutingDestination].liveDirectProviders.compactMap(\.credentialID)
),
Set(OpenAICompatibleConfiguration.supported.map(\.credentialID))
)
}

func testMoonshotKeyReadiesOnlyMoonshotPreset() async {
let store = InMemoryCredentialStore()
let model = AppModel(
credentialStore: store,
destinations: .liveDirectProviders
)

_ = await model.saveAPIKey(
"moonshot-key",
for: APIKeyProviderDescriptor.supported[1]
)

XCTAssertEqual(
model.destinations.first {
$0.id == OpenAICompatibleConfiguration.moonshotPlatform.destinationID
}?.readiness,
.ready
)
XCTAssertTrue(
model.destinations.filter {
$0.id != OpenAICompatibleConfiguration.moonshotPlatform.destinationID
}.allSatisfy { $0.readiness == .signedOut }
)
XCTAssertNil(model.selectedDestinationID)
}

func testPinnedDirectDestinationRoutesOnlyAfterExplicitSelection() async {
let store = InMemoryCredentialStore()
let provider = DeterministicMockProvider(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,84 @@ final class OpenAICompatibleProviderTests: XCTestCase {
)
}

func testCompiledPresetsUseTheirOwnEndpointModelAndCredential() async {
let cases: [(OpenAICompatibleConfiguration, String)] = [
(.openAIPlatform, "openai-key"),
(.moonshotPlatform, "moonshot-key"),
(.openRouter, "openrouter-key"),
]

for (configuration, key) in cases {
let store = InMemoryCredentialStore()
try? await store.save(secret: key, for: configuration.credentialID)
let client = StubHTTPStreamingClient(
statusCode: 200,
chunks: [Data("data: [DONE]\n\n".utf8)]
)
let provider = OpenAICompatibleProvider(
configurations: OpenAICompatibleConfiguration.supported,
credentialStore: store,
httpClient: client
)

let result = await collect(
from: await provider.stream(
ProviderExecutionRequest(
id: UUID(),
prompt: "Hello",
destinationID: configuration.destinationID
)
)
)

XCTAssertEqual(result.events, [.completed])
XCTAssertNil(result.error)
let request = await client.capturedRequest()
XCTAssertEqual(request?.url, configuration.endpoint)
XCTAssertEqual(
request?.value(forHTTPHeaderField: "Authorization"),
"Bearer \(key)"
)
let body = try? XCTUnwrap(request?.httpBody)
let object = body.flatMap {
try? JSONSerialization.jsonObject(with: $0) as? [String: Any]
}
XCTAssertEqual(object?["model"] as? String, configuration.modelID)
}
}

func testPresetCannotBorrowAnotherProvidersCredential() async {
let store = InMemoryCredentialStore()
try? await store.save(
secret: "openai-key",
for: OpenAICompatibleConfiguration.openAIPlatform.credentialID
)
let client = StubHTTPStreamingClient(statusCode: 200, chunks: [])
let provider = OpenAICompatibleProvider(
configurations: OpenAICompatibleConfiguration.supported,
credentialStore: store,
httpClient: client
)

let result = await collect(
from: await provider.stream(
ProviderExecutionRequest(
id: UUID(),
prompt: "Hello",
destinationID:
OpenAICompatibleConfiguration.moonshotPlatform.destinationID
)
)
)

XCTAssertEqual(
result.error as? ProviderExecutionError,
.authenticationRequired
)
let requestCount = await client.requestCount()
XCTAssertEqual(requestCount, 0)
}

func testMissingCredentialFailsBeforeNetworkRequest() async {
let client = StubHTTPStreamingClient(statusCode: 200, chunks: [])
let provider = OpenAICompatibleProvider(
Expand Down
7 changes: 7 additions & 0 deletions roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,13 @@ in their own review boundaries. This compatibility slice uses the supported
Chat Completions shape; an OpenAI-specific Responses API adapter remains a
separate provider-preset decision.

The provider-preset slice compiles Moonshot/Kimi Platform (`kimi-k2.6`) and
OpenRouter (`openrouter/auto`) alongside OpenAI Platform. Each preset owns its
documented HTTPS endpoint, model identifier, and Keychain credential ID.
Credentials ready only their matching destinations; all three destinations
remain explicit-only and do not enter Automatic. Dynamic model inventory stays
in the following stacked review.

- implement `CredentialStore`;
- API-key lifecycle UI;
- generic OpenAI-compatible direct provider;
Expand Down
Loading