diff --git a/ios/WayfinderIOS/README.md b/ios/WayfinderIOS/README.md index 292d239..31a5061 100644 --- a/ios/WayfinderIOS/README.md +++ b/ios/WayfinderIOS/README.md @@ -4,12 +4,13 @@ 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 @@ -17,13 +18,13 @@ states, and compact route receipts persist locally through the 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 diff --git a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift index b5bb0b2..ab5e2ee 100644 --- a/ios/WayfinderIOS/WayfinderIOS/AppModel.swift +++ b/ios/WayfinderIOS/WayfinderIOS/AppModel.swift @@ -1135,12 +1135,16 @@ 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, @@ -1148,8 +1152,8 @@ extension [RoutingDestination] { billingClass: .apiMetered, readiness: .signedOut, automaticEligible: false, - contextWindow: nil, - credentialID: OpenAICompatibleConfiguration.openAIPlatform.credentialID + contextWindow: configuration.contextWindow, + credentialID: configuration.credentialID ) - ] + } } diff --git a/ios/WayfinderIOS/WayfinderIOS/OpenAICompatibleProvider.swift b/ios/WayfinderIOS/WayfinderIOS/OpenAICompatibleProvider.swift index 79c858d..77f42a6 100644 --- a/ios/WayfinderIOS/WayfinderIOS/OpenAICompatibleProvider.swift +++ b/ios/WayfinderIOS/WayfinderIOS/OpenAICompatibleProvider.swift @@ -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 { @@ -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] = [:] @@ -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 } @@ -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." ) @@ -276,6 +322,7 @@ actor OpenAICompatibleProvider: ProviderExecutor { } let urlRequest = try makeRequest( + configuration: configuration, messages: request.messages, apiKey: apiKey ) @@ -324,6 +371,7 @@ actor OpenAICompatibleProvider: ProviderExecutor { } private func makeRequest( + configuration: OpenAICompatibleConfiguration, messages: [ProviderExecutionMessage], apiKey: String ) throws -> URLRequest { diff --git a/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift b/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift index 869ce04..2ca3d3d 100644 --- a/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift +++ b/ios/WayfinderIOS/WayfinderIOS/WayfinderIOSApp.swift @@ -7,7 +7,7 @@ struct WayfinderIOSApp: App { init() { let credentialStore = KeychainCredentialStore() let provider = OpenAICompatibleProvider( - configuration: .openAIPlatform, + configurations: OpenAICompatibleConfiguration.supported, credentialStore: credentialStore ) diff --git a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift index cdabd94..da88182 100644 --- a/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift +++ b/ios/WayfinderIOS/WayfinderIOSTests/AppModelTests.swift @@ -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( diff --git a/ios/WayfinderIOS/WayfinderIOSTests/OpenAICompatibleProviderTests.swift b/ios/WayfinderIOS/WayfinderIOSTests/OpenAICompatibleProviderTests.swift index ec0956d..0d8fa54 100644 --- a/ios/WayfinderIOS/WayfinderIOSTests/OpenAICompatibleProviderTests.swift +++ b/ios/WayfinderIOS/WayfinderIOSTests/OpenAICompatibleProviderTests.swift @@ -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( diff --git a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md index 29268db..ce74be2 100644 --- a/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md +++ b/roadmaps/WF-ROADMAP-0016-native-mobile-v0.2.md @@ -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;