From e59e0c309af541f8dc9d288a472b5410a0e2e27c Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 17:29:51 -0400 Subject: [PATCH 1/9] Add OAuth login providers --- .../screenshots/oauth-login-providers.jpeg | Bin 0 -> 109724 bytes Sources/Fluid/ContentView.swift | 4 +- .../SettingsStore+CommandMode.swift | 16 +- Sources/Fluid/Persistence/SettingsStore.swift | 24 +- .../Services/CodexSubscriptionAuth.swift | 429 ++++++++++ .../Fluid/Services/CommandModeService.swift | 1 + .../DictationAIPostProcessingGate.swift | 26 +- .../DictationPostProcessingService.swift | 6 +- .../Fluid/Services/GrokSubscriptionAuth.swift | 739 ++++++++++++++++++ Sources/Fluid/Services/LLMClient.swift | 569 +++++++++++++- Sources/Fluid/Services/ModelRepository.swift | 44 +- .../Fluid/Services/OfficialProviderAuth.swift | 612 +++++++++++++++ .../Fluid/Services/RewriteModeService.swift | 17 +- .../AIEnhancementSettingsViewModel.swift | 164 +++- .../UI/AISettingsView+AIConfiguration.swift | 259 +++++- .../DictationE2ETests.swift | 10 +- .../LLMClientRequestBodyTests.swift | 471 +++++++++++ 17 files changed, 3283 insertions(+), 108 deletions(-) create mode 100644 .github/screenshots/oauth-login-providers.jpeg create mode 100644 Sources/Fluid/Services/CodexSubscriptionAuth.swift create mode 100644 Sources/Fluid/Services/GrokSubscriptionAuth.swift create mode 100644 Sources/Fluid/Services/OfficialProviderAuth.swift diff --git a/.github/screenshots/oauth-login-providers.jpeg b/.github/screenshots/oauth-login-providers.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..e507003848dc12368b393de5ae8214d919af19c6 GIT binary patch literal 109724 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 literal 0 HcmV?d00001 diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 353ba1026..a36be2cfa 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -1861,7 +1861,7 @@ struct ContentView: View { let isLocal = self.isLocalEndpoint(derivedBaseURL) let apiKey = route.apiKey - if !isLocal { + if !isLocal, !OfficialProviderAuth.isOfficialProvider(currentSelectedProviderID) { guard !apiKey.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty else { throw AIProcessingError.missingAPIKey(provider: derivedCurrentProvider) } @@ -1939,6 +1939,7 @@ struct ContentView: View { // Build LLMClient configuration var config = LLMClient.Config( + providerID: currentSelectedProviderID, messages: messages, model: derivedSelectedModel, baseURL: derivedBaseURL, @@ -1966,6 +1967,7 @@ struct ContentView: View { source: "ContentView" ) let fallbackConfig = LLMClient.Config( + providerID: currentSelectedProviderID, messages: messages, model: derivedSelectedModel, baseURL: derivedBaseURL, diff --git a/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift b/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift index 7c9a06eb2..d134f333b 100644 --- a/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift +++ b/Sources/Fluid/Persistence/SettingsStore+CommandMode.swift @@ -1,5 +1,4 @@ import Combine -import CryptoKit import Foundation extension SettingsStore { @@ -101,7 +100,7 @@ extension SettingsStore { let baseURL = self.commandModeProviderBaseURL(for: providerID) let apiKey = self.getAPIKey(for: providerID) ?? "" - return self.commandModeProviderFingerprint(baseURL: baseURL, apiKey: apiKey) == stored + return self.commandModeProviderFingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) == stored } private func commandModeProviderBaseURL(for providerID: String) -> String { @@ -114,13 +113,12 @@ extension SettingsStore { return "" } - private func commandModeProviderFingerprint(baseURL: String, apiKey: String) -> String? { - let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedBase.isEmpty else { return nil } - let input = "\(trimmedBase)|\(trimmedKey)" - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private func commandModeProviderFingerprint(providerID: String, baseURL: String, apiKey: String) -> String? { + OfficialProviderAuth.configurationFingerprint( + providerID: providerID, + baseURL: baseURL, + apiKey: apiKey + ) } private func isPrivateAIProviderID(_ providerID: String) -> Bool { diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index d4b6e60ba..564cbf305 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1,7 +1,6 @@ import AppKit import ApplicationServices import Combine -import CryptoKit import Foundation import ServiceManagement import SwiftUI @@ -1668,7 +1667,7 @@ final class SettingsStore: ObservableObject { let hasDefaultModel = !ModelRepository.shared.defaultModels(for: providerID).isEmpty let hasModel = hasSelectedModel || hasDefaultModel - return (isLocal || hasApiKey) && hasModel + return (isLocal || hasApiKey || OfficialProviderAuth.isOfficialProvider(providerID)) && hasModel } /// The base URL for the currently selected AI provider @@ -3587,9 +3586,12 @@ final class SettingsStore: ObservableObject { let baseURL = self.providerBaseURLForVerification(for: trimmed) let apiKey = (self.getAPIKey(for: trimmed) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard ModelRepository.shared.isLocalEndpoint(baseURL) || !apiKey.isEmpty else { return false } + guard ModelRepository.shared.isLocalEndpoint(baseURL) || + OfficialProviderAuth.isOfficialProvider(trimmed) || + !apiKey.isEmpty + else { return false } - return self.providerFingerprint(baseURL: baseURL, apiKey: apiKey) == stored + return self.providerFingerprint(providerID: trimmed, baseURL: baseURL, apiKey: apiKey) == stored } private func providerBaseURLForVerification(for providerID: String) -> String { @@ -3604,14 +3606,12 @@ final class SettingsStore: ObservableObject { return "" } - private func providerFingerprint(baseURL: String, apiKey: String) -> String? { - let trimmedBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedBaseURL.isEmpty else { return nil } - - let input = "\(trimmedBaseURL)|\(trimmedAPIKey)" - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private func providerFingerprint(providerID: String, baseURL: String, apiKey: String) -> String? { + OfficialProviderAuth.configurationFingerprint( + providerID: providerID, + baseURL: baseURL, + apiKey: apiKey + ) } private func syncLinkedProviderSelections(to providerID: String) { diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift new file mode 100644 index 000000000..d661ae68f --- /dev/null +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -0,0 +1,429 @@ +import Foundation +import Security + +/// ChatGPT subscription authentication compatible with the public Codex device flow. +/// +/// FluidVoice stores its own access/refresh pair in a dedicated Keychain item. +/// The existing read-only Codex `auth.json` import remains available as a fallback. +enum CodexSubscriptionAuth { + static let providerID = "openai-codex-subscription" + static let baseURL = "https://chatgpt.com/backend-api/codex" + static let supportsInAppSignIn = true + + private static let issuer = "https://auth.openai.com" + private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" + private static let keychainService = "com.fluidvoice.provider-oauth" + private static let expirySafetyWindow: TimeInterval = 120 + + struct DeviceAuthorization: Equatable { + let deviceAuthID: String + let userCode: String + let pollingInterval: TimeInterval + let expiresAt: Date + + var browserURL: URL { + URL(string: "https://auth.openai.com/codex/device")! + } + } + + struct OAuthSession: Codable, Equatable { + let accessToken: String + let refreshToken: String + let expiresAt: Date + let accountID: String? + let email: String? + + var accountLabel: String { + let trimmedEmail = self.email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedEmail.isEmpty { return trimmedEmail } + let trimmedAccount = self.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedAccount.isEmpty ? "ChatGPT account" : trimmedAccount + } + } + + struct ResolvedCredential { + let accessToken: String + let accountID: String? + let accountLabel: String + } + + enum AuthError: LocalizedError, Equatable { + case invalidOAuthResponse + case requestFailed(Int) + case authorizationDenied + case authorizationExpired + case keychainFailure(OSStatus) + + var errorDescription: String? { + switch self { + case .invalidOAuthResponse: + return "OpenAI returned an invalid sign-in response. Please try again." + case let .requestFailed(statusCode): + return statusCode == 0 + ? "Could not reach OpenAI to complete sign-in. Check your connection and try again." + : "OpenAI sign-in failed (HTTP \(statusCode)). Please try again." + case .authorizationDenied: + return "ChatGPT sign-in was denied in the browser." + case .authorizationExpired: + return "The ChatGPT sign-in code expired. Please try again." + case let .keychainFailure(status): + return "FluidVoice could not access the ChatGPT OAuth session in Keychain (status \(status))." + } + } + } + + private struct DeviceAuthorizationResponse: Decodable { + let deviceAuthID: String + let userCode: String + let interval: String? + + enum CodingKeys: String, CodingKey { + case deviceAuthID = "device_auth_id" + case userCode = "user_code" + case interval + } + } + + private struct DeviceTokenResponse: Decodable { + let authorizationCode: String + let codeVerifier: String + + enum CodingKeys: String, CodingKey { + case authorizationCode = "authorization_code" + case codeVerifier = "code_verifier" + } + } + + private struct OAuthTokenResponse: Decodable { + let idToken: String? + let accessToken: String + let refreshToken: String? + let expiresIn: Double? + + enum CodingKeys: String, CodingKey { + case idToken = "id_token" + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + } + } + + static func requestDeviceAuthorization( + session: URLSession = .shared, + now: Date = Date() + ) async throws -> DeviceAuthorization { + guard let url = URL(string: "\(self.issuer)/api/accounts/deviceauth/usercode") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.httpBody = try? JSONSerialization.data(withJSONObject: ["client_id": self.clientID]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.requestFailed(0) + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw AuthError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try self.decodeDeviceAuthorization(from: data, now: now) + } + + static func decodeDeviceAuthorization( + from data: Data, + now: Date = Date() + ) throws -> DeviceAuthorization { + guard let response = try? JSONDecoder().decode(DeviceAuthorizationResponse.self, from: data), + !response.deviceAuthID.isEmpty, + response.userCode.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) + else { + throw AuthError.invalidOAuthResponse + } + let interval = min(max(Double(response.interval ?? "") ?? 5, 1), 30) + return DeviceAuthorization( + deviceAuthID: response.deviceAuthID, + userCode: response.userCode, + pollingInterval: interval, + expiresAt: now.addingTimeInterval(15 * 60) + ) + } + + static func completeDeviceAuthorization( + _ authorization: DeviceAuthorization, + session: URLSession = .shared, + now: @escaping () -> Date = Date.init + ) async throws -> OAuthSession { + while now() < authorization.expiresAt { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: UInt64((authorization.pollingInterval + 3) * 1_000_000_000)) + + guard let url = URL(string: "\(self.issuer)/api/accounts/deviceauth/token") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "device_auth_id": authorization.deviceAuthID, + "user_code": authorization.userCode, + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.requestFailed(0) + } + guard let http = response as? HTTPURLResponse else { + throw AuthError.invalidOAuthResponse + } + if http.statusCode == 403 || http.statusCode == 404 { + continue + } + guard (200..<300).contains(http.statusCode), + let deviceToken = try? JSONDecoder().decode(DeviceTokenResponse.self, from: data), + !deviceToken.authorizationCode.isEmpty, + !deviceToken.codeVerifier.isEmpty + else { + if http.statusCode == 401 { throw AuthError.authorizationDenied } + throw AuthError.requestFailed(http.statusCode) + } + + let oauthSession = try await self.exchangeAuthorizationCode( + deviceToken.authorizationCode, + codeVerifier: deviceToken.codeVerifier, + now: now(), + session: session + ) + try self.storeOAuthSession(oauthSession) + return oauthSession + } + throw AuthError.authorizationExpired + } + + static func decodeOAuthSession( + from data: Data, + previousRefreshToken: String? = nil, + previousAccountID: String? = nil, + previousEmail: String? = nil, + now: Date = Date() + ) throws -> OAuthSession { + guard let response = try? JSONDecoder().decode(OAuthTokenResponse.self, from: data) else { + throw AuthError.invalidOAuthResponse + } + let accessToken = response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + let responseRefresh = response.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let refreshToken = responseRefresh.isEmpty ? (previousRefreshToken ?? "") : responseRefresh + guard !accessToken.isEmpty, !refreshToken.isEmpty else { + throw AuthError.invalidOAuthResponse + } + + let idClaims = response.idToken.flatMap { self.jwtClaims(from: $0) } + let accessClaims = self.jwtClaims(from: accessToken) + let accountID = self.accountID(in: idClaims) + ?? self.accountID(in: accessClaims) + ?? previousAccountID + let email = (idClaims?["email"] as? String) + ?? (accessClaims?["email"] as? String) + ?? previousEmail + let lifetime = min(max(response.expiresIn ?? 3600, 60), 24 * 60 * 60) + + return OAuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: now.addingTimeInterval(lifetime), + accountID: accountID, + email: email + ) + } + + static func resolveCredential( + now: Date = Date(), + session: URLSession = .shared + ) async throws -> ResolvedCredential? { + guard var oauthSession = try self.loadOAuthSession() else { return nil } + if oauthSession.expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { + oauthSession = try await self.refreshOAuthSession(oauthSession, now: now, session: session) + try self.storeOAuthSession(oauthSession) + } + return ResolvedCredential( + accessToken: oauthSession.accessToken, + accountID: oauthSession.accountID, + accountLabel: oauthSession.accountLabel + ) + } + + static func disconnectFluidVoiceSession() throws { + let status = SecItemDelete(self.keychainQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AuthError.keychainFailure(status) + } + } + + private static func exchangeAuthorizationCode( + _ code: String, + codeVerifier: String, + now: Date, + session: URLSession + ) async throws -> OAuthSession { + try await self.requestOAuthToken( + parameters: [ + "client_id": self.clientID, + "code": code, + "code_verifier": codeVerifier, + "grant_type": "authorization_code", + "redirect_uri": "\(self.issuer)/deviceauth/callback", + ], + now: now, + session: session + ) + } + + private static func refreshOAuthSession( + _ oauthSession: OAuthSession, + now: Date, + session: URLSession + ) async throws -> OAuthSession { + try await self.requestOAuthToken( + parameters: [ + "client_id": self.clientID, + "grant_type": "refresh_token", + "refresh_token": oauthSession.refreshToken, + ], + previous: oauthSession, + now: now, + session: session + ) + } + + private static func requestOAuthToken( + parameters: [String: String], + previous: OAuthSession? = nil, + now: Date, + session: URLSession + ) async throws -> OAuthSession { + guard let url = URL(string: "\(self.issuer)/oauth/token") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.httpBody = self.formEncoded(parameters) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.requestFailed(0) + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw AuthError.requestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try self.decodeOAuthSession( + from: data, + previousRefreshToken: previous?.refreshToken, + previousAccountID: previous?.accountID, + previousEmail: previous?.email, + now: now + ) + } + + private static func loadOAuthSession() throws -> OAuthSession? { + var query = self.keychainQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, + let data = item as? Data, + let oauthSession = try? JSONDecoder().decode(OAuthSession.self, from: data) + else { + throw AuthError.keychainFailure(status) + } + return oauthSession + } + + private static func storeOAuthSession(_ oauthSession: OAuthSession) throws { + guard let data = try? JSONEncoder().encode(oauthSession) else { + throw AuthError.invalidOAuthResponse + } + var attributes = self.keychainQuery() + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(attributes as CFDictionary, nil) + if status == errSecSuccess { return } + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate( + self.keychainQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw AuthError.keychainFailure(updateStatus) + } + return + } + throw AuthError.keychainFailure(status) + } + + private static func keychainQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.keychainService, + kSecAttrAccount as String: self.providerID, + ] + } + + private static func accountID(in claims: [String: Any]?) -> String? { + if let direct = claims?["chatgpt_account_id"] as? String, !direct.isEmpty { + return direct + } + if let auth = claims?["https://api.openai.com/auth"] as? [String: Any], + let nested = auth["chatgpt_account_id"] as? String, + !nested.isEmpty + { + return nested + } + if let organizations = claims?["organizations"] as? [[String: Any]], + let first = organizations.first?["id"] as? String, + !first.isEmpty + { + return first + } + return nil + } + + private static func jwtClaims(from token: String) -> [String: Any]? { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count >= 2 else { return nil } + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) + guard let data = Data(base64Encoded: payload) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func formEncoded(_ values: [String: String]) -> Data? { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "+&=") + let body = values.keys.sorted().compactMap { key -> String? in + guard let value = values[key], + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed), + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) + else { return nil } + return "\(encodedKey)=\(encodedValue)" + }.joined(separator: "&") + return body.data(using: .utf8) + } +} diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index b356788ce..f65d54978 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -892,6 +892,7 @@ final class CommandModeService: ObservableObject { // Build LLMClient configuration var config = LLMClient.Config( + providerID: providerID, messages: messages, model: model, baseURL: baseURL, diff --git a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift index 7e1e1ecee..6ee19416f 100644 --- a/Sources/Fluid/Services/DictationAIPostProcessingGate.swift +++ b/Sources/Fluid/Services/DictationAIPostProcessingGate.swift @@ -1,4 +1,3 @@ -import CryptoKit import Foundation /// Shared gating logic for whether dictation AI post-processing is usable/configured. @@ -58,9 +57,12 @@ enum DictationAIPostProcessingGate { let baseURL = route.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) let apiKey = route.apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard self.isLocalEndpoint(baseURL) || !apiKey.isEmpty else { return false } + guard self.isLocalEndpoint(baseURL) || + OfficialProviderAuth.isOfficialProvider(providerID) || + !apiKey.isEmpty + else { return false } - return self.providerFingerprint(baseURL: baseURL, apiKey: apiKey) == storedFingerprint + return self.providerFingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) == storedFingerprint } static func baseURL(for providerID: String, settings: SettingsStore) -> String { @@ -83,14 +85,16 @@ enum DictationAIPostProcessingGate { return "custom:\(trimmed)" } - static func providerFingerprint(baseURL: String, apiKey: String) -> String? { - let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedBase.isEmpty else { return nil } - - let input = "\(trimmedBase)|\(trimmedKey)" - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + static func providerFingerprint( + providerID: String = "", + baseURL: String, + apiKey: String + ) -> String? { + OfficialProviderAuth.configurationFingerprint( + providerID: providerID, + baseURL: baseURL, + apiKey: apiKey + ) } private static func isPrivateProviderConfigured(settings: SettingsStore) -> Bool { diff --git a/Sources/Fluid/Services/DictationPostProcessingService.swift b/Sources/Fluid/Services/DictationPostProcessingService.swift index c3382cd3a..020731e66 100644 --- a/Sources/Fluid/Services/DictationPostProcessingService.swift +++ b/Sources/Fluid/Services/DictationPostProcessingService.swift @@ -210,7 +210,10 @@ final class DictationPostProcessingService { } let isLocal = ModelRepository.shared.isLocalEndpoint(resolved.baseURL) - if !isLocal, resolved.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + if !isLocal, + !OfficialProviderAuth.isOfficialProvider(resolved.providerID), + resolved.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { throw AIProcessingError.missingAPIKey(provider: resolved.providerKey) } @@ -228,6 +231,7 @@ final class DictationPostProcessingService { messages.append(["role": "user", "content": userMessageContent]) var config = LLMClient.Config( + providerID: resolved.providerID, messages: messages, model: resolved.model, baseURL: resolved.baseURL, diff --git a/Sources/Fluid/Services/GrokSubscriptionAuth.swift b/Sources/Fluid/Services/GrokSubscriptionAuth.swift new file mode 100644 index 000000000..342a31cd7 --- /dev/null +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -0,0 +1,739 @@ +import Foundation +import Security + +/// Grok subscription authentication through xAI's public device flow. +/// +/// FluidVoice stores sessions it creates in its own Keychain item. It can also reuse +/// the official Grok client's `auth.json` as a read-only fallback without modifying it. +enum GrokSubscriptionAuth { + static let providerID = "xai-grok-subscription" + static let proxyBaseURL = "https://cli-chat-proxy.grok.com/v1" + static let supportsInAppSignIn = true + + private static let xAIIssuer = "https://auth.x.ai" + private static let oauthClientID = "b1a00492-073a-47ea-816f-4c329264a828" + static let deviceAuthorizationScopes = [ + "openid", + "profile", + "email", + "offline_access", + "grok-cli:access", + "api:access", + "conversations:read", + "conversations:write", + "workspaces:read", + "workspaces:write", + ] + static let deviceAuthorizationReferrer = "grok-build" + private static let oauthScopes = deviceAuthorizationScopes.joined(separator: " ") + private static let oauthKeychainService = "com.fluidvoice.provider-oauth" + private static let deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + private static let maximumAuthFileSize = 1_048_576 + private static let fallbackCredentialLifetime: TimeInterval = 30 * 24 * 60 * 60 + private static let expirySafetyWindow: TimeInterval = 60 + private static let fallbackClientVersion = "1.0.0" + + struct DeviceAuthorization: Equatable { + let deviceCode: String + let userCode: String + let verificationURL: URL + let verificationCompleteURL: URL? + let pollingInterval: TimeInterval + let expiresAt: Date + + var browserURL: URL { + if let verificationCompleteURL { + return verificationCompleteURL + } + guard var components = URLComponents(url: self.verificationURL, resolvingAgainstBaseURL: false) else { + return self.verificationURL + } + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "user_code", value: self.userCode)) + components.queryItems = queryItems + return components.url ?? self.verificationURL + } + } + + struct ResolvedCredential { + let accessToken: String + let baseURL: String + let requestHeaders: [String: String] + let accountLabel: String + } + + struct OAuthSession: Codable, Equatable { + let accessToken: String + let refreshToken: String? + let expiresAt: Date + let scope: String + let userID: String + let email: String? + + var accountLabel: String { + let trimmedEmail = self.email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedEmail.isEmpty ? "Grok account" : trimmedEmail + } + } + + struct Credential: Equatable { + let accessToken: String + let scope: String + let userID: String + let email: String? + let expiresAt: Date + let grokHome: URL + + var accountLabel: String { + let trimmedEmail = self.email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmedEmail.isEmpty ? "Grok account" : trimmedEmail + } + + /// Stable across access-token refreshes, but changes if the imported account changes. + var verificationIdentity: String { + "grok-subscription|\(self.scope)|\(self.userID)" + } + + var requestHeaders: [String: String] { + GrokSubscriptionAuth.proxyRequestHeaders(userID: self.userID, grokHome: self.grokHome) + } + } + + enum AuthError: LocalizedError, Equatable { + case authFileNotFound(String) + case authFileTooLarge + case unreadableAuthFile + case invalidAuthFile + case noSubscriptionSession + case expiredSession + case invalidOAuthResponse + case invalidVerificationURL + case oauthRequestFailed(Int) + case authorizationDenied + case authorizationExpired + case keychainFailure(OSStatus) + + var errorDescription: String? { + switch self { + case let .authFileNotFound(path): + return "No Grok login was found at \(path). Sign in from FluidVoice, or run `grok login` to import an existing official-client session." + case .authFileTooLarge: + return "The Grok authentication file is unexpectedly large and was not read." + case .unreadableAuthFile: + return "The Grok authentication file could not be read. Check its permissions, then import again." + case .invalidAuthFile: + return "The Grok authentication file is invalid. Sign in from FluidVoice, or run `grok login` to recreate it." + case .noSubscriptionSession: + return "No Grok subscription login was found. Click Sign In with Grok, or run `grok login --oauth` to import an existing session." + case .expiredSession: + return "The Grok login has expired. Sign in with Grok again." + case .invalidOAuthResponse: + return "xAI returned an invalid OAuth response. Please try signing in again." + case .invalidVerificationURL: + return "xAI returned an unsafe sign-in URL, so FluidVoice did not open it." + case let .oauthRequestFailed(statusCode): + return statusCode == 0 + ? "Could not reach xAI to complete sign-in. Check your connection and try again." + : "xAI sign-in failed (HTTP \(statusCode)). Please try again." + case .authorizationDenied: + return "Grok sign-in was denied in the browser." + case .authorizationExpired: + return "The Grok sign-in code expired. Please try again." + case let .keychainFailure(status): + return "FluidVoice could not access the Grok OAuth session in Keychain (status \(status))." + } + } + } + + private struct DeviceAuthorizationResponse: Decodable { + let deviceCode: String + let userCode: String + let verificationURI: String + let verificationURIComplete: String? + let expiresIn: Double + let interval: Double? + + enum CodingKeys: String, CodingKey { + case deviceCode = "device_code" + case userCode = "user_code" + case verificationURI = "verification_uri" + case verificationURIComplete = "verification_uri_complete" + case expiresIn = "expires_in" + case interval + } + } + + private struct OAuthTokenResponse: Decodable { + let accessToken: String + let refreshToken: String? + let expiresIn: Double? + let scope: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case expiresIn = "expires_in" + case scope + case idToken = "id_token" + } + } + + private struct OAuthErrorResponse: Decodable { + let error: String? + } + + private struct StoredCredential: Decodable { + let key: String? + let authMode: String? + let createTime: String? + let userID: String? + let email: String? + let expiresAt: String? + let oidcIssuer: String? + + enum CodingKeys: String, CodingKey { + case key + case authMode = "auth_mode" + case createTime = "create_time" + case userID = "user_id" + case email + case expiresAt = "expires_at" + case oidcIssuer = "oidc_issuer" + } + } + + static func isSubscriptionProvider(_ providerID: String) -> Bool { + providerID.trimmingCharacters(in: .whitespacesAndNewlines) == self.providerID + } + + static func isProxyBaseURL(_ value: String) -> Bool { + guard let host = URL(string: value.trimmingCharacters(in: .whitespacesAndNewlines))?.host else { + return false + } + return host.caseInsensitiveCompare("cli-chat-proxy.grok.com") == .orderedSame + } + + // MARK: - FluidVoice-owned OAuth + + static func requestDeviceAuthorization( + session: URLSession = .shared, + now: Date = Date() + ) async throws -> DeviceAuthorization { + guard let url = URL(string: "\(self.xAIIssuer)/oauth2/device/code") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.setValue("ui", forHTTPHeaderField: "x-grok-client-surface") + request.setValue(self.fallbackClientVersion, forHTTPHeaderField: "x-grok-client-version") + request.httpBody = self.formEncoded([ + "client_id": self.oauthClientID, + "referrer": self.deviceAuthorizationReferrer, + "scope": self.oauthScopes, + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.oauthRequestFailed(0) + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw AuthError.oauthRequestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try self.decodeDeviceAuthorization(from: data, now: now) + } + + static func decodeDeviceAuthorization( + from data: Data, + now: Date = Date() + ) throws -> DeviceAuthorization { + guard let response = try? JSONDecoder().decode(DeviceAuthorizationResponse.self, from: data), + !response.deviceCode.isEmpty, + response.userCode.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }), + response.expiresIn > 0, + let verificationURL = URL(string: response.verificationURI), + self.isAllowedVerificationURL(verificationURL) + else { + throw AuthError.invalidOAuthResponse + } + + let completeURL: URL? + if let value = response.verificationURIComplete { + guard let url = URL(string: value), self.isAllowedVerificationURL(url) else { + throw AuthError.invalidVerificationURL + } + completeURL = url + } else { + completeURL = nil + } + + return DeviceAuthorization( + deviceCode: response.deviceCode, + userCode: response.userCode, + verificationURL: verificationURL, + verificationCompleteURL: completeURL, + pollingInterval: min(max(response.interval ?? 5, 1), 30), + expiresAt: now.addingTimeInterval(min(response.expiresIn, 30 * 60)) + ) + } + + static func completeDeviceAuthorization( + _ authorization: DeviceAuthorization, + session: URLSession = .shared, + now: @escaping () -> Date = Date.init + ) async throws -> OAuthSession { + var pollingInterval = authorization.pollingInterval + + while now() < authorization.expiresAt { + try Task.checkCancellation() + try await Task.sleep(nanoseconds: UInt64(pollingInterval * 1_000_000_000)) + + guard let url = URL(string: "\(self.xAIIssuer)/oauth2/token") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.setValue("ui", forHTTPHeaderField: "x-grok-client-surface") + request.setValue(self.fallbackClientVersion, forHTTPHeaderField: "x-grok-client-version") + request.httpBody = self.formEncoded([ + "client_id": self.oauthClientID, + "device_code": authorization.deviceCode, + "grant_type": self.deviceGrantType, + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.oauthRequestFailed(0) + } + + guard let http = response as? HTTPURLResponse else { + throw AuthError.invalidOAuthResponse + } + if (200..<300).contains(http.statusCode) { + let oauthSession = try self.decodeOAuthSession(from: data, now: now()) + try self.storeOAuthSession(oauthSession) + return oauthSession + } + + let code = (try? JSONDecoder().decode(OAuthErrorResponse.self, from: data))?.error ?? "" + switch code { + case "authorization_pending": + continue + case "slow_down": + pollingInterval = min(pollingInterval + 5, 60) + case "access_denied", "authorization_denied": + throw AuthError.authorizationDenied + case "expired_token": + throw AuthError.authorizationExpired + default: + throw AuthError.oauthRequestFailed(http.statusCode) + } + } + + throw AuthError.authorizationExpired + } + + static func decodeOAuthSession( + from data: Data, + previousRefreshToken: String? = nil, + now: Date = Date() + ) throws -> OAuthSession { + guard let response = try? JSONDecoder().decode(OAuthTokenResponse.self, from: data) else { + throw AuthError.invalidOAuthResponse + } + let accessToken = response.accessToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !accessToken.isEmpty else { + throw AuthError.invalidOAuthResponse + } + + let claims = response.idToken.flatMap(self.jwtClaims) + ?? self.jwtClaims(from: accessToken) + let userID = (claims?["sub"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let email = (claims?["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let responseRefreshToken = response.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) + let refreshToken = responseRefreshToken?.isEmpty == false ? responseRefreshToken : previousRefreshToken + let lifetime = min(max(response.expiresIn ?? 3600, 60), 24 * 60 * 60) + + return OAuthSession( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: now.addingTimeInterval(lifetime), + scope: response.scope ?? self.oauthScopes, + userID: userID, + email: email?.isEmpty == false ? email : nil + ) + } + + static func resolveCredential( + now: Date = Date(), + session: URLSession = .shared + ) async throws -> ResolvedCredential { + if var ownedSession = try self.loadOAuthSession() { + if ownedSession.expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { + ownedSession = try await self.refreshOAuthSession(ownedSession, now: now, session: session) + try self.storeOAuthSession(ownedSession) + } + return ResolvedCredential( + accessToken: ownedSession.accessToken, + baseURL: self.proxyBaseURL, + requestHeaders: self.proxyRequestHeaders( + userID: ownedSession.userID, + grokHome: self.defaultGrokHome() + ), + accountLabel: ownedSession.accountLabel + ) + } + + let imported = try self.loadCredential(now: now) + return ResolvedCredential( + accessToken: imported.accessToken, + baseURL: self.proxyBaseURL, + requestHeaders: imported.requestHeaders, + accountLabel: imported.accountLabel + ) + } + + static func disconnectFluidVoiceSession() throws { + let status = SecItemDelete(self.oauthKeychainQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AuthError.keychainFailure(status) + } + } + + static func defaultGrokHome( + environment: [String: String] = ProcessInfo.processInfo.environment, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let configured = environment["GROK_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !configured.isEmpty + { + let expanded = NSString(string: configured).expandingTildeInPath + if expanded.hasPrefix("/") { + return URL(fileURLWithPath: expanded, isDirectory: true).standardizedFileURL + } + return homeDirectory.appendingPathComponent(expanded, isDirectory: true).standardizedFileURL + } + return homeDirectory.appendingPathComponent(".grok", isDirectory: true) + } + + static func loadCredential( + from authFileURL: URL? = nil, + now: Date = Date() + ) throws -> Credential { + let fileURL = authFileURL ?? self.defaultGrokHome().appendingPathComponent("auth.json", isDirectory: false) + let grokHome = fileURL.deletingLastPathComponent() + + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw AuthError.authFileNotFound(fileURL.path) + } + + let values: URLResourceValues + do { + values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + } catch { + throw AuthError.unreadableAuthFile + } + guard values.isRegularFile == true else { + throw AuthError.unreadableAuthFile + } + guard (values.fileSize ?? 0) <= self.maximumAuthFileSize else { + throw AuthError.authFileTooLarge + } + + let data: Data + do { + data = try Data(contentsOf: fileURL, options: [.mappedIfSafe]) + } catch { + throw AuthError.unreadableAuthFile + } + + return try self.decodeCredential(from: data, grokHome: grokHome, now: now) + } + + static func decodeCredential( + from data: Data, + grokHome: URL, + now: Date = Date() + ) throws -> Credential { + let store: [String: StoredCredential] + do { + store = try JSONDecoder().decode([String: StoredCredential].self, from: data) + } catch { + throw AuthError.invalidAuthFile + } + + var foundSubscriptionSession = false + var foundExpiredSession = false + var candidates: [(credential: Credential, createdAt: Date)] = [] + + for (scope, stored) in store { + let mode = stored.authMode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard mode == "oidc" || mode == "external" else { continue } + + let issuer = stored.oidcIssuer?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let scopeIssuer = scope.components(separatedBy: "::").first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard issuer == self.xAIIssuer || scopeIssuer == self.xAIIssuer else { continue } + foundSubscriptionSession = true + + let token = stored.key?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !token.isEmpty else { continue } + + let createdAt = stored.createTime.flatMap(self.parseRFC3339) ?? .distantPast + let expiresAt = stored.expiresAt.flatMap(self.parseRFC3339) + ?? createdAt.addingTimeInterval(self.fallbackCredentialLifetime) + guard expiresAt.timeIntervalSince(now) > self.expirySafetyWindow else { + foundExpiredSession = true + continue + } + + let storedUserID = stored.userID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let userID = !storedUserID.isEmpty ? storedUserID : (self.jwtSubject(from: token) ?? "") + + candidates.append(( + Credential( + accessToken: token, + scope: scope, + userID: userID, + email: stored.email, + expiresAt: expiresAt, + grokHome: grokHome + ), + createdAt + )) + } + + if let selected = candidates.max(by: { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.credential.expiresAt < rhs.credential.expiresAt + } + return lhs.createdAt < rhs.createdAt + }) { + return selected.credential + } + + if foundExpiredSession { + throw AuthError.expiredSession + } + if foundSubscriptionSession { + throw AuthError.invalidAuthFile + } + throw AuthError.noSubscriptionSession + } + + private static func refreshOAuthSession( + _ oauthSession: OAuthSession, + now: Date, + session: URLSession + ) async throws -> OAuthSession { + guard let refreshToken = oauthSession.refreshToken, !refreshToken.isEmpty else { + throw AuthError.expiredSession + } + guard let url = URL(string: "\(self.xAIIssuer)/oauth2/token") else { + throw AuthError.invalidOAuthResponse + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.setValue("ui", forHTTPHeaderField: "x-grok-client-surface") + request.setValue(self.fallbackClientVersion, forHTTPHeaderField: "x-grok-client-version") + request.httpBody = self.formEncoded([ + "client_id": self.oauthClientID, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.oauthRequestFailed(0) + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw AuthError.oauthRequestFailed((response as? HTTPURLResponse)?.statusCode ?? 0) + } + return try self.decodeOAuthSession( + from: data, + previousRefreshToken: refreshToken, + now: now + ) + } + + private static func loadOAuthSession() throws -> OAuthSession? { + var query = self.oauthKeychainQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = item as? Data else { + throw AuthError.keychainFailure(status) + } + guard let oauthSession = try? JSONDecoder().decode(OAuthSession.self, from: data) else { + throw AuthError.invalidAuthFile + } + return oauthSession + } + + private static func storeOAuthSession(_ oauthSession: OAuthSession) throws { + let data: Data + do { + data = try JSONEncoder().encode(oauthSession) + } catch { + throw AuthError.invalidOAuthResponse + } + + var attributes = self.oauthKeychainQuery() + attributes[kSecValueData as String] = data + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let status = SecItemAdd(attributes as CFDictionary, nil) + if status == errSecSuccess { return } + if status == errSecDuplicateItem { + let updateStatus = SecItemUpdate( + self.oauthKeychainQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + guard updateStatus == errSecSuccess else { + throw AuthError.keychainFailure(updateStatus) + } + return + } + throw AuthError.keychainFailure(status) + } + + private static func oauthKeychainQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: self.oauthKeychainService, + kSecAttrAccount as String: self.providerID, + ] + } + + static func proxyRequestHeaders(userID: String, grokHome: URL) -> [String: String] { + var headers = [ + "X-XAI-Token-Auth": "xai-grok-cli", + "x-authenticateresponse": "authenticate-response", + "x-grok-client-mode": "interactive", + "x-grok-client-version": self.clientVersion(in: grokHome), + "x-grok-client-identifier": "fluidvoice", + "User-Agent": "FluidVoice", + ] + + if !userID.isEmpty { + // The models endpoint currently expects x-userid; inference requests + // use x-grok-user-id. Supplying both matches the official client seams. + headers["x-userid"] = userID + headers["x-grok-user-id"] = userID + } + return headers + } + + private static func isAllowedVerificationURL(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "https", + let host = url.host?.lowercased() + else { return false } + return host == "x.ai" || host.hasSuffix(".x.ai") + } + + nonisolated private static func jwtClaims(from token: String) -> [String: Any]? { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count >= 2 else { return nil } + + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) + guard let data = Data(base64Encoded: payload) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func formEncoded(_ values: [String: String]) -> Data? { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "+&=") + let body = values.keys.sorted().compactMap { key -> String? in + guard let value = values[key], + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed), + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) + else { return nil } + return "\(encodedKey)=\(encodedValue)" + }.joined(separator: "&") + return body.data(using: .utf8) + } + + nonisolated private static func parseRFC3339(_ value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { + return date + } + + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + return standard.date(from: value) + } + + private static func jwtSubject(from token: String) -> String? { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count >= 2 else { return nil } + + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - payload.count % 4) % 4 + payload.append(String(repeating: "=", count: padding)) + + guard let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let subject = object["sub"] as? String + else { return nil } + + let trimmed = subject.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func clientVersion(in grokHome: URL) -> String { + let managedBinary = grokHome.appendingPathComponent("bin/grok", isDirectory: false) + if let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: managedBinary.path), + let version = self.version(fromManagedBinaryName: URL(fileURLWithPath: destination).lastPathComponent) + { + return version + } + + let versionFile = grokHome.appendingPathComponent("version.json", isDirectory: false) + if let data = try? Data(contentsOf: versionFile), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = object["version"] as? String, + !version.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return version.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return self.fallbackClientVersion + } + + private static func version(fromManagedBinaryName name: String) -> String? { + guard name.hasPrefix("grok-") else { return nil } + let suffix = String(name.dropFirst("grok-".count)) + let components = suffix.split(separator: "-").map(String.init) + let platformNames = Set(["macos", "linux", "darwin", "windows"]) + let endIndex = components.firstIndex(where: { platformNames.contains($0) }) ?? components.endIndex + let version = components[.. Void)? init( + providerID: String = "", messages: [[String: Any]], model: String, baseURL: String, @@ -153,6 +163,7 @@ final class LLMClient { maxTokens: Int? = nil, extraParameters: [String: Any] = [:] ) { + self.providerID = providerID self.messages = messages self.model = model self.baseURL = baseURL @@ -171,7 +182,22 @@ final class LLMClient { /// Supports both streaming and non-streaming modes. /// Handles thinking token extraction, tool call parsing, and retries. func call(_ config: Config) async throws -> Response { - var request = try buildRequest(config) + let officialSession: OfficialProviderAuth.Session? + if OfficialProviderAuth.isOfficialProvider(config.providerID) { + officialSession = try await OfficialProviderAuth.resolve( + providerID: config.providerID, + session: self.session + ) + } else { + officialSession = nil + } + var effectiveConfig = config + effectiveConfig.streaming = Self.effectiveStreaming( + providerID: config.providerID, + requested: config.streaming + ) + var request = try self.buildRequest(effectiveConfig, officialSession: officialSession) + let wireProtocol = officialSession?.wireProtocol // Apply timeout to the request itself let timeout = config.timeoutSeconds ?? Self.defaultTimeoutSeconds @@ -183,21 +209,39 @@ final class LLMClient { // than racing a separate "timeout task". A task-group timeout wrapper can accidentally // keep the caller suspended until the full timeout elapses, which is the exact stall // we want to eliminate for overlay responsiveness. - return try await self.executeWithRetry(request: request, config: config) + return try await self.executeWithRetry( + request: request, + config: effectiveConfig, + wireProtocol: wireProtocol + ) + } + + static func effectiveStreaming(providerID: String, requested: Bool) -> Bool { + requested || OfficialProviderAuth.requiresStreamingRequests(providerID) } /// Execute request with retry logic (extracted for timeout wrapper) - private func executeWithRetry(request: URLRequest, config: Config) async throws -> Response { + private func executeWithRetry( + request: URLRequest, + config: Config, + wireProtocol: OfficialProviderAuth.WireProtocol? + ) async throws -> Response { var lastError: Error? for attempt in 1...config.maxRetries { do { if config.streaming { - if self.isResponsesRequest(request) { + if wireProtocol == .anthropicMessages { + return try await self.processAnthropicStreaming(request: request, config: config) + } + if wireProtocol == .geminiCodeAssist { + return try await self.processGeminiStreaming(request: request, config: config) + } + if wireProtocol == .responses || self.isResponsesRequest(request) { return try await self.processResponsesStreaming(request: request, config: config) } return try await self.processStreaming(request: request, config: config) } else { - return try await self.processNonStreaming(request: request) + return try await self.processNonStreaming(request: request, wireProtocol: wireProtocol) } } catch let error as URLError where self.isRetryableError(error) { lastError = LLMError.networkError(error) @@ -222,22 +266,46 @@ final class LLMClient { // MARK: - Request Building - private func buildRequest(_ config: Config) throws -> URLRequest { + private func buildRequest( + _ config: Config, + officialSession: OfficialProviderAuth.Session? = nil + ) throws -> URLRequest { // Build endpoint URL - let baseURL = config.baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let baseURL = (officialSession?.baseURL ?? config.baseURL) + .trimmingCharacters(in: .whitespacesAndNewlines) guard !baseURL.isEmpty else { DebugLogger.shared.error("LLMClient: Missing base URL; refusing to fall back to OpenAI", source: "LLMClient") throw LLMError.invalidURL } - let useResponsesAPI = self.shouldUseResponsesAPI(for: config, baseURL: baseURL) - let endpoint = self.endpoint(for: baseURL, useResponsesAPI: useResponsesAPI) + let wireProtocol = officialSession?.wireProtocol + let useResponsesAPI = wireProtocol == .responses || self.shouldUseResponsesAPI(for: config, baseURL: baseURL) + let endpoint: String + switch wireProtocol { + case .anthropicMessages: + endpoint = baseURL.contains("/messages") ? baseURL : self.appendingPath("messages", to: baseURL) + case .geminiCodeAssist: + let operation = config.streaming ? ":streamGenerateContent?alt=sse" : ":generateContent" + endpoint = baseURL.contains(":generateContent") || baseURL.contains(":streamGenerateContent") + ? baseURL + : "\(baseURL)\(operation)" + case .responses, .none: + endpoint = self.endpoint(for: baseURL, useResponsesAPI: useResponsesAPI) + } guard let url = URL(string: endpoint) else { throw LLMError.invalidURL } - let body = useResponsesAPI ? self.buildResponsesBody(config) : self.buildChatCompletionsBody(config) + let body: [String: Any] + switch wireProtocol { + case .anthropicMessages: + body = self.buildAnthropicMessagesBody(config) + case .geminiCodeAssist: + body = self.buildGeminiCodeAssistBody(config, project: officialSession?.project ?? "") + case .responses, .none: + body = useResponsesAPI ? self.buildResponsesBody(config) : self.buildChatCompletionsBody(config) + } // Serialize to JSON guard let jsonData = try? JSONSerialization.data(withJSONObject: body, options: []) else { @@ -257,8 +325,12 @@ final class LLMClient { request.addValue("application/json", forHTTPHeaderField: "Content-Type") // Send Authorization whenever a key exists; some localhost endpoints still require auth. - if !config.apiKey.isEmpty { - request.addValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + let accessToken = officialSession?.accessToken ?? config.apiKey + if !accessToken.isEmpty { + request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + for (name, value) in officialSession?.headers ?? [:] { + request.setValue(value, forHTTPHeaderField: name) } request.httpBody = jsonData @@ -354,12 +426,30 @@ final class LLMClient { } func buildResponsesBody(_ config: Config) -> [String: Any] { + let usesCodexSubscription = config.providerID == OfficialProviderAuth.codexProviderID + let systemInstructions = config.messages.compactMap { message -> String? in + guard message["role"] as? String == "system", + let content = message["content"] as? String, + !content.isEmpty + else { return nil } + return content + }.joined(separator: "\n\n") var body: [String: Any] = [ "model": config.model, - "input": self.responsesInput(from: config.messages), + "input": self.responsesInput( + from: config.messages, + excludingSystemMessages: usesCodexSubscription + ), "store": false, ] + if usesCodexSubscription { + if !systemInstructions.isEmpty { body["instructions"] = systemInstructions } + body["tool_choice"] = "auto" + body["parallel_tool_calls"] = false + body["include"] = ["reasoning.encrypted_content"] + } + // Always send stream explicitly — providers like Ollama treat an absent key as true body["stream"] = config.streaming @@ -368,7 +458,10 @@ final class LLMClient { body["tool_choice"] = "auto" } - if let tokens = config.maxTokens { + // The ChatGPT Codex subscription backend follows the official Codex + // request contract, which does not accept `max_output_tokens`. + // Keep the standard Responses API behavior for API-key providers. + if let tokens = config.maxTokens, !usesCodexSubscription { body["max_output_tokens"] = tokens } @@ -387,6 +480,197 @@ final class LLMClient { return body } + func buildAnthropicMessagesBody(_ config: Config) -> [String: Any] { + var systemParts: [String] = [] + var messages: [[String: Any]] = [] + + func appendMessage(role: String, blocks: [[String: Any]]) { + guard !blocks.isEmpty else { return } + if let lastIndex = messages.indices.last, + messages[lastIndex]["role"] as? String == role, + var existing = messages[lastIndex]["content"] as? [[String: Any]] + { + existing.append(contentsOf: blocks) + messages[lastIndex]["content"] = existing + } else { + messages.append(["role": role, "content": blocks]) + } + } + + for message in config.messages { + let role = message["role"] as? String ?? "user" + let content = message["content"] as? String ?? "" + if role == "system" { + if !content.isEmpty { systemParts.append(content) } + continue + } + if role == "tool" { + appendMessage(role: "user", blocks: [[ + "type": "tool_result", + "tool_use_id": message["tool_call_id"] as? String ?? "call_unknown", + "content": content, + ]]) + continue + } + + var blocks: [[String: Any]] = [] + if !content.isEmpty { + blocks.append(["type": "text", "text": content]) + } + if let toolCalls = message["tool_calls"] as? [[String: Any]] { + for toolCall in toolCalls { + guard let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String + else { continue } + let argumentsString = function["arguments"] as? String ?? "{}" + let input = argumentsString.data(using: .utf8) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:] + blocks.append([ + "type": "tool_use", + "id": toolCall["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))", + "name": name, + "input": input, + ]) + } + } + appendMessage(role: role == "assistant" ? "assistant" : "user", blocks: blocks) + } + + var body: [String: Any] = [ + "model": config.model, + "messages": messages, + "max_tokens": config.maxTokens ?? 4096, + "stream": config.streaming, + ] + if !systemParts.isEmpty { + body["system"] = systemParts.joined(separator: "\n\n") + } + if let temperature = config.temperature { + body["temperature"] = temperature + } + if !config.tools.isEmpty { + body["tools"] = config.tools.compactMap { tool -> [String: Any]? in + guard tool["type"] as? String == "function", + let function = tool["function"] as? [String: Any], + let name = function["name"] as? String, + let parameters = function["parameters"] as? [String: Any] + else { return nil } + var converted: [String: Any] = ["name": name, "input_schema": parameters] + if let description = function["description"] as? String { + converted["description"] = description + } + return converted + } + } + for (name, value) in config.extraParameters where name != "reasoning_effort" { + body[name] = value + } + return body + } + + func buildGeminiCodeAssistBody(_ config: Config, project: String) -> [String: Any] { + var systemParts: [String] = [] + var contents: [[String: Any]] = [] + var toolNamesByCallID: [String: String] = [:] + + for message in config.messages { + guard let toolCalls = message["tool_calls"] as? [[String: Any]] else { continue } + for toolCall in toolCalls { + guard let callID = toolCall["id"] as? String, + let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String + else { continue } + toolNamesByCallID[callID] = name + } + } + + func appendContent(role: String, parts: [[String: Any]]) { + guard !parts.isEmpty else { return } + if let lastIndex = contents.indices.last, + contents[lastIndex]["role"] as? String == role, + var existing = contents[lastIndex]["parts"] as? [[String: Any]] + { + existing.append(contentsOf: parts) + contents[lastIndex]["parts"] = existing + } else { + contents.append(["role": role, "parts": parts]) + } + } + + for message in config.messages { + let role = message["role"] as? String ?? "user" + let content = message["content"] as? String ?? "" + if role == "system" { + if !content.isEmpty { systemParts.append(content) } + continue + } + if role == "tool" { + let callID = message["tool_call_id"] as? String ?? "call_unknown" + appendContent(role: "user", parts: [[ + "functionResponse": [ + "name": toolNamesByCallID[callID] ?? "tool", + "response": ["result": content], + ], + ]]) + continue + } + + var parts: [[String: Any]] = [] + if !content.isEmpty { parts.append(["text": content]) } + if let toolCalls = message["tool_calls"] as? [[String: Any]] { + for toolCall in toolCalls { + guard let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String + else { continue } + let argumentsString = function["arguments"] as? String ?? "{}" + let arguments = argumentsString.data(using: .utf8) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:] + parts.append(["functionCall": ["name": name, "args": arguments]]) + } + } + appendContent(role: role == "assistant" ? "model" : "user", parts: parts) + } + + var request: [String: Any] = [ + "contents": contents, + "session_id": UUID().uuidString.lowercased(), + ] + if !systemParts.isEmpty { + request["systemInstruction"] = ["parts": [["text": systemParts.joined(separator: "\n\n")]]] + } + + var generationConfig: [String: Any] = [:] + if let temperature = config.temperature { generationConfig["temperature"] = temperature } + if let maxTokens = config.maxTokens { generationConfig["maxOutputTokens"] = maxTokens } + for (name, value) in config.extraParameters where name != "reasoning_effort" { + generationConfig[name] = value + } + if !generationConfig.isEmpty { request["generationConfig"] = generationConfig } + + if !config.tools.isEmpty { + let declarations = config.tools.compactMap { tool -> [String: Any]? in + guard tool["type"] as? String == "function", + let function = tool["function"] as? [String: Any], + let name = function["name"] as? String, + let parameters = function["parameters"] as? [String: Any] + else { return nil } + var declaration: [String: Any] = ["name": name, "parameters": parameters] + if let description = function["description"] as? String { + declaration["description"] = description + } + return declaration + } + if !declarations.isEmpty { request["tools"] = [["functionDeclarations": declarations]] } + } + + return [ + "model": config.model, + "project": project, + "user_prompt_id": UUID().uuidString.lowercased(), + "request": request, + ] + } + private func addResponsesExtraParameter(name: String, value: Any, to body: inout [String: Any]) { if name == "reasoning_effort" { body["reasoning"] = ["effort": value] @@ -420,11 +704,15 @@ final class LLMClient { return tools } - private func responsesInput(from messages: [[String: Any]]) -> [[String: Any]] { + private func responsesInput( + from messages: [[String: Any]], + excludingSystemMessages: Bool = false + ) -> [[String: Any]] { var input: [[String: Any]] = [] for message in messages { let role = message["role"] as? String ?? "user" + if excludingSystemMessages, role == "system" { continue } if role == "tool" { input.append([ @@ -462,7 +750,10 @@ final class LLMClient { // MARK: - Non-Streaming Response - private func processNonStreaming(request: URLRequest) async throws -> Response { + private func processNonStreaming( + request: URLRequest, + wireProtocol: OfficialProviderAuth.WireProtocol? + ) async throws -> Response { DebugLogger.shared.debug("LLMClient: Making non-streaming request to \(request.url?.absoluteString ?? "unknown")", source: "LLMClient") let (data, response) = try await self.session.data(for: request) @@ -479,7 +770,13 @@ final class LLMClient { throw LLMError.invalidResponse } - if self.isResponsesRequest(request) { + if wireProtocol == .anthropicMessages { + return try self.parseAnthropicResponse(json) + } + if wireProtocol == .geminiCodeAssist { + return try self.parseGeminiResponse(json) + } + if wireProtocol == .responses || self.isResponsesRequest(request) { return try self.parseResponsesResponse(json) } @@ -491,6 +788,149 @@ final class LLMClient { return self.parseMessageResponse(message) } + private func processAnthropicStreaming(request: URLRequest, config: Config) async throws -> Response { + let (bytes, response) = try await self.session.bytes(for: request) + if let http = response as? HTTPURLResponse, http.statusCode >= 400 { + var errorData = Data() + for try await byte in bytes { errorData.append(byte) } + throw LLMError.httpError(http.statusCode, String(data: errorData, encoding: .utf8) ?? "Unknown error") + } + + var content: [String] = [] + var thinking: [String] = [] + var toolCalls: [Int: AnthropicToolCallAccumulator] = [:] + var isThinking = false + + for try await rawLine in bytes.lines { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("data:") else { continue } + let jsonString = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) + guard let data = jsonString.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = event["type"] as? String + else { continue } + + let index = event["index"] as? Int ?? 0 + switch type { + case "content_block_start": + guard let block = event["content_block"] as? [String: Any] else { continue } + switch block["type"] as? String { + case "tool_use": + var call = toolCalls[index] ?? AnthropicToolCallAccumulator() + call.id = block["id"] as? String + call.name = block["name"] as? String + if let input = block["input"] as? [String: Any], !input.isEmpty, + let inputData = try? JSONSerialization.data(withJSONObject: input), + let inputString = String(data: inputData, encoding: .utf8) + { + call.arguments = inputString + } + toolCalls[index] = call + if let name = call.name { config.onToolCallStart?(name) } + case "thinking": + if !isThinking { config.onThinkingStart?() } + isThinking = true + default: + break + } + case "content_block_delta": + guard let delta = event["delta"] as? [String: Any] else { continue } + switch delta["type"] as? String { + case "text_delta": + if isThinking { + isThinking = false + config.onThinkingEnd?() + } + if let text = delta["text"] as? String { + content.append(text) + config.onContentChunk?(text) + } + case "thinking_delta": + if !isThinking { config.onThinkingStart?() } + isThinking = true + if let text = delta["thinking"] as? String { + thinking.append(text) + config.onThinkingChunk?(text) + } + case "input_json_delta": + var call = toolCalls[index] ?? AnthropicToolCallAccumulator() + call.arguments += delta["partial_json"] as? String ?? "" + toolCalls[index] = call + default: + break + } + default: + continue + } + } + if isThinking { config.onThinkingEnd?() } + + let parsedTools = toolCalls.keys.sorted().compactMap { index -> ToolCall? in + guard let call = toolCalls[index], let name = call.name else { return nil } + let argumentString = call.arguments.isEmpty ? "{}" : call.arguments + guard let data = argumentString.data(using: .utf8), + let arguments = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return ToolCall( + id: call.id ?? "call_\(UUID().uuidString.prefix(8))", + name: name, + arguments: arguments + ) + } + let thinkingText = thinking.joined().trimmingCharacters(in: .whitespacesAndNewlines) + return Response( + thinking: thinkingText.isEmpty ? nil : thinkingText, + content: content.joined().trimmingCharacters(in: .whitespacesAndNewlines), + toolCalls: parsedTools + ) + } + + private func processGeminiStreaming(request: URLRequest, config: Config) async throws -> Response { + let (bytes, response) = try await self.session.bytes(for: request) + if let http = response as? HTTPURLResponse, http.statusCode >= 400 { + var errorData = Data() + for try await byte in bytes { errorData.append(byte) } + throw LLMError.httpError(http.statusCode, String(data: errorData, encoding: .utf8) ?? "Unknown error") + } + + var content: [String] = [] + var thinking: [String] = [] + var tools: [ToolCall] = [] + var reportedThinking = false + + for try await rawLine in bytes.lines { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix("data:") else { continue } + let jsonString = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) + guard let data = jsonString.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { continue } + + let parsed = self.geminiParts(from: root) + for text in parsed.content { + content.append(text) + config.onContentChunk?(text) + } + for text in parsed.thinking { + if !reportedThinking { config.onThinkingStart?() } + reportedThinking = true + thinking.append(text) + config.onThinkingChunk?(text) + } + for tool in parsed.toolCalls { + tools.append(tool) + config.onToolCallStart?(tool.name) + } + } + if reportedThinking { config.onThinkingEnd?() } + let thinkingText = thinking.joined().trimmingCharacters(in: .whitespacesAndNewlines) + return Response( + thinking: thinkingText.isEmpty ? nil : thinkingText, + content: content.joined().trimmingCharacters(in: .whitespacesAndNewlines), + toolCalls: tools + ) + } + private func processResponsesStreaming(request: URLRequest, config: Config) async throws -> Response { DebugLogger.shared.debug("LLMClient: Starting Responses streaming request to \(request.url?.absoluteString ?? "unknown")", source: "LLMClient") @@ -792,6 +1232,90 @@ final class LLMClient { // MARK: - Parse Non-Streaming Message + private func parseAnthropicResponse(_ json: [String: Any]) throws -> Response { + guard let blocks = json["content"] as? [[String: Any]] else { + throw LLMError.invalidResponse + } + var content: [String] = [] + var thinking: [String] = [] + var tools: [ToolCall] = [] + for block in blocks { + switch block["type"] as? String { + case "text": + if let text = block["text"] as? String { content.append(text) } + case "thinking": + if let text = block["thinking"] as? String { thinking.append(text) } + case "tool_use": + guard let name = block["name"] as? String, + let input = block["input"] as? [String: Any] + else { continue } + tools.append(ToolCall( + id: block["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))", + name: name, + arguments: input + )) + default: + continue + } + } + let thinkingText = thinking.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + return Response( + thinking: thinkingText.isEmpty ? nil : thinkingText, + content: content.joined().trimmingCharacters(in: .whitespacesAndNewlines), + toolCalls: tools + ) + } + + private func parseGeminiResponse(_ json: [String: Any]) throws -> Response { + let parsed = self.geminiParts(from: json) + guard !parsed.content.isEmpty || !parsed.thinking.isEmpty || !parsed.toolCalls.isEmpty else { + throw LLMError.invalidResponse + } + let thinkingText = parsed.thinking.joined().trimmingCharacters(in: .whitespacesAndNewlines) + return Response( + thinking: thinkingText.isEmpty ? nil : thinkingText, + content: parsed.content.joined().trimmingCharacters(in: .whitespacesAndNewlines), + toolCalls: parsed.toolCalls + ) + } + + private func geminiParts(from root: [String: Any]) -> ( + content: [String], + thinking: [String], + toolCalls: [ToolCall] + ) { + let payload = (root["response"] as? [String: Any]) ?? root + let candidates = payload["candidates"] as? [[String: Any]] ?? [] + var content: [String] = [] + var thinking: [String] = [] + var tools: [ToolCall] = [] + + for candidate in candidates { + guard let candidateContent = candidate["content"] as? [String: Any], + let parts = candidateContent["parts"] as? [[String: Any]] + else { continue } + for part in parts { + if let text = part["text"] as? String { + if part["thought"] as? Bool == true { + thinking.append(text) + } else { + content.append(text) + } + } + if let call = part["functionCall"] as? [String: Any], + let name = call["name"] as? String + { + tools.append(ToolCall( + id: call["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))", + name: name, + arguments: call["args"] as? [String: Any] ?? [:] + )) + } + } + } + return (content, thinking, tools) + } + private func parseResponsesResponse(_ json: [String: Any]) throws -> Response { guard let output = json["output"] as? [[String: Any]] else { throw LLMError.invalidResponse @@ -1003,7 +1527,14 @@ final class LLMClient { var curl = "curl -X \(method) \"\(url.absoluteString)\" \\\n" for (key, value) in request.allHTTPHeaderFields ?? [:] { - let maskedValue = key.lowercased().contains("auth") ? "Bearer [REDACTED]" : value + let normalizedKey = key.lowercased() + let isSensitive = normalizedKey.contains("auth") || + normalizedKey.contains("token") || + normalizedKey.contains("api-key") || + normalizedKey.contains("account-id") || + normalizedKey.contains("userid") || + normalizedKey.contains("user-id") + let maskedValue = isSensitive ? "[REDACTED]" : value curl += " -H \"\(key): \(maskedValue)\" \\\n" } curl += " -d '\(bodyString)'" diff --git a/Sources/Fluid/Services/ModelRepository.swift b/Sources/Fluid/Services/ModelRepository.swift index c78adaa7e..495e9fbf5 100644 --- a/Sources/Fluid/Services/ModelRepository.swift +++ b/Sources/Fluid/Services/ModelRepository.swift @@ -17,7 +17,12 @@ final class ModelRepository { /// All built-in provider IDs (not including custom/saved providers) static var builtInProviderIDs: [String] { var providers = [ - "openai", "anthropic", "xai", "groq", "cerebras", "google", "openrouter", "ollama", "lmstudio", + "openai", OfficialProviderAuth.codexProviderID, + "anthropic", OfficialProviderAuth.claudeProviderID, + "xai", GrokSubscriptionAuth.providerID, + "groq", "cerebras", + "google", OfficialProviderAuth.geminiProviderID, + "openrouter", "ollama", "lmstudio", ] if PrivateFeatures.privateAIProvider { providers.insert(PrivateAIProviderFeature.shared.providerID, at: 0) @@ -33,6 +38,16 @@ final class ModelRepository { } switch providerID { + case OfficialProviderAuth.codexProviderID: + // GPT-5.5 remains the broadly available ChatGPT/Codex default; + // the 5.6 tiers can vary by subscription. + return ["gpt-5.5", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.4"] + case OfficialProviderAuth.claudeProviderID: + return ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"] + case OfficialProviderAuth.geminiProviderID: + return ["gemini-2.5-pro", "gemini-2.5-flash"] + case GrokSubscriptionAuth.providerID: + return ["grok-4.5"] case "openai": return ["gpt-4.1"] case "anthropic": @@ -59,6 +74,14 @@ final class ModelRepository { /// Returns the default base URL for a given provider ID. func defaultBaseURL(for providerID: String) -> String { switch providerID { + case OfficialProviderAuth.codexProviderID: + return "https://chatgpt.com/backend-api/codex" + case OfficialProviderAuth.claudeProviderID: + return "https://api.anthropic.com/v1" + case OfficialProviderAuth.geminiProviderID: + return "https://cloudcode-pa.googleapis.com/v1internal" + case GrokSubscriptionAuth.providerID: + return GrokSubscriptionAuth.proxyBaseURL case "openai": return "https://api.openai.com/v1" case "anthropic": @@ -89,6 +112,10 @@ final class ModelRepository { } switch providerID { + case OfficialProviderAuth.codexProviderID: return "ChatGPT (Codex Login)" + case OfficialProviderAuth.claudeProviderID: return "Claude Subscription" + case OfficialProviderAuth.geminiProviderID: return "Gemini Subscription" + case GrokSubscriptionAuth.providerID: return "Grok Subscription" case "openai": return "OpenAI" case "anthropic": return "Anthropic" case "xai": return "xAI" @@ -111,6 +138,9 @@ final class ModelRepository { /// Returns nil for providers that don't have a relevant URL. func providerWebsiteURL(for providerID: String) -> (url: String, label: String)? { switch providerID { + case let id where OfficialProviderAuth.isOfficialProvider(id): + guard let info = OfficialProviderAuth.info(for: id) else { return nil } + return (info.setupURL, info.setupLabel) case "openai": return ("https://platform.openai.com/api-keys", "Get API Key") case "anthropic": @@ -153,11 +183,15 @@ final class ModelRepository { func builtInProvidersList() -> [(id: String, name: String)] { var list: [(id: String, name: String)] = [ ("openai", "OpenAI"), + (OfficialProviderAuth.codexProviderID, "ChatGPT (Codex Login)"), ("anthropic", "Anthropic"), + (OfficialProviderAuth.claudeProviderID, "Claude Subscription"), ("xai", "xAI"), + (GrokSubscriptionAuth.providerID, "Grok Subscription"), ("groq", "Groq"), ("cerebras", "Cerebras"), ("google", "Google"), + (OfficialProviderAuth.geminiProviderID, "Gemini Subscription"), ("openrouter", "OpenRouter"), ("ollama", "Ollama"), ("lmstudio", "LM Studio"), @@ -227,6 +261,14 @@ final class ModelRepository { return PrivateAIProviderFeature.shared.modelIDs() } + // First-party subscription clients do not expose one common public model-list + // contract. Resolve the official session so setup errors are actionable, then + // return the documented models curated for that client integration. + if OfficialProviderAuth.isOfficialProvider(providerID) { + _ = try await OfficialProviderAuth.resolve(providerID: providerID) + return self.defaultModels(for: providerID) + } + let isAnthropic = providerID == "anthropic" || baseURL.contains("anthropic.com") // Construct the models endpoint URL diff --git a/Sources/Fluid/Services/OfficialProviderAuth.swift b/Sources/Fluid/Services/OfficialProviderAuth.swift new file mode 100644 index 000000000..20060f1ed --- /dev/null +++ b/Sources/Fluid/Services/OfficialProviderAuth.swift @@ -0,0 +1,612 @@ +import CryptoKit +import Foundation +import Security + +/// Adapters for subscription sessions created by first-party AI clients. +/// +/// Grok and ChatGPT additionally support public device authorization flows and +/// store FluidVoice-owned tokens in dedicated Keychain items. Other providers +/// remain read-only imports; FluidVoice never writes to another app's credentials. +enum OfficialProviderAuth { + static let codexProviderID = CodexSubscriptionAuth.providerID + static let claudeProviderID = "anthropic-claude-subscription" + static let geminiProviderID = "google-gemini-subscription" + + static let providerIDs: Set = [ + codexProviderID, + claudeProviderID, + geminiProviderID, + GrokSubscriptionAuth.providerID, + ] + + enum WireProtocol: Equatable { + case responses + case anthropicMessages + case geminiCodeAssist + } + + struct Session { + let providerID: String + let accessToken: String + let baseURL: String + let wireProtocol: WireProtocol + let headers: [String: String] + let accountLabel: String + let project: String? + } + + struct ProviderInfo { + let displayName: String + let setupURL: String + let setupLabel: String + let setupCommand: String + let detail: String + } + + enum AuthError: LocalizedError { + case credentialNotFound(String) + case invalidCredential(String) + case expiredCredential(String) + case keychainFailure(String) + case refreshFailed(String) + case geminiNotOnboarded + + var errorDescription: String? { + switch self { + case let .credentialNotFound(message), + let .invalidCredential(message), + let .expiredCredential(message), + let .keychainFailure(message), + let .refreshFailed(message): + return message + case .geminiNotOnboarded: + return "The Gemini login has not finished Code Assist setup. Run `gemini`, choose Sign in with Google, and complete setup before verifying again." + } + } + } + + private static let expirySafetyWindow: TimeInterval = 60 + private static let maximumCredentialFileSize = 1_048_576 + struct CodexCredential: Equatable { + let accessToken: String + let accountID: String? + let accountLabel: String + let expiresAt: Date? + } + + struct ClaudeCredential: Equatable { + let accessToken: String + let accountLabel: String + let expiresAt: Date? + } + + struct GeminiCredential: Equatable { + let accessToken: String + let accountLabel: String + let expiresAt: Date? + } + + static func isOfficialProvider(_ providerID: String) -> Bool { + self.providerIDs.contains(providerID.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + static func supportsInAppSignIn(_ providerID: String) -> Bool { + providerID == CodexSubscriptionAuth.providerID || providerID == GrokSubscriptionAuth.providerID + } + + /// The ChatGPT Codex backend only accepts Responses requests over SSE. + /// Normalize this centrally so verification and non-streaming dictation paths + /// cannot accidentally send `stream: false` to the subscription endpoint. + static func requiresStreamingRequests(_ providerID: String) -> Bool { + providerID == CodexSubscriptionAuth.providerID + } + + static func inAppSignInLabel(for providerID: String) -> String? { + switch providerID { + case CodexSubscriptionAuth.providerID: + return "Sign In with ChatGPT" + case GrokSubscriptionAuth.providerID: + return "Sign In with Grok" + default: + return nil + } + } + + static func info(for providerID: String) -> ProviderInfo? { + switch providerID { + case self.codexProviderID: + return ProviderInfo( + displayName: "ChatGPT (Codex Login)", + setupURL: "https://developers.openai.com/codex/auth/", + setupLabel: "Codex login guide", + setupCommand: "codex login", + detail: "Sign in directly with ChatGPT, or reuse a session owned by the official Codex client." + ) + case self.claudeProviderID: + return ProviderInfo( + displayName: "Claude Subscription", + setupURL: "https://code.claude.com/docs/en/authentication", + setupLabel: "Claude login guide", + setupCommand: "claude", + detail: "Uses the subscription session owned by the official Claude client." + ) + case self.geminiProviderID: + return ProviderInfo( + displayName: "Gemini Subscription", + setupURL: "https://github.com/google-gemini/gemini-cli#authentication-options", + setupLabel: "Gemini login guide", + setupCommand: "gemini", + detail: "Uses the Google session owned by the official Gemini CLI." + ) + case GrokSubscriptionAuth.providerID: + return ProviderInfo( + displayName: "Grok Subscription", + setupURL: "https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md", + setupLabel: "Grok login guide", + setupCommand: "grok login --oauth", + detail: "Sign in directly with xAI, or reuse a session owned by the official Grok client." + ) + default: + return nil + } + } + + /// Verification records the selected login adapter, not an access token. + /// Tokens are intentionally never copied into UserDefaults and may rotate at any time. + static func verificationFingerprint(for providerID: String) -> String? { + guard self.isOfficialProvider(providerID) else { return nil } + return self.sha256("official-oauth-provider|\(providerID)") + } + + static func configurationFingerprint(providerID: String, baseURL: String, apiKey: String) -> String? { + if let official = self.verificationFingerprint(for: providerID) { + return official + } + + let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedBase.isEmpty else { return nil } + return self.sha256("\(trimmedBase)|\(trimmedKey)") + } + + static func resolve( + providerID: String, + now: Date = Date(), + session: URLSession = .shared + ) async throws -> Session { + switch providerID { + case self.codexProviderID: + if let owned = try await CodexSubscriptionAuth.resolveCredential(now: now, session: session) { + var headers = [ + "originator": "fluidvoice", + "User-Agent": "FluidVoice", + ] + if let accountID = owned.accountID, !accountID.isEmpty { + headers["ChatGPT-Account-ID"] = accountID + } + return Session( + providerID: providerID, + accessToken: owned.accessToken, + baseURL: CodexSubscriptionAuth.baseURL, + wireProtocol: .responses, + headers: headers, + accountLabel: owned.accountLabel, + project: nil + ) + } + let credential = try self.loadCodexCredential(now: now) + var headers = [ + "originator": "fluidvoice", + "User-Agent": "FluidVoice", + ] + if let accountID = credential.accountID, !accountID.isEmpty { + headers["ChatGPT-Account-ID"] = accountID + } + return Session( + providerID: providerID, + accessToken: credential.accessToken, + baseURL: "https://chatgpt.com/backend-api/codex", + wireProtocol: .responses, + headers: headers, + accountLabel: credential.accountLabel, + project: nil + ) + + case self.claudeProviderID: + let credential = try self.loadClaudeCredential(now: now) + return Session( + providerID: providerID, + accessToken: credential.accessToken, + baseURL: "https://api.anthropic.com/v1", + wireProtocol: .anthropicMessages, + headers: [ + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20,claude-code-20250219", + "User-Agent": "FluidVoice", + ], + accountLabel: credential.accountLabel, + project: nil + ) + + case self.geminiProviderID: + let stored = try self.loadGeminiCredential() + let credential = try self.validGeminiCredential(stored, now: now) + let project = try await self.loadGeminiProject(accessToken: credential.accessToken, session: session) + return Session( + providerID: providerID, + accessToken: credential.accessToken, + baseURL: "https://cloudcode-pa.googleapis.com/v1internal", + wireProtocol: .geminiCodeAssist, + headers: ["User-Agent": "FluidVoice"], + accountLabel: credential.accountLabel, + project: project + ) + + case GrokSubscriptionAuth.providerID: + let credential = try await GrokSubscriptionAuth.resolveCredential(now: now, session: session) + return Session( + providerID: providerID, + accessToken: credential.accessToken, + baseURL: credential.baseURL, + wireProtocol: .responses, + headers: credential.requestHeaders, + accountLabel: credential.accountLabel, + project: nil + ) + + default: + throw AuthError.invalidCredential("Unknown official login provider: \(providerID)") + } + } + + // MARK: - Codex + + static func decodeCodexCredential(from data: Data) throws -> CodexCredential { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tokens = root["tokens"] as? [String: Any] + else { + throw AuthError.invalidCredential("The Codex authentication file is invalid. Run `codex login` to recreate it.") + } + + let authMode = (root["auth_mode"] as? String)?.lowercased() ?? "chatgpt" + guard authMode == "chatgpt" else { + throw AuthError.invalidCredential("Codex is configured with an API key, not a ChatGPT login. Run `codex login` and choose ChatGPT.") + } + + let accessToken = (tokens["access_token"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !accessToken.isEmpty else { + throw AuthError.invalidCredential("The Codex ChatGPT session has no access token. Run `codex login` again.") + } + + let idToken = tokens["id_token"] as? String + let accessClaims = self.jwtClaims(from: accessToken) + let idClaims = idToken.flatMap(self.jwtClaims) + let accountID = (tokens["account_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let email = self.firstString(in: [idClaims, accessClaims], keys: ["email"]) + let subject = self.firstString(in: [idClaims, accessClaims], keys: ["sub"]) + let label = [email, accountID, subject].compactMap { value -> String? in + guard let value, !value.isEmpty else { return nil } + return value + }.first ?? "ChatGPT account" + + return CodexCredential( + accessToken: accessToken, + accountID: accountID, + accountLabel: label, + expiresAt: self.jwtExpiry(from: accessClaims) + ) + } + + private static func loadCodexCredential(now: Date) throws -> CodexCredential { + let environment = ProcessInfo.processInfo.environment + let codexHome: URL + if let configured = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), !configured.isEmpty { + codexHome = URL(fileURLWithPath: NSString(string: configured).expandingTildeInPath, isDirectory: true) + } else { + codexHome = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex", isDirectory: true) + } + let fileURL = codexHome.appendingPathComponent("auth.json", isDirectory: false) + let data = try self.readCredentialFile( + fileURL, + missingMessage: "No Codex ChatGPT login was found at \(fileURL.path). Run `codex login`, then verify again." + ) + let credential = try self.decodeCodexCredential(from: data) + if let expiresAt = credential.expiresAt, expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { + throw AuthError.expiredCredential("The Codex ChatGPT session has expired. Run `codex` once so the official client can refresh it, then verify again.") + } + return credential + } + + // MARK: - Claude + + static func decodeClaudeCredential(from data: Data, accountLabel: String = "Claude account") throws -> ClaudeCredential { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AuthError.invalidCredential("The Claude authentication data is invalid. Run `claude` and sign in again.") + } + let oauth = (root["claudeAiOauth"] as? [String: Any]) ?? root + let accessToken = ((oauth["accessToken"] as? String) ?? (oauth["access_token"] as? String))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !accessToken.isEmpty else { + throw AuthError.invalidCredential("The Claude subscription session has no access token. Run `claude` and sign in again.") + } + + let label = (oauth["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let expiresAt = self.dateFromMillisecondsOrSeconds(oauth["expiresAt"] ?? oauth["expires_at"]) + ?? self.jwtExpiry(from: self.jwtClaims(from: accessToken)) + return ClaudeCredential( + accessToken: accessToken, + accountLabel: (label?.isEmpty == false ? label! : accountLabel), + expiresAt: expiresAt + ) + } + + private static func loadClaudeCredential(now: Date) throws -> ClaudeCredential { + if let token = ProcessInfo.processInfo.environment["CLAUDE_CODE_OAUTH_TOKEN"]? + .trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty + { + let credential = ClaudeCredential( + accessToken: token, + accountLabel: "Claude setup token", + expiresAt: self.jwtExpiry(from: self.jwtClaims(from: token)) + ) + return try self.validateClaudeExpiry(credential, now: now) + } + + #if os(macOS) + if let keychainItem = try self.readGenericPassword(service: "Claude Code-credentials") { + let credential = try self.decodeClaudeCredential( + from: keychainItem.data, + accountLabel: keychainItem.account.isEmpty ? "Claude account" : keychainItem.account + ) + return try self.validateClaudeExpiry(credential, now: now) + } + #endif + + let fileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent(".credentials.json", isDirectory: false) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw AuthError.credentialNotFound( + "No Claude subscription login was found. Run `claude` and sign in, or launch FluidVoice with `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`." + ) + } + let data = try self.readCredentialFile(fileURL, missingMessage: "No Claude login was found.") + return try self.validateClaudeExpiry(self.decodeClaudeCredential(from: data), now: now) + } + + private static func validateClaudeExpiry(_ credential: ClaudeCredential, now: Date) throws -> ClaudeCredential { + if let expiresAt = credential.expiresAt, expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { + throw AuthError.expiredCredential("The Claude subscription session has expired. Run `claude` so the official client can refresh it, then verify again.") + } + return credential + } + + // MARK: - Gemini + + static func decodeGeminiCredential(from data: Data, accountLabel: String = "Google account") throws -> GeminiCredential { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AuthError.invalidCredential("The Gemini authentication data is invalid. Run `gemini` and sign in again.") + } + let token = (root["token"] as? [String: Any]) ?? root + let accessToken = ((token["accessToken"] as? String) ?? (token["access_token"] as? String))? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !accessToken.isEmpty else { + throw AuthError.invalidCredential("The Gemini login contains no usable access token. Run `gemini` and sign in again.") + } + + let expiresAt = self.dateFromMillisecondsOrSeconds( + token["expiresAt"] ?? token["expiry_date"] ?? token["expires_at"] + ) + return GeminiCredential( + accessToken: accessToken, + accountLabel: accountLabel, + expiresAt: expiresAt + ) + } + + private static func loadGeminiCredential() throws -> GeminiCredential { + let accountLabel = self.geminiAccountLabel() + + #if os(macOS) + if let keychainItem = try self.readGenericPassword(service: "gemini-cli-oauth", account: "main-account") { + return try self.decodeGeminiCredential(from: keychainItem.data, accountLabel: accountLabel) + } + #endif + + let fileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("oauth_creds.json", isDirectory: false) + let data = try self.readCredentialFile( + fileURL, + missingMessage: "No Gemini CLI login was found at \(fileURL.path). Run `gemini` and choose Sign in with Google, then verify again." + ) + return try self.decodeGeminiCredential(from: data, accountLabel: accountLabel) + } + + private static func validGeminiCredential( + _ credential: GeminiCredential, + now: Date + ) throws -> GeminiCredential { + if !credential.accessToken.isEmpty, + credential.expiresAt?.timeIntervalSince(now) ?? 300 > self.expirySafetyWindow + { + return credential + } + // FluidVoice imports the official client's access token but never copies + // or refreshes its refresh token. That keeps credential ownership with + // Gemini CLI and avoids shipping another application's OAuth client secret. + throw AuthError.expiredCredential( + "The Gemini subscription session has expired. Run `gemini` so the official client can refresh it, then verify again." + ) + } + + private static func loadGeminiProject(accessToken: String, session: URLSession) async throws -> String { + guard let url = URL(string: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist") else { + throw AuthError.geminiNotOnboarded + } + let configuredProject = try self.configuredGeminiProject() + let projectValue: Any = configuredProject.map { $0 as Any } ?? NSNull() + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("FluidVoice", forHTTPHeaderField: "User-Agent") + request.httpBody = try? JSONSerialization.data(withJSONObject: [ + "cloudaicompanionProject": projectValue, + "metadata": [ + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": projectValue, + ], + ]) + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw AuthError.refreshFailed("Gemini Code Assist setup check failed: \(error.localizedDescription)") + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw AuthError.geminiNotOnboarded + } + if let project = root["cloudaicompanionProject"] as? String, !project.isEmpty { + return project + } + if let projectObject = root["cloudaicompanionProject"] as? [String: Any], + let project = (projectObject["id"] as? String) ?? (projectObject["name"] as? String), + !project.isEmpty + { + return project + } + if let configuredProject { + return configuredProject + } + throw AuthError.geminiNotOnboarded + } + + static func configuredGeminiProject( + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> String? { + let project = [environment["GOOGLE_CLOUD_PROJECT"], environment["GOOGLE_CLOUD_PROJECT_ID"]] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + guard let project else { return nil } + guard !project.allSatisfy(\.isNumber) else { + throw AuthError.invalidCredential( + "Gemini requires a string Google Cloud project ID, not the numeric project number in GOOGLE_CLOUD_PROJECT." + ) + } + return project + } + + private static func geminiAccountLabel() -> String { + let fileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("google_accounts.json", isDirectory: false) + guard let data = try? Data(contentsOf: fileURL), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let active = root["active"] as? String, + !active.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return "Google account" } + return active.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Shared helpers + + private struct GenericPasswordItem { + let account: String + let data: Data + } + + private static func readGenericPassword(service: String, account: String? = nil) throws -> GenericPasswordItem? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + if let account { + query[kSecAttrAccount as String] = account + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, + let dictionary = item as? [String: Any], + let data = dictionary[kSecValueData as String] as? Data + else { + throw AuthError.keychainFailure("The official client's Keychain credential could not be read (status \(status)). Open the client and sign in again.") + } + return GenericPasswordItem( + account: dictionary[kSecAttrAccount as String] as? String ?? account ?? "", + data: data + ) + } + + private static func readCredentialFile(_ fileURL: URL, missingMessage: String) throws -> Data { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw AuthError.credentialNotFound(missingMessage) + } + guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + (values.fileSize ?? 0) <= self.maximumCredentialFileSize, + let data = try? Data(contentsOf: fileURL, options: [.mappedIfSafe]) + else { + throw AuthError.invalidCredential("The authentication file at \(fileURL.path) could not be read safely.") + } + return data + } + + private static func jwtClaims(from token: String) -> [String: Any]? { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard segments.count >= 2 else { return nil } + var payload = String(segments[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) + guard let data = Data(base64Encoded: payload) else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + + private static func jwtExpiry(from claims: [String: Any]?) -> Date? { + guard let timestamp = (claims?["exp"] as? NSNumber)?.doubleValue else { return nil } + return Date(timeIntervalSince1970: timestamp) + } + + private static func firstString(in objects: [[String: Any]?], keys: [String]) -> String? { + for object in objects { + for key in keys { + if let value = object?[key] as? String, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + } + return nil + } + + private static func dateFromMillisecondsOrSeconds(_ value: Any?) -> Date? { + let number: Double? + if let value = value as? NSNumber { + number = value.doubleValue + } else if let value = value as? String { + number = Double(value) + } else { + number = nil + } + guard var timestamp = number, timestamp > 0 else { return nil } + if timestamp > 10_000_000_000 { timestamp /= 1000 } + return Date(timeIntervalSince1970: timestamp) + } + + private static func sha256(_ value: String) -> String { + SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Fluid/Services/RewriteModeService.swift b/Sources/Fluid/Services/RewriteModeService.swift index d476a25de..c29641e23 100644 --- a/Sources/Fluid/Services/RewriteModeService.swift +++ b/Sources/Fluid/Services/RewriteModeService.swift @@ -1,6 +1,5 @@ import AppKit import Combine -import CryptoKit import Foundation @MainActor @@ -342,6 +341,7 @@ final class RewriteModeService: ObservableObject { // Build LLMClient configuration var config = LLMClient.Config( + providerID: providerID, messages: apiMessages, model: model, baseURL: baseURL, @@ -438,13 +438,12 @@ final class RewriteModeService: ObservableObject { return "" } - private func providerFingerprint(baseURL: String, apiKey: String) -> String? { - let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedBase.isEmpty else { return nil } - let input = "\(trimmedBase)|\(trimmedKey)" - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private func providerFingerprint(providerID: String, baseURL: String, apiKey: String) -> String? { + OfficialProviderAuth.configurationFingerprint( + providerID: providerID, + baseURL: baseURL, + apiKey: apiKey + ) } private func isProviderVerified(_ providerID: String, settings: SettingsStore) -> Bool { @@ -453,7 +452,7 @@ final class RewriteModeService: ObservableObject { guard let stored = settings.verifiedProviderFingerprints[key] else { return false } let baseURL = self.providerBaseURL(for: providerID, settings: settings) let apiKey = settings.getAPIKey(for: providerID) ?? "" - let current = self.providerFingerprint(baseURL: baseURL, apiKey: apiKey) + let current = self.providerFingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) return current == stored } diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 08a869b1d..9d4c20420 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -1,6 +1,5 @@ import AppKit import Combine -import CryptoKit import Security import SwiftUI import UniformTypeIdentifiers @@ -60,6 +59,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var connectionErrorMessageByProvider: [String: String] = [:] @Published var fetchedModelsProviders: Set = [] @Published var editingAPIKeyProviders: Set = [] + @Published var signingInProviderID: String? = nil + @Published var oauthSignInMessageByProvider: [String: String] = [:] + @Published var oauthSignInURLByProvider: [String: URL] = [:] + private var officialProviderSignInTask: Task? // UI State @Published var showHelp: Bool = false @@ -275,6 +278,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return ModelRepository.shared.displayName(for: providerID) } + if ModelRepository.shared.isBuiltIn(providerID) { + return ModelRepository.shared.displayName(for: providerID) + } + switch providerID { case "": return "No Provider" case "openai": return "OpenAI" @@ -307,6 +314,14 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.connectionErrorMessageByProvider[providerID] ?? "" } + func oauthSignInMessage(for providerID: String) -> String { + self.oauthSignInMessageByProvider[providerID] ?? "" + } + + func oauthSignInURL(for providerID: String) -> URL? { + self.oauthSignInURLByProvider[providerID] + } + // MARK: - Provider Items Cache (for scroll performance) /// Refreshes the cached provider items. Call this when providers or connection status changes. @@ -734,6 +749,96 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // MARK: - API Connection Testing + func beginOfficialProviderSignIn(_ providerID: String) { + guard self.officialProviderSignInTask == nil else { return } + self.officialProviderSignInTask = Task { [weak self] in + await self?.signInWithOfficialProvider(providerID) + } + } + + func cancelOfficialProviderSignIn(_ providerID: String) { + guard self.signingInProviderID == providerID else { return } + self.officialProviderSignInTask?.cancel() + } + + private func signInWithOfficialProvider(_ providerID: String) async { + guard OfficialProviderAuth.supportsInAppSignIn(providerID), + self.signingInProviderID == nil + else { return } + + self.signingInProviderID = providerID + self.isTestingConnection = true + self.updateConnectionStatus(.testing, for: providerID) + + do { + switch providerID { + case GrokSubscriptionAuth.providerID: + let authorization = try await GrokSubscriptionAuth.requestDeviceAuthorization() + self.oauthSignInMessageByProvider[providerID] = + "Confirm code \(authorization.userCode) in your browser. FluidVoice will finish automatically after you approve." + self.oauthSignInURLByProvider[providerID] = authorization.browserURL + _ = NSWorkspace.shared.open(authorization.browserURL) + _ = try await GrokSubscriptionAuth.completeDeviceAuthorization(authorization) + + case CodexSubscriptionAuth.providerID: + let authorization = try await CodexSubscriptionAuth.requestDeviceAuthorization() + self.oauthSignInMessageByProvider[providerID] = + "Enter code \(authorization.userCode) on the ChatGPT sign-in page. FluidVoice will finish automatically after you approve." + self.oauthSignInURLByProvider[providerID] = authorization.browserURL + _ = NSWorkspace.shared.open(authorization.browserURL) + _ = try await CodexSubscriptionAuth.completeDeviceAuthorization(authorization) + + default: + return + } + self.oauthSignInMessageByProvider.removeValue(forKey: providerID) + self.oauthSignInURLByProvider.removeValue(forKey: providerID) + self.signingInProviderID = nil + self.officialProviderSignInTask = nil + self.isTestingConnection = false + + await self.testAPIConnection() + } catch is CancellationError { + self.oauthSignInMessageByProvider.removeValue(forKey: providerID) + self.oauthSignInURLByProvider.removeValue(forKey: providerID) + self.signingInProviderID = nil + self.officialProviderSignInTask = nil + self.isTestingConnection = false + self.updateConnectionStatus(.unknown, for: providerID) + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + self.oauthSignInMessageByProvider.removeValue(forKey: providerID) + self.oauthSignInURLByProvider.removeValue(forKey: providerID) + self.signingInProviderID = nil + self.officialProviderSignInTask = nil + self.isTestingConnection = false + self.updateConnectionStatus(.failed, for: providerID) + self.setConnectionError(message, for: providerID) + } + } + + func disconnectOfficialProvider(_ providerID: String) { + do { + switch providerID { + case GrokSubscriptionAuth.providerID: + try GrokSubscriptionAuth.disconnectFluidVoiceSession() + case CodexSubscriptionAuth.providerID: + try CodexSubscriptionAuth.disconnectFluidVoiceSession() + default: + return + } + self.oauthSignInMessageByProvider.removeValue(forKey: providerID) + self.oauthSignInURLByProvider.removeValue(forKey: providerID) + self.resetVerification(for: providerID) + } catch { + self.updateConnectionStatus(.failed, for: providerID) + self.setConnectionError( + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription, + for: providerID + ) + } + } + func testAPIConnection() async { guard !self.isTestingConnection else { return } @@ -758,7 +863,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return } - if !isLocal && apiKey.isEmpty { + if !isLocal && !OfficialProviderAuth.isOfficialProvider(providerID) && apiKey.isEmpty { await MainActor.run { self.updateConnectionStatus(.failed, for: providerID) self.setConnectionError("API key is required for \(providerName). Enter your API key above.", for: providerID) @@ -781,6 +886,41 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.updateConnectionStatus(.testing, for: providerID) } + if OfficialProviderAuth.isOfficialProvider(providerID) { + do { + var config = LLMClient.Config( + providerID: providerID, + messages: [["role": "user", "content": "Reply with OK."]], + model: trimmedModel, + baseURL: baseURL, + apiKey: "", + streaming: false, + temperature: nil, + maxTokens: 16 + ) + config.timeoutSeconds = 30 + config.maxRetries = 1 + _ = try await LLMClient.shared.call(config) + await MainActor.run { + self.setEditingAPIKey(false, for: providerID) + self.storeVerificationFingerprint(for: providerID, baseURL: baseURL, apiKey: "") + self.isTestingConnection = false + } + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + DebugLogger.shared.error( + "Official login verification failed for \(providerID): \(message)", + source: "AISettingsView" + ) + await MainActor.run { + self.updateConnectionStatus(.failed, for: providerID) + self.setConnectionError(message, for: providerID) + self.isTestingConnection = false + } + } + return + } + // Build the endpoint URL let endpoint = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) let fullURL: String @@ -1344,14 +1484,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return "" } - private func fingerprint(baseURL: String, apiKey: String) -> String? { - let trimmedBase = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) - // Only require baseURL - API key can be empty for local providers (Ollama, LM Studio, etc.) - guard !trimmedBase.isEmpty else { return nil } - let input = "\(trimmedBase)|\(trimmedKey)" - let digest = SHA256.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private func fingerprint(providerID: String, baseURL: String, apiKey: String) -> String? { + OfficialProviderAuth.configurationFingerprint( + providerID: providerID, + baseURL: baseURL, + apiKey: apiKey + ) } private func privateAIFingerprint(for modelID: String) -> String { @@ -1368,7 +1506,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } private func storeVerificationFingerprint(for providerID: String, baseURL: String, apiKey: String) { - guard let fingerprint = self.fingerprint(baseURL: baseURL, apiKey: apiKey) else { return } + guard let fingerprint = self.fingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) else { return } let key = self.providerKey(for: providerID) var fingerprints = self.settings.verifiedProviderFingerprints fingerprints[key] = fingerprint @@ -1382,7 +1520,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { guard let stored = self.settings.verifiedProviderFingerprints[key] else { return } let baseURL = self.providerBaseURL(for: providerID) let apiKey = self.providerAPIKey(for: providerID) - let current = self.fingerprint(baseURL: baseURL, apiKey: apiKey) + let current = self.fingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) if current != stored { self.settings.verifiedProviderFingerprints.removeValue(forKey: key) self.connectionStatusByProvider[providerID] = .unknown @@ -1416,7 +1554,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } let baseURL = self.providerBaseURL(for: providerID) let apiKey = self.providerAPIKey(for: providerID) - let current = self.fingerprint(baseURL: baseURL, apiKey: apiKey) + let current = self.fingerprint(providerID: providerID, baseURL: baseURL, apiKey: apiKey) if current == stored { statuses[providerID] = .success } else if statuses[providerID] == .success { diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 72b451118..b18f486c3 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -1321,13 +1321,14 @@ extension AIEnhancementSettingsView { let isCustom = !ModelRepository.shared.isBuiltIn(item.id) let baseURL = self.viewModel.openAIBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) let isLocal = self.viewModel.isLocalEndpoint(baseURL) + let isOfficialLogin = OfficialProviderAuth.isOfficialProvider(item.id) let apiKeyValue = self.viewModel.providerAPIKey(for: item.id) let hasAPIKey = !apiKeyValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let models = self.viewModel.availableModelsByProvider[providerKey] ?? [] let hasModels = !models.isEmpty let isRefreshing = self.viewModel.isFetchingModels && self.viewModel.selectedProviderID == item.id let hasName = isCustom ? !(self.viewModel.savedProviders.first { $0.id == item.id }?.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty : true - let canFetchModels = hasName && (isLocal ? !baseURL.isEmpty : (hasAPIKey && !baseURL.isEmpty)) + let canFetchModels = hasName && !baseURL.isEmpty && (isLocal || isOfficialLogin || hasAPIKey) let canVerify = hasModels && !self.viewModel.selectedModel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && canFetchModels let apiKeyBinding = Binding( get: { self.viewModel.providerAPIKey(for: item.id) }, @@ -1378,37 +1379,133 @@ extension AIEnhancementSettingsView { } } - VStack(alignment: .leading, spacing: 6) { - Text("API Key") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - HStack(alignment: .center, spacing: 8) { - SecureField("Enter API key", text: apiKeyBinding) - .textFieldStyle(.roundedBorder) - .font(.system(size: 13)) - .frame(maxWidth: 200) - .onTapGesture { - self.viewModel.ensureKeychainAccessForAPIKeyEdit() + if isOfficialLogin, let loginInfo = OfficialProviderAuth.info(for: item.id) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Image(systemName: "person.badge.key.fill") + .foregroundStyle(self.theme.palette.accent) + Text("Official client login") + .font(.system(size: 12, weight: .semibold)) + } + Text(loginInfo.detail) + .font(.caption) + .foregroundStyle(.secondary) + if OfficialProviderAuth.supportsInAppSignIn(item.id) { + Button(action: { + if self.viewModel.signingInProviderID == item.id { + self.viewModel.cancelOfficialProviderSignIn(item.id) + } else { + self.activateProvider(item.id) + self.viewModel.beginOfficialProviderSignIn(item.id) + } + }) { + HStack(spacing: 6) { + if self.viewModel.signingInProviderID == item.id { + Image(systemName: "xmark.circle.fill") + } else { + Image(systemName: "person.badge.key.fill") + } + Text( + self.viewModel.signingInProviderID == item.id + ? "Cancel Sign-In" + : (OfficialProviderAuth.inAppSignInLabel(for: item.id) ?? "Sign In") + ) + } } - if let websiteInfo = ModelRepository.shared.providerWebsiteURL(for: item.id), - let url = URL(string: websiteInfo.url) - { - Button(action: { NSWorkspace.shared.open(url) }) { - HStack(spacing: 4) { - Image(systemName: websiteInfo.label.contains("Guide") ? "book.fill" : "key.fill") - .font(.system(size: 10)) - Text(websiteInfo.label) + .fluidButton(.accent, size: .small) + .disabled( + (self.viewModel.signingInProviderID != nil && self.viewModel.signingInProviderID != item.id) || + (self.viewModel.isTestingConnection && self.viewModel.signingInProviderID != item.id) + ) + + let signInMessage = self.viewModel.oauthSignInMessage(for: item.id) + if !signInMessage.isEmpty { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(signInMessage) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let signInURL = self.viewModel.oauthSignInURL(for: item.id) { + Button("Open Sign-In Page") { NSWorkspace.shared.open(signInURL) } + .buttonStyle(.link) + } + } + } + + HStack(spacing: 8) { + Text("CLI fallback: \(loginInfo.setupCommand)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let url = URL(string: loginInfo.setupURL) { + Button(loginInfo.setupLabel) { NSWorkspace.shared.open(url) } + .buttonStyle(.link) .font(.system(size: 11, weight: .medium)) } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(self.theme.palette.accent) - ) - .foregroundStyle(.white) } - .buttonStyle(.plain) + } else { + Text("FluidVoice reads the current access session on demand and does not copy refresh tokens. Sign in with:") + .font(.caption) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(loginInfo.setupCommand) + .font(.system(size: 12, design: .monospaced)) + .textSelection(.enabled) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.theme.palette.contentBackground) + ) + if let url = URL(string: loginInfo.setupURL) { + Button(action: { NSWorkspace.shared.open(url) }) { + Label(loginInfo.setupLabel, systemImage: "book.fill") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.link) + } + } + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(self.theme.palette.accent.opacity(0.07)) + ) + } else { + VStack(alignment: .leading, spacing: 6) { + Text("API Key") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + HStack(alignment: .center, spacing: 8) { + SecureField("Enter API key", text: apiKeyBinding) + .textFieldStyle(.roundedBorder) + .font(.system(size: 13)) + .frame(maxWidth: 200) + .onTapGesture { + self.viewModel.ensureKeychainAccessForAPIKeyEdit() + } + if let websiteInfo = ModelRepository.shared.providerWebsiteURL(for: item.id), + let url = URL(string: websiteInfo.url) + { + Button(action: { NSWorkspace.shared.open(url) }) { + HStack(spacing: 4) { + Image(systemName: websiteInfo.label.contains("Guide") ? "book.fill" : "key.fill") + .font(.system(size: 10)) + Text(websiteInfo.label) + .font(.system(size: 11, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.theme.palette.accent) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + } } } } @@ -1628,7 +1725,8 @@ extension AIEnhancementSettingsView { let isLocal = self.viewModel.isLocalEndpoint(baseURL) let apiKeyValue = self.viewModel.providerAPIKey(for: item.id) let hasAPIKey = !apiKeyValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let canFetchModels = isLocal ? !baseURL.isEmpty : (hasAPIKey && !baseURL.isEmpty) + let canFetchModels = !baseURL.isEmpty && + (isLocal || OfficialProviderAuth.isOfficialProvider(item.id) || hasAPIKey) let hasModels = !models.isEmpty let isEditing = self.viewModel.showingEditProvider && self.viewModel.selectedProviderID == item.id let iconColumnWidth = AISettingsLayout.providerRowControlHeight @@ -1766,7 +1864,11 @@ extension AIEnhancementSettingsView { .background(self.theme.palette.separator.opacity(0.5)) .padding(.vertical, 10) - self.editProviderSection + if OfficialProviderAuth.isOfficialProvider(item.id) { + self.officialLoginEditSection(item) + } else { + self.editProviderSection + } } if !isPrivateAIProvider, @@ -2356,6 +2458,103 @@ extension AIEnhancementSettingsView { .transition(.opacity.combined(with: .scale(scale: 0.98))) } + @ViewBuilder + private func officialLoginEditSection(_ item: ProviderItem) -> some View { + if let info = OfficialProviderAuth.info(for: item.id) { + VStack(alignment: .leading, spacing: 12) { + Label("Official client login", systemImage: "person.badge.key.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(self.theme.palette.accent) + Text(info.detail) + .font(.caption) + .foregroundStyle(.secondary) + if OfficialProviderAuth.supportsInAppSignIn(item.id) { + Button(action: { + if self.viewModel.signingInProviderID == item.id { + self.viewModel.cancelOfficialProviderSignIn(item.id) + } else { + self.activateProvider(item.id) + self.viewModel.beginOfficialProviderSignIn(item.id) + } + }) { + HStack(spacing: 6) { + if self.viewModel.signingInProviderID == item.id { + Image(systemName: "xmark.circle.fill") + } else { + Image(systemName: "person.badge.key.fill") + } + Text( + self.viewModel.signingInProviderID == item.id + ? "Cancel Sign-In" + : (OfficialProviderAuth.inAppSignInLabel(for: item.id) ?? "Sign In") + ) + } + } + .fluidButton(.accent, size: .small) + .disabled( + (self.viewModel.signingInProviderID != nil && self.viewModel.signingInProviderID != item.id) || + (self.viewModel.isTestingConnection && self.viewModel.signingInProviderID != item.id) + ) + + let signInMessage = self.viewModel.oauthSignInMessage(for: item.id) + if !signInMessage.isEmpty { + Text(signInMessage) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Text("CLI fallback: \(info.setupCommand)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + Text(info.setupCommand) + .font(.system(size: 12, design: .monospaced)) + .textSelection(.enabled) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(self.theme.palette.contentBackground) + ) + } + HStack(spacing: 10) { + if let url = URL(string: info.setupURL) { + Button(info.setupLabel) { NSWorkspace.shared.open(url) } + .fluidButton(.glass, size: .compact) + } + Button("Verify Again") { + self.activateProvider(item.id) + Task { await self.viewModel.testAPIConnection() } + } + .fluidButton(.accent, size: .small) + .disabled(self.viewModel.isTestingConnection) + Button("Reset Verification") { + self.viewModel.resetVerification(for: item.id) + self.viewModel.clearEditProviderDraft() + } + .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) + if OfficialProviderAuth.supportsInAppSignIn(item.id) { + Button("Remove FluidVoice Login") { + self.viewModel.disconnectOfficialProvider(item.id) + self.viewModel.clearEditProviderDraft() + } + .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) + } + Spacer() + Button("Done") { self.viewModel.clearEditProviderDraft() } + .fluidButton(.compact, size: .compact) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.theme.palette.accent.opacity(0.06)) + ) + } + } + var appleIntelligenceBadge: some View { HStack(spacing: 8) { Image(systemName: "apple.logo").font(.system(size: 14)) diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index aaf71efcb..97ea0e301 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -1571,10 +1571,13 @@ final class DictationE2ETests: XCTestCase { let settings = SettingsStore.shared settings.selectedProviderID = "openai" settings.selectedModelByProvider = ["openai": "gpt-4.1", "ollama": "test-local-model"] + // Host-app tests can inherit an existing Keychain entry. Match the + // route's active configuration without changing or exposing the key. + let ollamaAPIKey = settings.getAPIKey(for: "ollama") ?? "" settings.verifiedProviderFingerprints = [ "ollama": DictationAIPostProcessingGate.providerFingerprint( baseURL: ModelRepository.shared.defaultBaseURL(for: "ollama"), - apiKey: "" + apiKey: ollamaAPIKey ) ?? "", ] settings.setDictationPromptSelection(.default, for: .primary) @@ -1647,10 +1650,13 @@ final class DictationE2ETests: XCTestCase { settings.dictationPromptRoutingScope = .allApps settings.selectedProviderID = "openai" settings.selectedModelByProvider = ["openai": "gpt-4.1", "ollama": "editor-model"] + // Host-app tests can inherit an existing Keychain entry. Match the + // route's active configuration without changing or exposing the key. + let ollamaAPIKey = settings.getAPIKey(for: "ollama") ?? "" settings.verifiedProviderFingerprints = [ "ollama": DictationAIPostProcessingGate.providerFingerprint( baseURL: ModelRepository.shared.defaultBaseURL(for: "ollama"), - apiKey: "" + apiKey: ollamaAPIKey ) ?? "", ] settings.setDictationPromptSelection(.default, for: .primary) diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 25add37b8..9fa99b502 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -53,6 +53,23 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(body["stream"] as? Bool, true) } + func testCodexSubscriptionForcesStreamingWithoutChangingOtherProviders() { + XCTAssertTrue( + LLMClient.effectiveStreaming( + providerID: OfficialProviderAuth.codexProviderID, + requested: false + ) + ) + XCTAssertFalse(LLMClient.effectiveStreaming(providerID: "openai", requested: false)) + XCTAssertFalse( + LLMClient.effectiveStreaming( + providerID: GrokSubscriptionAuth.providerID, + requested: false + ) + ) + XCTAssertTrue(LLMClient.effectiveStreaming(providerID: "openai", requested: true)) + } + // MARK: - Dictation custom prompt resolution func testCustomPromptOnly_omitsBasePromptFromEffectivePromptAndRequestBody() { @@ -157,6 +174,449 @@ final class LLMClientRequestBodyTests: XCTestCase { } } + // MARK: - Official provider OAuth adapters (#819) + + func testCodexCredentialDecoderImportsChatGPTSessionWithoutRefreshTokenExposure() throws { + let token = self.jwt(["sub": "user-123", "email": "person@example.com", "exp": 2_000_000_000]) + let data = try JSONSerialization.data(withJSONObject: [ + "auth_mode": "chatgpt", + "tokens": [ + "access_token": token, + "refresh_token": "owned-by-codex", + "account_id": "account-456", + ], + ]) + + let credential = try OfficialProviderAuth.decodeCodexCredential(from: data) + XCTAssertEqual(credential.accessToken, token) + XCTAssertEqual(credential.accountID, "account-456") + XCTAssertEqual(credential.accountLabel, "person@example.com") + } + + func testCodexDeviceAuthorizationDecodesPublicBrowserFlow() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let data = try JSONSerialization.data(withJSONObject: [ + "device_auth_id": "device-auth-secret", + "user_code": "WXYZ-9876", + "interval": "5", + ]) + + let authorization = try CodexSubscriptionAuth.decodeDeviceAuthorization(from: data, now: now) + XCTAssertEqual(authorization.userCode, "WXYZ-9876") + XCTAssertEqual(authorization.pollingInterval, 5) + XCTAssertEqual(authorization.expiresAt, now.addingTimeInterval(15 * 60)) + XCTAssertEqual(authorization.browserURL.absoluteString, "https://auth.openai.com/codex/device") + } + + func testCodexOAuthTokenDecoderPreservesRotatingSessionIdentity() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let idToken = self.jwt([ + "email": "chatgpt@example.com", + "https://api.openai.com/auth": ["chatgpt_account_id": "account-123"], + ]) + let initialData = try JSONSerialization.data(withJSONObject: [ + "id_token": idToken, + "access_token": "codex-access", + "refresh_token": "codex-refresh", + "expires_in": 3600, + ]) + + let initial = try CodexSubscriptionAuth.decodeOAuthSession(from: initialData, now: now) + XCTAssertEqual(initial.accountID, "account-123") + XCTAssertEqual(initial.accountLabel, "chatgpt@example.com") + XCTAssertEqual(initial.refreshToken, "codex-refresh") + + let refreshedData = try JSONSerialization.data(withJSONObject: [ + "access_token": "codex-access-rotated", + "expires_in": 1800, + ]) + let refreshed = try CodexSubscriptionAuth.decodeOAuthSession( + from: refreshedData, + previousRefreshToken: initial.refreshToken, + previousAccountID: initial.accountID, + previousEmail: initial.email, + now: now + ) + XCTAssertEqual(refreshed.accessToken, "codex-access-rotated") + XCTAssertEqual(refreshed.refreshToken, "codex-refresh") + XCTAssertEqual(refreshed.accountID, "account-123") + XCTAssertEqual(refreshed.accountLabel, "chatgpt@example.com") + } + + func testClaudeCredentialDecoderImportsOfficialCredentialShape() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "claudeAiOauth": [ + "accessToken": "claude-oauth-access", + "refreshToken": "owned-by-claude", + "expiresAt": 2_000_000_000_000, + "email": "claude@example.com", + ], + ]) + + let credential = try OfficialProviderAuth.decodeClaudeCredential(from: data) + XCTAssertEqual(credential.accessToken, "claude-oauth-access") + XCTAssertEqual(credential.accountLabel, "claude@example.com") + XCTAssertEqual(credential.expiresAt, Date(timeIntervalSince1970: 2_000_000_000)) + } + + func testGeminiCredentialDecoderSupportsLegacyCLIFile() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "access_token": "google-access", + "refresh_token": "owned-by-gemini", + "expiry_date": 2_000_000_000_000, + ]) + + let credential = try OfficialProviderAuth.decodeGeminiCredential( + from: data, + accountLabel: "google@example.com" + ) + XCTAssertEqual(credential.accessToken, "google-access") + XCTAssertEqual(credential.accountLabel, "google@example.com") + XCTAssertEqual(credential.expiresAt, Date(timeIntervalSince1970: 2_000_000_000)) + } + + func testGeminiProjectUsesOfficialCLIEnvironmentContract() throws { + XCTAssertEqual( + try OfficialProviderAuth.configuredGeminiProject(environment: [ + "GOOGLE_CLOUD_PROJECT": "workspace-project", + "GOOGLE_CLOUD_PROJECT_ID": "fallback-project", + ]), + "workspace-project" + ) + XCTAssertThrowsError( + try OfficialProviderAuth.configuredGeminiProject(environment: [ + "GOOGLE_CLOUD_PROJECT": "1234567890", + ]) + ) + } + + func testGrokCredentialDecoderSelectsOAuthSessionAndIgnoresAPIKey() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let data = try JSONSerialization.data(withJSONObject: [ + "https://api.x.ai::legacy": [ + "key": "xai-api-key", + "auth_mode": "api_key", + "create_time": "2027-01-01T00:00:00Z", + ], + "https://auth.x.ai::openid profile offline_access": [ + "key": "grok-oauth-access", + "auth_mode": "oidc", + "create_time": "2027-01-02T00:00:00Z", + "expires_at": "2027-02-02T00:00:00Z", + "oidc_issuer": "https://auth.x.ai", + "user_id": "grok-user-1", + "email": "grok@example.com", + ], + ]) + + let credential = try GrokSubscriptionAuth.decodeCredential( + from: data, + grokHome: URL(fileURLWithPath: "/tmp/test-grok-home", isDirectory: true), + now: now + ) + XCTAssertEqual(credential.accessToken, "grok-oauth-access") + XCTAssertEqual(credential.userID, "grok-user-1") + XCTAssertEqual(credential.accountLabel, "grok@example.com") + XCTAssertEqual(credential.requestHeaders["X-XAI-Token-Auth"], "xai-grok-cli") + XCTAssertEqual(credential.requestHeaders["x-grok-user-id"], "grok-user-1") + XCTAssertEqual(credential.requestHeaders["x-grok-client-identifier"], "fluidvoice") + XCTAssertEqual(ModelRepository.shared.defaultBaseURL(for: GrokSubscriptionAuth.providerID), GrokSubscriptionAuth.proxyBaseURL) + } + + func testGrokDeviceLoginKeepsTheOfficialSubscriptionRoutingContract() { + XCTAssertEqual( + GrokSubscriptionAuth.deviceAuthorizationScopes, + [ + "openid", + "profile", + "email", + "offline_access", + "grok-cli:access", + "api:access", + "conversations:read", + "conversations:write", + "workspaces:read", + "workspaces:write", + ] + ) + XCTAssertEqual(GrokSubscriptionAuth.deviceAuthorizationReferrer, "grok-build") + + let headers = GrokSubscriptionAuth.proxyRequestHeaders( + userID: "grok-user-2", + grokHome: URL(fileURLWithPath: "/tmp/no-grok-install", isDirectory: true) + ) + XCTAssertEqual(headers["X-XAI-Token-Auth"], "xai-grok-cli") + XCTAssertEqual(headers["x-authenticateresponse"], "authenticate-response") + XCTAssertEqual(headers["x-grok-client-mode"], "interactive") + XCTAssertEqual(headers["x-grok-client-identifier"], "fluidvoice") + XCTAssertEqual(headers["x-grok-user-id"], "grok-user-2") + XCTAssertEqual(headers["x-userid"], "grok-user-2") + } + + func testGrokDeviceAuthorizationBuildsSafeBrowserChallenge() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let data = try JSONSerialization.data(withJSONObject: [ + "device_code": "device-secret", + "user_code": "ABCD-1234", + "verification_uri": "https://auth.x.ai/device", + "expires_in": 600, + "interval": 5, + ]) + + let authorization = try GrokSubscriptionAuth.decodeDeviceAuthorization(from: data, now: now) + XCTAssertEqual(authorization.userCode, "ABCD-1234") + XCTAssertEqual(authorization.pollingInterval, 5) + XCTAssertEqual(authorization.expiresAt, now.addingTimeInterval(600)) + XCTAssertEqual( + authorization.browserURL.absoluteString, + "https://auth.x.ai/device?user_code=ABCD-1234" + ) + } + + func testGrokDeviceAuthorizationRejectsUntrustedVerificationURL() throws { + let data = try JSONSerialization.data(withJSONObject: [ + "device_code": "device-secret", + "user_code": "ABCD-1234", + "verification_uri": "https://phishing.invalid/device", + "expires_in": 600, + ]) + + XCTAssertThrowsError(try GrokSubscriptionAuth.decodeDeviceAuthorization(from: data)) + } + + func testGrokOAuthTokenDecoderRetainsRefreshTokenAndAccountIdentity() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let idToken = self.jwt([ + "sub": "grok-user-2", + "email": "signed-in@example.com", + ]) + let initialData = try JSONSerialization.data(withJSONObject: [ + "access_token": "fluidvoice-grok-access", + "refresh_token": "fluidvoice-grok-refresh", + "expires_in": 3600, + "scope": "openid offline_access api:access", + "id_token": idToken, + ]) + + let initial = try GrokSubscriptionAuth.decodeOAuthSession(from: initialData, now: now) + XCTAssertEqual(initial.accessToken, "fluidvoice-grok-access") + XCTAssertEqual(initial.refreshToken, "fluidvoice-grok-refresh") + XCTAssertEqual(initial.expiresAt, now.addingTimeInterval(3600)) + XCTAssertEqual(initial.userID, "grok-user-2") + XCTAssertEqual(initial.accountLabel, "signed-in@example.com") + + let refreshData = try JSONSerialization.data(withJSONObject: [ + "access_token": "rotated-access", + "expires_in": 1800, + ]) + let refreshed = try GrokSubscriptionAuth.decodeOAuthSession( + from: refreshData, + previousRefreshToken: initial.refreshToken, + now: now + ) + XCTAssertEqual(refreshed.accessToken, "rotated-access") + XCTAssertEqual(refreshed.refreshToken, "fluidvoice-grok-refresh") + } + + func testOfficialLoginFingerprintDoesNotContainOrDependOnRotatingAccessToken() { + let first = OfficialProviderAuth.configurationFingerprint( + providerID: GrokSubscriptionAuth.providerID, + baseURL: "https://one.invalid", + apiKey: "access-one" + ) + let second = OfficialProviderAuth.configurationFingerprint( + providerID: GrokSubscriptionAuth.providerID, + baseURL: "https://two.invalid", + apiKey: "access-two" + ) + XCTAssertEqual(first, second) + XCTAssertEqual(first, OfficialProviderAuth.verificationFingerprint(for: GrokSubscriptionAuth.providerID)) + } + + func testAnthropicMessagesBodyConvertsSystemToolsAndToolResults() throws { + let config = LLMClient.Config( + providerID: OfficialProviderAuth.claudeProviderID, + messages: [ + ["role": "system", "content": "Be concise."], + ["role": "user", "content": "List files"], + [ + "role": "assistant", + "content": "", + "tool_calls": [[ + "id": "call-1", + "type": "function", + "function": ["name": "shell", "arguments": "{\"command\":\"ls\"}"], + ]], + ], + ["role": "tool", "tool_call_id": "call-1", "content": "file.txt"], + ], + model: "claude-sonnet-4-6", + baseURL: "https://api.anthropic.com/v1", + apiKey: "", + streaming: false, + tools: [[ + "type": "function", + "function": [ + "name": "shell", + "description": "Run a command", + "parameters": ["type": "object"], + ], + ]], + maxTokens: 128 + ) + + let body = LLMClient.shared.buildAnthropicMessagesBody(config) + XCTAssertEqual(body["system"] as? String, "Be concise.") + XCTAssertEqual(body["max_tokens"] as? Int, 128) + XCTAssertEqual(body["stream"] as? Bool, false) + let messages = try XCTUnwrap(body["messages"] as? [[String: Any]]) + XCTAssertEqual(messages.count, 3) + let assistantBlocks = try XCTUnwrap(messages[1]["content"] as? [[String: Any]]) + XCTAssertEqual(assistantBlocks.first?["type"] as? String, "tool_use") + let resultBlocks = try XCTUnwrap(messages[2]["content"] as? [[String: Any]]) + XCTAssertEqual(resultBlocks.first?["type"] as? String, "tool_result") + let tools = try XCTUnwrap(body["tools"] as? [[String: Any]]) + XCTAssertEqual(tools.first?["name"] as? String, "shell") + } + + func testGeminiCodeAssistBodyWrapsProjectAndConvertsRoles() throws { + let config = LLMClient.Config( + providerID: OfficialProviderAuth.geminiProviderID, + messages: [ + ["role": "system", "content": "Be concise."], + ["role": "user", "content": "Hello"], + ["role": "assistant", "content": "Hi"], + ], + model: "gemini-2.5-flash", + baseURL: "https://cloudcode-pa.googleapis.com/v1internal", + apiKey: "", + streaming: true, + temperature: 0.2, + maxTokens: 64 + ) + + let body = LLMClient.shared.buildGeminiCodeAssistBody(config, project: "projects/example") + XCTAssertEqual(body["model"] as? String, "gemini-2.5-flash") + XCTAssertEqual(body["project"] as? String, "projects/example") + let request = try XCTUnwrap(body["request"] as? [String: Any]) + let system = try XCTUnwrap(request["systemInstruction"] as? [String: Any]) + let systemParts = try XCTUnwrap(system["parts"] as? [[String: Any]]) + XCTAssertEqual(systemParts.first?["text"] as? String, "Be concise.") + let contents = try XCTUnwrap(request["contents"] as? [[String: Any]]) + XCTAssertEqual(contents.map { $0["role"] as? String }, ["user", "model"]) + let generation = try XCTUnwrap(request["generationConfig"] as? [String: Any]) + XCTAssertEqual(generation["maxOutputTokens"] as? Int, 64) + } + + func testOfficialProvidersHaveBundledModelsAndBaseURLs() { + for providerID in OfficialProviderAuth.providerIDs { + XCTAssertTrue(ModelRepository.shared.isBuiltIn(providerID)) + XCTAssertFalse(ModelRepository.shared.defaultModels(for: providerID).isEmpty) + XCTAssertFalse(ModelRepository.shared.defaultBaseURL(for: providerID).isEmpty) + } + } + + func testAPIKeyProvidersRemainAvailableBesideOfficialLoginProviders() { + let providerPairs = [ + (apiKey: "openai", login: OfficialProviderAuth.codexProviderID), + (apiKey: "anthropic", login: OfficialProviderAuth.claudeProviderID), + (apiKey: "google", login: OfficialProviderAuth.geminiProviderID), + (apiKey: "xai", login: GrokSubscriptionAuth.providerID), + ] + let providerIDs = Set(ModelRepository.shared.builtInProvidersList().map(\.id)) + + for pair in providerPairs { + XCTAssertNotEqual(pair.apiKey, pair.login) + XCTAssertTrue(providerIDs.contains(pair.apiKey)) + XCTAssertTrue(providerIDs.contains(pair.login)) + XCTAssertEqual(ModelRepository.shared.providerWebsiteURL(for: pair.apiKey)?.label, "Get API Key") + } + } + + func testAPIKeyFingerprintStillTracksTheConfiguredKeyAndBaseURL() { + let original = OfficialProviderAuth.configurationFingerprint( + providerID: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test-one" + ) + let changedKey = OfficialProviderAuth.configurationFingerprint( + providerID: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test-two" + ) + let changedBaseURL = OfficialProviderAuth.configurationFingerprint( + providerID: "openai", + baseURL: "https://gateway.example/v1", + apiKey: "sk-test-one" + ) + + XCTAssertNotNil(original) + XCTAssertNotEqual(original, changedKey) + XCTAssertNotEqual(original, changedBaseURL) + } + + func testCodexLoginUsesInstructionsWithoutChangingOpenAIAPIKeyResponsesBody() throws { + let messages: [[String: Any]] = [ + ["role": "system", "content": "Be concise."], + ["role": "user", "content": "Hello"], + ] + let apiKeyConfig = LLMClient.Config( + providerID: "openai", + messages: messages, + model: "gpt-4.1", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test", + streaming: false + ) + let loginConfig = LLMClient.Config( + providerID: OfficialProviderAuth.codexProviderID, + messages: messages, + model: "gpt-5.6-sol", + baseURL: "https://chatgpt.com/backend-api/codex", + apiKey: "", + streaming: false + ) + + let apiKeyBody = LLMClient.shared.buildResponsesBody(apiKeyConfig) + XCTAssertNil(apiKeyBody["instructions"]) + let apiKeyInput = try XCTUnwrap(apiKeyBody["input"] as? [[String: Any]]) + XCTAssertEqual(apiKeyInput.first?["role"] as? String, "system") + + let loginBody = LLMClient.shared.buildResponsesBody(loginConfig) + XCTAssertEqual(loginBody["instructions"] as? String, "Be concise.") + let loginInput = try XCTUnwrap(loginBody["input"] as? [[String: Any]]) + XCTAssertEqual(loginInput.count, 1) + XCTAssertEqual(loginInput.first?["role"] as? String, "user") + } + + func testCodexLoginOmitsUnsupportedOutputTokenLimitWithoutChangingAPIKeyResponsesBody() { + let messages: [[String: Any]] = [["role": "user", "content": "Reply with OK."]] + let apiKeyConfig = LLMClient.Config( + providerID: "openai", + messages: messages, + model: "gpt-5", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-test", + streaming: true, + maxTokens: 16 + ) + let loginConfig = LLMClient.Config( + providerID: OfficialProviderAuth.codexProviderID, + messages: messages, + model: "gpt-5.6-luna", + baseURL: "https://chatgpt.com/backend-api/codex", + apiKey: "", + streaming: true, + maxTokens: 16 + ) + + let apiKeyBody = LLMClient.shared.buildResponsesBody(apiKeyConfig) + XCTAssertEqual(apiKeyBody["max_output_tokens"] as? Int, 16) + + let loginBody = LLMClient.shared.buildResponsesBody(loginConfig) + XCTAssertNil(loginBody["max_output_tokens"]) + } + private static let basePromptMarker = "You are a voice-to-text dictation cleaner" private func resetPromptSettings(_ settings: SettingsStore) { @@ -196,4 +656,15 @@ final class LLMClientRequestBodyTests: XCTestCase { guard let messages = body["messages"] as? [[String: Any]] else { return [] } return messages.compactMap { $0["content"] as? String } } + + private func jwt(_ claims: [String: Any]) -> String { + let header = Data("{\"alg\":\"none\"}".utf8).base64EncodedString() + let payload = (try? JSONSerialization.data(withJSONObject: claims).base64EncodedString()) ?? "" + func base64URL(_ value: String) -> String { + value.replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(base64URL(header)).\(base64URL(payload)).signature" + } } From e9b2e4ee51e5b3bda0b578167f0e2c0a3e34231c Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 18:00:20 -0400 Subject: [PATCH 2/9] Address OAuth provider review feedback --- .../Services/CodexSubscriptionAuth.swift | 38 +++-- .../Fluid/Services/CommandModeService.swift | 33 +++- .../Fluid/Services/GrokSubscriptionAuth.swift | 33 ++-- Sources/Fluid/Services/LLMClient.swift | 159 ++++++++++++++---- .../Fluid/Services/OfficialProviderAuth.swift | 37 ++-- .../AIEnhancementSettingsViewModel.swift | 63 +++++-- .../LLMClientRequestBodyTests.swift | 50 ++++++ 7 files changed, 321 insertions(+), 92 deletions(-) diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift index d661ae68f..9a807eab4 100644 --- a/Sources/Fluid/Services/CodexSubscriptionAuth.swift +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -35,7 +35,9 @@ enum CodexSubscriptionAuth { var accountLabel: String { let trimmedEmail = self.email?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedEmail.isEmpty { return trimmedEmail } + if !trimmedEmail.isEmpty { + return trimmedEmail + } let trimmedAccount = self.accountID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmedAccount.isEmpty ? "ChatGPT account" : trimmedAccount } @@ -192,7 +194,9 @@ enum CodexSubscriptionAuth { !deviceToken.authorizationCode.isEmpty, !deviceToken.codeVerifier.isEmpty else { - if http.statusCode == 401 { throw AuthError.authorizationDenied } + if http.statusCode == 401 { + throw AuthError.authorizationDenied + } throw AuthError.requestFailed(http.statusCode) } @@ -225,13 +229,13 @@ enum CodexSubscriptionAuth { throw AuthError.invalidOAuthResponse } - let idClaims = response.idToken.flatMap { self.jwtClaims(from: $0) } + let idClaims = response.idToken.map { self.jwtClaims(from: $0) } ?? [:] let accessClaims = self.jwtClaims(from: accessToken) let accountID = self.accountID(in: idClaims) ?? self.accountID(in: accessClaims) ?? previousAccountID - let email = (idClaims?["email"] as? String) - ?? (accessClaims?["email"] as? String) + let email = (idClaims["email"] as? String) + ?? (accessClaims["email"] as? String) ?? previousEmail let lifetime = min(max(response.expiresIn ?? 3600, 60), 24 * 60 * 60) @@ -344,7 +348,9 @@ enum CodexSubscriptionAuth { query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecItemNotFound { return nil } + if status == errSecItemNotFound { + return nil + } guard status == errSecSuccess, let data = item as? Data, let oauthSession = try? JSONDecoder().decode(OAuthSession.self, from: data) @@ -362,7 +368,9 @@ enum CodexSubscriptionAuth { attributes[kSecValueData as String] = data attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(attributes as CFDictionary, nil) - if status == errSecSuccess { return } + if status == errSecSuccess { + return + } if status == errSecDuplicateItem { let updateStatus = SecItemUpdate( self.keychainQuery() as CFDictionary, @@ -384,17 +392,17 @@ enum CodexSubscriptionAuth { ] } - private static func accountID(in claims: [String: Any]?) -> String? { - if let direct = claims?["chatgpt_account_id"] as? String, !direct.isEmpty { + private static func accountID(in claims: [String: Any]) -> String? { + if let direct = claims["chatgpt_account_id"] as? String, !direct.isEmpty { return direct } - if let auth = claims?["https://api.openai.com/auth"] as? [String: Any], + if let auth = claims["https://api.openai.com/auth"] as? [String: Any], let nested = auth["chatgpt_account_id"] as? String, !nested.isEmpty { return nested } - if let organizations = claims?["organizations"] as? [[String: Any]], + if let organizations = claims["organizations"] as? [[String: Any]], let first = organizations.first?["id"] as? String, !first.isEmpty { @@ -403,15 +411,15 @@ enum CodexSubscriptionAuth { return nil } - private static func jwtClaims(from token: String) -> [String: Any]? { + private static func jwtClaims(from token: String) -> [String: Any] { let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count >= 2 else { return nil } + guard segments.count >= 2 else { return [:] } var payload = String(segments[1]) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) - guard let data = Data(base64Encoded: payload) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + guard let data = Data(base64Encoded: payload) else { return [:] } + return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] } private static func formEncoded(_ values: [String: String]) -> Data? { diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index f65d54978..d67c2e5e5 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -17,7 +17,7 @@ final class CommandModeService: ObservableObject { private let maxTurns = 20 private var didRequireConfirmationThisRun: Bool = false - // Flag to enable notch output display + /// Flag to enable notch output display var enableNotchOutput: Bool = true // Streaming UI update throttling - adaptive rate based on content length @@ -68,6 +68,7 @@ final class CommandModeService: ObservableObject { let content: String let thinking: String? // Display-only: AI reasoning tokens (NOT sent to API) let toolCall: ToolCall? + let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] let stepType: StepType let timestamp: Date @@ -94,11 +95,19 @@ final class CommandModeService: ObservableObject { let purpose: String? // Why this command is being run } - init(role: Role, content: String, thinking: String? = nil, toolCall: ToolCall? = nil, stepType: StepType = .normal) { + init( + role: Role, + content: String, + thinking: String? = nil, + toolCall: ToolCall? = nil, + responsesContinuationItems: [LLMClient.ResponsesContinuationItem] = [], + stepType: StepType = .normal + ) { self.role = role self.content = content self.thinking = thinking self.toolCall = toolCall + self.responsesContinuationItems = responsesContinuationItems self.stepType = stepType self.timestamp = Date() } @@ -297,7 +306,9 @@ final class CommandModeService: ObservableObject { } // Skip tool outputs in notch (they're verbose) - if msg.role == .tool { continue } + if msg.role == .tool { + continue + } NotchContentState.shared.addCommandMessage(role: role, content: msg.content) } @@ -422,6 +433,7 @@ final class CommandModeService: ObservableObject { .workingDirectory, purpose: tc.purpose ), + responsesContinuationItems: response.responsesContinuationItems, stepType: stepType )) @@ -704,6 +716,7 @@ final class CommandModeService: ObservableObject { let content: String let thinking: String? // Display-only, NOT sent back to API let toolCall: ToolCallData? + let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] struct ToolCallData { let id: String @@ -839,7 +852,7 @@ final class CommandModeService: ObservableObject { DebugLogger.shared.error("Failed to encode tool call args: \(error)", source: "CommandModeService") argsJSON = "{}" } - messages.append([ + var assistantMessage: [String: Any] = [ "role": "assistant", "content": msg.content, "tool_calls": [[ @@ -850,7 +863,11 @@ final class CommandModeService: ObservableObject { "arguments": argsJSON, ], ]], - ]) + ] + if !msg.responsesContinuationItems.isEmpty { + assistantMessage["responses_continuation_items"] = msg.responsesContinuationItems.map(\.inputItem) + } + messages.append(assistantMessage) } else { messages.append(["role": "assistant", "content": msg.content]) } @@ -998,7 +1015,8 @@ final class CommandModeService: ObservableObject { command: command, workingDirectory: workDir, purpose: purpose - ) + ), + responsesContinuationItems: response.responsesContinuationItems ) } @@ -1014,7 +1032,8 @@ final class CommandModeService: ObservableObject { return LLMResponse( content: response.content, thinking: finalThinking, // Display-only - toolCall: nil + toolCall: nil, + responsesContinuationItems: response.responsesContinuationItems ) } } diff --git a/Sources/Fluid/Services/GrokSubscriptionAuth.swift b/Sources/Fluid/Services/GrokSubscriptionAuth.swift index 342a31cd7..3aeb9f631 100644 --- a/Sources/Fluid/Services/GrokSubscriptionAuth.swift +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -348,6 +348,8 @@ enum GrokSubscriptionAuth { static func decodeOAuthSession( from data: Data, previousRefreshToken: String? = nil, + previousUserID: String? = nil, + previousEmail: String? = nil, now: Date = Date() ) throws -> OAuthSession { guard let response = try? JSONDecoder().decode(OAuthTokenResponse.self, from: data) else { @@ -358,10 +360,13 @@ enum GrokSubscriptionAuth { throw AuthError.invalidOAuthResponse } - let claims = response.idToken.flatMap(self.jwtClaims) - ?? self.jwtClaims(from: accessToken) - let userID = (claims?["sub"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let email = (claims?["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let idClaims = response.idToken.map(self.jwtClaims) ?? [:] + let accessClaims = self.jwtClaims(from: accessToken) + let claims = idClaims.isEmpty ? accessClaims : idClaims + let claimedUserID = (claims["sub"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let userID = claimedUserID.isEmpty ? (previousUserID ?? "") : claimedUserID + let claimedEmail = (claims["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let email = claimedEmail.isEmpty ? previousEmail : claimedEmail let responseRefreshToken = response.refreshToken?.trimmingCharacters(in: .whitespacesAndNewlines) let refreshToken = responseRefreshToken?.isEmpty == false ? responseRefreshToken : previousRefreshToken let lifetime = min(max(response.expiresIn ?? 3600, 60), 24 * 60 * 60) @@ -569,6 +574,8 @@ enum GrokSubscriptionAuth { return try self.decodeOAuthSession( from: data, previousRefreshToken: refreshToken, + previousUserID: oauthSession.userID, + previousEmail: oauthSession.email, now: now ) } @@ -580,7 +587,9 @@ enum GrokSubscriptionAuth { var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecItemNotFound { return nil } + if status == errSecItemNotFound { + return nil + } guard status == errSecSuccess, let data = item as? Data else { throw AuthError.keychainFailure(status) } @@ -602,7 +611,9 @@ enum GrokSubscriptionAuth { attributes[kSecValueData as String] = data attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly let status = SecItemAdd(attributes as CFDictionary, nil) - if status == errSecSuccess { return } + if status == errSecSuccess { + return + } if status == errSecDuplicateItem { let updateStatus = SecItemUpdate( self.oauthKeychainQuery() as CFDictionary, @@ -650,16 +661,16 @@ enum GrokSubscriptionAuth { return host == "x.ai" || host.hasSuffix(".x.ai") } - nonisolated private static func jwtClaims(from token: String) -> [String: Any]? { + private nonisolated static func jwtClaims(from token: String) -> [String: Any] { let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count >= 2 else { return nil } + guard segments.count >= 2 else { return [:] } var payload = String(segments[1]) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) - guard let data = Data(base64Encoded: payload) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + guard let data = Data(base64Encoded: payload) else { return [:] } + return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] } private static func formEncoded(_ values: [String: String]) -> Data? { @@ -675,7 +686,7 @@ enum GrokSubscriptionAuth { return body.data(using: .utf8) } - nonisolated private static func parseRFC3339(_ value: String) -> Date? { + private nonisolated static func parseRFC3339(_ value: String) -> Date? { let fractional = ISO8601DateFormatter() fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = fractional.date(from: value) { diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index 979f43569..58ffa8fd2 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -84,6 +84,34 @@ final class LLMClient { let content: String /// Parsed tool calls for agentic modes (nil if none) let toolCalls: [ToolCall] + /// Opaque reasoning state required to continue stateless Responses API tool loops. + let responsesContinuationItems: [ResponsesContinuationItem] + + init( + thinking: String?, + content: String, + toolCalls: [ToolCall], + responsesContinuationItems: [ResponsesContinuationItem] = [] + ) { + self.thinking = thinking + self.content = content + self.toolCalls = toolCalls + self.responsesContinuationItems = responsesContinuationItems + } + } + + struct ResponsesContinuationItem: Equatable { + let id: String + let encryptedContent: String + + var inputItem: [String: Any] { + [ + "type": "reasoning", + "id": self.id, + "summary": [], + "encrypted_content": self.encryptedContent, + ] + } } struct ToolCall { @@ -444,7 +472,9 @@ final class LLMClient { ] if usesCodexSubscription { - if !systemInstructions.isEmpty { body["instructions"] = systemInstructions } + if !systemInstructions.isEmpty { + body["instructions"] = systemInstructions + } body["tool_choice"] = "auto" body["parallel_tool_calls"] = false body["include"] = ["reasoning.encrypted_content"] @@ -501,7 +531,9 @@ final class LLMClient { let role = message["role"] as? String ?? "user" let content = message["content"] as? String ?? "" if role == "system" { - if !content.isEmpty { systemParts.append(content) } + if !content.isEmpty { + systemParts.append(content) + } continue } if role == "tool" { @@ -549,18 +581,20 @@ final class LLMClient { body["temperature"] = temperature } if !config.tools.isEmpty { - body["tools"] = config.tools.compactMap { tool -> [String: Any]? in + var convertedTools: [[String: Any]] = [] + for tool in config.tools { guard tool["type"] as? String == "function", let function = tool["function"] as? [String: Any], let name = function["name"] as? String, let parameters = function["parameters"] as? [String: Any] - else { return nil } + else { continue } var converted: [String: Any] = ["name": name, "input_schema": parameters] if let description = function["description"] as? String { converted["description"] = description } - return converted + convertedTools.append(converted) } + body["tools"] = convertedTools } for (name, value) in config.extraParameters where name != "reasoning_effort" { body[name] = value @@ -601,7 +635,9 @@ final class LLMClient { let role = message["role"] as? String ?? "user" let content = message["content"] as? String ?? "" if role == "system" { - if !content.isEmpty { systemParts.append(content) } + if !content.isEmpty { + systemParts.append(content) + } continue } if role == "tool" { @@ -616,7 +652,9 @@ final class LLMClient { } var parts: [[String: Any]] = [] - if !content.isEmpty { parts.append(["text": content]) } + if !content.isEmpty { + parts.append(["text": content]) + } if let toolCalls = message["tool_calls"] as? [[String: Any]] { for toolCall in toolCalls { guard let function = toolCall["function"] as? [String: Any], @@ -640,27 +678,36 @@ final class LLMClient { } var generationConfig: [String: Any] = [:] - if let temperature = config.temperature { generationConfig["temperature"] = temperature } - if let maxTokens = config.maxTokens { generationConfig["maxOutputTokens"] = maxTokens } + if let temperature = config.temperature { + generationConfig["temperature"] = temperature + } + if let maxTokens = config.maxTokens { + generationConfig["maxOutputTokens"] = maxTokens + } for (name, value) in config.extraParameters where name != "reasoning_effort" { generationConfig[name] = value } - if !generationConfig.isEmpty { request["generationConfig"] = generationConfig } + if !generationConfig.isEmpty { + request["generationConfig"] = generationConfig + } if !config.tools.isEmpty { - let declarations = config.tools.compactMap { tool -> [String: Any]? in + var declarations: [[String: Any]] = [] + for tool in config.tools { guard tool["type"] as? String == "function", let function = tool["function"] as? [String: Any], let name = function["name"] as? String, let parameters = function["parameters"] as? [String: Any] - else { return nil } + else { continue } var declaration: [String: Any] = ["name": name, "parameters": parameters] if let description = function["description"] as? String { declaration["description"] = description } - return declaration + declarations.append(declaration) + } + if !declarations.isEmpty { + request["tools"] = [["functionDeclarations": declarations]] } - if !declarations.isEmpty { request["tools"] = [["functionDeclarations": declarations]] } } return [ @@ -712,7 +759,13 @@ final class LLMClient { for message in messages { let role = message["role"] as? String ?? "user" - if excludingSystemMessages, role == "system" { continue } + if excludingSystemMessages, role == "system" { + continue + } + + if let continuationItems = message["responses_continuation_items"] as? [[String: Any]] { + input.append(contentsOf: continuationItems) + } if role == "tool" { input.append([ @@ -792,7 +845,9 @@ final class LLMClient { let (bytes, response) = try await self.session.bytes(for: request) if let http = response as? HTTPURLResponse, http.statusCode >= 400 { var errorData = Data() - for try await byte in bytes { errorData.append(byte) } + for try await byte in bytes { + errorData.append(byte) + } throw LLMError.httpError(http.statusCode, String(data: errorData, encoding: .utf8) ?? "Unknown error") } @@ -826,9 +881,13 @@ final class LLMClient { call.arguments = inputString } toolCalls[index] = call - if let name = call.name { config.onToolCallStart?(name) } + if let name = call.name { + config.onToolCallStart?(name) + } case "thinking": - if !isThinking { config.onThinkingStart?() } + if !isThinking { + config.onThinkingStart?() + } isThinking = true default: break @@ -846,7 +905,9 @@ final class LLMClient { config.onContentChunk?(text) } case "thinking_delta": - if !isThinking { config.onThinkingStart?() } + if !isThinking { + config.onThinkingStart?() + } isThinking = true if let text = delta["thinking"] as? String { thinking.append(text) @@ -863,7 +924,9 @@ final class LLMClient { continue } } - if isThinking { config.onThinkingEnd?() } + if isThinking { + config.onThinkingEnd?() + } let parsedTools = toolCalls.keys.sorted().compactMap { index -> ToolCall? in guard let call = toolCalls[index], let name = call.name else { return nil } @@ -889,7 +952,9 @@ final class LLMClient { let (bytes, response) = try await self.session.bytes(for: request) if let http = response as? HTTPURLResponse, http.statusCode >= 400 { var errorData = Data() - for try await byte in bytes { errorData.append(byte) } + for try await byte in bytes { + errorData.append(byte) + } throw LLMError.httpError(http.statusCode, String(data: errorData, encoding: .utf8) ?? "Unknown error") } @@ -912,7 +977,9 @@ final class LLMClient { config.onContentChunk?(text) } for text in parsed.thinking { - if !reportedThinking { config.onThinkingStart?() } + if !reportedThinking { + config.onThinkingStart?() + } reportedThinking = true thinking.append(text) config.onThinkingChunk?(text) @@ -922,7 +989,9 @@ final class LLMClient { config.onToolCallStart?(tool.name) } } - if reportedThinking { config.onThinkingEnd?() } + if reportedThinking { + config.onThinkingEnd?() + } let thinkingText = thinking.joined().trimmingCharacters(in: .whitespacesAndNewlines) return Response( thinking: thinkingText.isEmpty ? nil : thinkingText, @@ -947,6 +1016,7 @@ final class LLMClient { var contentBuffer: [String] = [] var toolCallsByIndex: [Int: ResponsesToolCallAccumulator] = [:] + var continuationItemsByIndex: [Int: ResponsesContinuationItem] = [:] for try await rawLine in bytes.lines { let line = rawLine.trimmingCharacters(in: .whitespaces) @@ -974,10 +1044,15 @@ final class LLMClient { config.onContentChunk?(delta) } case "response.output_item.added", "response.output_item.done": - guard let item = event["item"] as? [String: Any], - item["type"] as? String == "function_call" - else { continue } + guard let item = event["item"] as? [String: Any] else { continue } let index = event["output_index"] as? Int ?? 0 + if type == "response.output_item.done", + let continuationItem = self.responsesContinuationItem(from: item) + { + continuationItemsByIndex[index] = continuationItem + continue + } + guard item["type"] as? String == "function_call" else { continue } var call = toolCallsByIndex[index] ?? ResponsesToolCallAccumulator() call.id = item["id"] as? String ?? call.id call.callID = item["call_id"] as? String ?? call.callID @@ -1030,7 +1105,10 @@ final class LLMClient { return Response( thinking: nil, content: contentBuffer.joined().trimmingCharacters(in: .whitespacesAndNewlines), - toolCalls: toolCalls + toolCalls: toolCalls, + responsesContinuationItems: continuationItemsByIndex.keys.sorted().compactMap { + continuationItemsByIndex[$0] + } ) } @@ -1242,9 +1320,13 @@ final class LLMClient { for block in blocks { switch block["type"] as? String { case "text": - if let text = block["text"] as? String { content.append(text) } + if let text = block["text"] as? String { + content.append(text) + } case "thinking": - if let text = block["thinking"] as? String { thinking.append(text) } + if let text = block["thinking"] as? String { + thinking.append(text) + } case "tool_use": guard let name = block["name"] as? String, let input = block["input"] as? [String: Any] @@ -1323,6 +1405,7 @@ final class LLMClient { var contentParts: [String] = [] var parsedToolCalls: [ToolCall] = [] + var continuationItems: [ResponsesContinuationItem] = [] for item in output { switch item["type"] as? String { @@ -1349,6 +1432,10 @@ final class LLMClient { arguments: args ) ) + case "reasoning": + if let continuationItem = self.responsesContinuationItem(from: item) { + continuationItems.append(continuationItem) + } default: continue } @@ -1360,10 +1447,22 @@ final class LLMClient { return Response( thinking: thinking.isEmpty ? nil : thinking, content: cleanedContent.isEmpty ? rawContent.trimmingCharacters(in: .whitespacesAndNewlines) : cleanedContent, - toolCalls: parsedToolCalls + toolCalls: parsedToolCalls, + responsesContinuationItems: continuationItems ) } + func responsesContinuationItem(from item: [String: Any]) -> ResponsesContinuationItem? { + guard item["type"] as? String == "reasoning", + let id = item["id"] as? String, + !id.isEmpty, + let encryptedContent = item["encrypted_content"] as? String, + !encryptedContent.isEmpty + else { return nil } + + return ResponsesContinuationItem(id: id, encryptedContent: encryptedContent) + } + private func parseMessageResponse(_ message: [String: Any]) -> Response { // Extract content let rawContent = message["content"] as? String ?? "" diff --git a/Sources/Fluid/Services/OfficialProviderAuth.swift b/Sources/Fluid/Services/OfficialProviderAuth.swift index 20060f1ed..88ac02409 100644 --- a/Sources/Fluid/Services/OfficialProviderAuth.swift +++ b/Sources/Fluid/Services/OfficialProviderAuth.swift @@ -118,7 +118,7 @@ enum OfficialProviderAuth { return ProviderInfo( displayName: "ChatGPT (Codex Login)", setupURL: "https://developers.openai.com/codex/auth/", - setupLabel: "Codex login guide", + setupLabel: "Codex Login Guide", setupCommand: "codex login", detail: "Sign in directly with ChatGPT, or reuse a session owned by the official Codex client." ) @@ -126,7 +126,7 @@ enum OfficialProviderAuth { return ProviderInfo( displayName: "Claude Subscription", setupURL: "https://code.claude.com/docs/en/authentication", - setupLabel: "Claude login guide", + setupLabel: "Claude Login Guide", setupCommand: "claude", detail: "Uses the subscription session owned by the official Claude client." ) @@ -134,7 +134,7 @@ enum OfficialProviderAuth { return ProviderInfo( displayName: "Gemini Subscription", setupURL: "https://github.com/google-gemini/gemini-cli#authentication-options", - setupLabel: "Gemini login guide", + setupLabel: "Gemini Login Guide", setupCommand: "gemini", detail: "Uses the Google session owned by the official Gemini CLI." ) @@ -142,7 +142,7 @@ enum OfficialProviderAuth { return ProviderInfo( displayName: "Grok Subscription", setupURL: "https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md", - setupLabel: "Grok login guide", + setupLabel: "Grok Login Guide", setupCommand: "grok login --oauth", detail: "Sign in directly with xAI, or reuse a session owned by the official Grok client." ) @@ -280,7 +280,7 @@ enum OfficialProviderAuth { let idToken = tokens["id_token"] as? String let accessClaims = self.jwtClaims(from: accessToken) - let idClaims = idToken.flatMap(self.jwtClaims) + let idClaims = idToken.map(self.jwtClaims) ?? [:] let accountID = (tokens["account_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let email = self.firstString(in: [idClaims, accessClaims], keys: ["email"]) let subject = self.firstString(in: [idClaims, accessClaims], keys: ["sub"]) @@ -331,11 +331,12 @@ enum OfficialProviderAuth { } let label = (oauth["email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedLabel = label.flatMap { $0.isEmpty ? nil : $0 } ?? accountLabel let expiresAt = self.dateFromMillisecondsOrSeconds(oauth["expiresAt"] ?? oauth["expires_at"]) ?? self.jwtExpiry(from: self.jwtClaims(from: accessToken)) return ClaudeCredential( accessToken: accessToken, - accountLabel: (label?.isEmpty == false ? label! : accountLabel), + accountLabel: resolvedLabel, expiresAt: expiresAt ) } @@ -536,7 +537,9 @@ enum OfficialProviderAuth { var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecItemNotFound { return nil } + if status == errSecItemNotFound { + return nil + } guard status == errSecSuccess, let dictionary = item as? [String: Any], let data = dictionary[kSecValueData as String] as? Data @@ -563,26 +566,26 @@ enum OfficialProviderAuth { return data } - private static func jwtClaims(from token: String) -> [String: Any]? { + private nonisolated static func jwtClaims(from token: String) -> [String: Any] { let segments = token.split(separator: ".", omittingEmptySubsequences: false) - guard segments.count >= 2 else { return nil } + guard segments.count >= 2 else { return [:] } var payload = String(segments[1]) .replacingOccurrences(of: "-", with: "+") .replacingOccurrences(of: "_", with: "/") payload.append(String(repeating: "=", count: (4 - payload.count % 4) % 4)) - guard let data = Data(base64Encoded: payload) else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + guard let data = Data(base64Encoded: payload) else { return [:] } + return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] } - private static func jwtExpiry(from claims: [String: Any]?) -> Date? { - guard let timestamp = (claims?["exp"] as? NSNumber)?.doubleValue else { return nil } + private nonisolated static func jwtExpiry(from claims: [String: Any]) -> Date? { + guard let timestamp = (claims["exp"] as? NSNumber)?.doubleValue else { return nil } return Date(timeIntervalSince1970: timestamp) } - private static func firstString(in objects: [[String: Any]?], keys: [String]) -> String? { + private nonisolated static func firstString(in objects: [[String: Any]], keys: [String]) -> String? { for object in objects { for key in keys { - if let value = object?[key] as? String, + if let value = object[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -602,7 +605,9 @@ enum OfficialProviderAuth { number = nil } guard var timestamp = number, timestamp > 0 else { return nil } - if timestamp > 10_000_000_000 { timestamp /= 1000 } + if timestamp > 10_000_000_000 { + timestamp /= 1000 + } return Date(timeIntervalSince1970: timestamp) } diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index 9d4c20420..a6e438e3a 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -191,7 +191,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { newKey = key.hasPrefix("custom:") ? key : "custom:\(key)" } let clean = Array(Set(models.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })).sorted() - if !clean.isEmpty { normalized[newKey] = clean } + if !clean.isEmpty { + normalized[newKey] = clean + } } self.availableModelsByProvider = normalized self.settings.availableModelsByProvider = normalized @@ -203,7 +205,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // Use ModelRepository to correctly identify ALL built-in providers let newKey: String = ModelRepository.shared.isBuiltIn(lower) ? lower : (key.hasPrefix("custom:") ? key : "custom:\(key)") - if let list = normalized[newKey], list.contains(model) { normalizedSel[newKey] = model } + if let list = normalized[newKey], list.contains(model) { + normalizedSel[newKey] = model + } } self.selectedModelByProvider = normalizedSel self.settings.selectedModelByProvider = normalizedSel @@ -250,9 +254,13 @@ final class AIEnhancementSettingsViewModel: ObservableObject { guard !trimmed.isEmpty else { return "" } // Built-in providers use their ID directly - if ModelRepository.shared.isBuiltIn(trimmed) { return trimmed } + if ModelRepository.shared.isBuiltIn(trimmed) { + return trimmed + } // Custom providers get "custom:" prefix (if not already present) - if trimmed.hasPrefix("custom:") { return trimmed } + if trimmed.hasPrefix("custom:") { + return trimmed + } return "custom:\(trimmed)" } @@ -542,8 +550,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { func updateCurrentProvider() { let url = self.openAIBaseURL.trimmingCharacters(in: .whitespacesAndNewlines) - if url.contains("openai.com") { self.currentProvider = "openai"; return } - if url.contains("groq.com") { self.currentProvider = "groq"; return } + if url.contains("openai.com") { + self.currentProvider = "openai"; return + } + if url.contains("groq.com") { + self.currentProvider = "groq"; return + } self.currentProvider = self.providerKey(for: self.selectedProviderID) } @@ -797,7 +809,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.officialProviderSignInTask = nil self.isTestingConnection = false - await self.testAPIConnection() + await self.testAPIConnection(providerID: providerID) } catch is CancellationError { self.oauthSignInMessageByProvider.removeValue(forKey: providerID) self.oauthSignInURLByProvider.removeValue(forKey: providerID) @@ -840,9 +852,12 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } func testAPIConnection() async { + await self.testAPIConnection(providerID: self.selectedProviderID) + } + + private func testAPIConnection(providerID: String) async { guard !self.isTestingConnection else { return } - let providerID = self.selectedProviderID let providerName = ModelRepository.shared.displayName(for: providerID) let baseURL = self.providerBaseURL(for: providerID) if self.hasProviderAPIKeyDraft(for: providerID), !self.saveProviderAPIKeys(invalidating: providerID) { @@ -871,7 +886,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { return } - let trimmedModel = self.selectedModel.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedModel = self.modelForVerification(providerID) guard !trimmedModel.isEmpty else { await MainActor.run { self.updateConnectionStatus(.failed, for: providerID) @@ -1090,6 +1105,20 @@ final class AIEnhancementSettingsViewModel: ObservableObject { } } + func modelForVerification(_ providerID: String) -> String { + let configuredModel = self.selectedModel(for: providerID) + if !configuredModel.isEmpty { + return configuredModel + } + + guard OfficialProviderAuth.isOfficialProvider(providerID), + let defaultModel = self.models(for: providerID).first + else { return "" } + + self.selectModel(defaultModel, for: providerID) + return defaultModel + } + /// Returns the provider's HTTP error body unchanged so setup errors match the real API response. private func interpretVerificationError(statusCode: Int, responseData: Data) -> String { let responseBody = String(data: responseData, encoding: .utf8)? @@ -1269,7 +1298,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let key = self.providerKey(for: self.selectedProviderID) var list = self.availableModelsByProvider[key] ?? self.availableModels list.removeAll { $0 == self.selectedModel } - if list.isEmpty { list = ModelRepository.shared.defaultModels(for: key) } + if list.isEmpty { + list = ModelRepository.shared.defaultModels(for: key) + } self.availableModelsByProvider[key] = list self.settings.availableModelsByProvider = self.availableModelsByProvider @@ -1370,7 +1401,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { let key = self.providerKey(for: providerID) let stored = (self.selectedModelByProvider[key] ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - if !stored.isEmpty { return stored } + if !stored.isEmpty { + return stored + } if providerID == self.selectedProviderID { return self.selectedModel.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -1549,7 +1582,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { continue } guard let stored = self.settings.verifiedProviderFingerprints[key] else { - if statuses[providerID] == .success { statuses[providerID] = .unknown } + if statuses[providerID] == .success { + statuses[providerID] = .unknown + } continue } let baseURL = self.providerBaseURL(for: providerID) @@ -2027,7 +2062,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { _ selection: SettingsStore.DictationPromptSelection, for slot: SettingsStore.DictationShortcutSlot ) -> Bool { - if slot == .secondary, !self.settings.promptModeShortcutEnabled { return false } + if slot == .secondary, !self.settings.promptModeShortcutEnabled { + return false + } return self.settings.dictationPromptSelection(for: slot) == selection } diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 9fa99b502..a2b9a67be 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -412,10 +412,14 @@ final class LLMClientRequestBodyTests: XCTestCase { let refreshed = try GrokSubscriptionAuth.decodeOAuthSession( from: refreshData, previousRefreshToken: initial.refreshToken, + previousUserID: initial.userID, + previousEmail: initial.email, now: now ) XCTAssertEqual(refreshed.accessToken, "rotated-access") XCTAssertEqual(refreshed.refreshToken, "fluidvoice-grok-refresh") + XCTAssertEqual(refreshed.userID, "grok-user-2") + XCTAssertEqual(refreshed.accountLabel, "signed-in@example.com") } func testOfficialLoginFingerprintDoesNotContainOrDependOnRotatingAccessToken() { @@ -617,6 +621,52 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertNil(loginBody["max_output_tokens"]) } + func testCodexLoginReplaysEncryptedReasoningBeforeToolContinuation() throws { + let reasoningItem = try XCTUnwrap(LLMClient.shared.responsesContinuationItem(from: [ + "type": "reasoning", + "id": "rs_123", + "encrypted_content": "opaque-reasoning-state", + ])) + let config = LLMClient.Config( + providerID: OfficialProviderAuth.codexProviderID, + messages: [ + [ + "role": "assistant", + "content": "", + "responses_continuation_items": [reasoningItem.inputItem], + "tool_calls": [[ + "id": "call-1", + "type": "function", + "function": [ + "name": "execute_terminal_command", + "arguments": "{\"command\":\"pwd\"}", + ], + ]], + ], + ["role": "tool", "tool_call_id": "call-1", "content": "/tmp"], + ], + model: "gpt-5.6-sol", + baseURL: "https://chatgpt.com/backend-api/codex", + apiKey: "", + streaming: true + ) + + let body = LLMClient.shared.buildResponsesBody(config) + let input = try XCTUnwrap(body["input"] as? [[String: Any]]) + XCTAssertEqual(input.map { $0["type"] as? String }, ["reasoning", "function_call", "function_call_output"]) + XCTAssertEqual(input[0]["id"] as? String, "rs_123") + XCTAssertEqual(input[0]["encrypted_content"] as? String, "opaque-reasoning-state") + XCTAssertEqual(input[1]["call_id"] as? String, "call-1") + XCTAssertEqual(input[2]["call_id"] as? String, "call-1") + } + + func testOfficialProviderGuideLabelsUseGuideIconContract() throws { + for providerID in OfficialProviderAuth.providerIDs { + let info = try XCTUnwrap(OfficialProviderAuth.info(for: providerID)) + XCTAssertTrue(info.setupLabel.contains("Guide")) + } + } + private static let basePromptMarker = "You are a voice-to-text dictation cleaner" private func resetPromptSettings(_ settings: SettingsStore) { From 221cb99cda45396e2d05a1c1fb0a4cde0c036482 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 18:20:54 -0400 Subject: [PATCH 3/9] Cancel OAuth sign-in before disconnecting --- .../AIEnhancementSettingsViewModel.swift | 56 ++++++++++++++++--- .../UI/AISettingsView+AIConfiguration.swift | 6 +- .../LLMClientRequestBodyTests.swift | 18 ++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index a6e438e3a..e3b3059b2 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -4,6 +4,47 @@ import Security import SwiftUI import UniformTypeIdentifiers +@MainActor +final class OfficialProviderSignInTaskCoordinator { + private var task: Task? + private(set) var providerID: String? + + @discardableResult + func start( + providerID: String, + operation: @escaping @MainActor () async -> Void + ) -> Bool { + guard self.task == nil else { return false } + + self.providerID = providerID + self.task = Task { [weak self] in + await operation() + self?.finish(providerID: providerID) + } + return true + } + + func cancel(providerID: String) { + guard self.providerID == providerID else { return } + self.task?.cancel() + } + + @discardableResult + func cancelAndWait(providerID: String) async -> Bool { + guard self.providerID == providerID, let task = self.task else { return false } + + task.cancel() + await task.value + return true + } + + private func finish(providerID: String) { + guard self.providerID == providerID else { return } + self.task = nil + self.providerID = nil + } +} + @MainActor final class AIEnhancementSettingsViewModel: ObservableObject { let settings: SettingsStore @@ -62,7 +103,7 @@ final class AIEnhancementSettingsViewModel: ObservableObject { @Published var signingInProviderID: String? = nil @Published var oauthSignInMessageByProvider: [String: String] = [:] @Published var oauthSignInURLByProvider: [String: URL] = [:] - private var officialProviderSignInTask: Task? + private let officialProviderSignInTasks = OfficialProviderSignInTaskCoordinator() // UI State @Published var showHelp: Bool = false @@ -762,15 +803,13 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // MARK: - API Connection Testing func beginOfficialProviderSignIn(_ providerID: String) { - guard self.officialProviderSignInTask == nil else { return } - self.officialProviderSignInTask = Task { [weak self] in + self.officialProviderSignInTasks.start(providerID: providerID) { [weak self] in await self?.signInWithOfficialProvider(providerID) } } func cancelOfficialProviderSignIn(_ providerID: String) { - guard self.signingInProviderID == providerID else { return } - self.officialProviderSignInTask?.cancel() + self.officialProviderSignInTasks.cancel(providerID: providerID) } private func signInWithOfficialProvider(_ providerID: String) async { @@ -806,7 +845,6 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.oauthSignInMessageByProvider.removeValue(forKey: providerID) self.oauthSignInURLByProvider.removeValue(forKey: providerID) self.signingInProviderID = nil - self.officialProviderSignInTask = nil self.isTestingConnection = false await self.testAPIConnection(providerID: providerID) @@ -814,7 +852,6 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.oauthSignInMessageByProvider.removeValue(forKey: providerID) self.oauthSignInURLByProvider.removeValue(forKey: providerID) self.signingInProviderID = nil - self.officialProviderSignInTask = nil self.isTestingConnection = false self.updateConnectionStatus(.unknown, for: providerID) } catch { @@ -822,14 +859,15 @@ final class AIEnhancementSettingsViewModel: ObservableObject { self.oauthSignInMessageByProvider.removeValue(forKey: providerID) self.oauthSignInURLByProvider.removeValue(forKey: providerID) self.signingInProviderID = nil - self.officialProviderSignInTask = nil self.isTestingConnection = false self.updateConnectionStatus(.failed, for: providerID) self.setConnectionError(message, for: providerID) } } - func disconnectOfficialProvider(_ providerID: String) { + func disconnectOfficialProvider(_ providerID: String) async { + await self.officialProviderSignInTasks.cancelAndWait(providerID: providerID) + do { switch providerID { case GrokSubscriptionAuth.providerID: diff --git a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index b18f486c3..72291f4d7 100644 --- a/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift +++ b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift @@ -2536,8 +2536,10 @@ extension AIEnhancementSettingsView { .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) if OfficialProviderAuth.supportsInAppSignIn(item.id) { Button("Remove FluidVoice Login") { - self.viewModel.disconnectOfficialProvider(item.id) - self.viewModel.clearEditProviderDraft() + Task { + await self.viewModel.disconnectOfficialProvider(item.id) + self.viewModel.clearEditProviderDraft() + } } .fluidCompactButton(foreground: .red, borderColor: .red.opacity(0.6)) } diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index a2b9a67be..4976afbe5 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -667,6 +667,24 @@ final class LLMClientRequestBodyTests: XCTestCase { } } + func testOfficialProviderSignInTaskCoordinatorCancelsAndAwaitsMatchingTask() async { + let coordinator = OfficialProviderSignInTaskCoordinator() + var observedCancellation = false + + XCTAssertTrue(coordinator.start(providerID: CodexSubscriptionAuth.providerID) { + while !Task.isCancelled { + await Task.yield() + } + observedCancellation = true + }) + XCTAssertEqual(coordinator.providerID, CodexSubscriptionAuth.providerID) + + let didCancel = await coordinator.cancelAndWait(providerID: CodexSubscriptionAuth.providerID) + XCTAssertTrue(didCancel) + XCTAssertTrue(observedCancellation) + XCTAssertNil(coordinator.providerID) + } + private static let basePromptMarker = "You are a voice-to-text dictation cleaner" private func resetPromptSettings(_ settings: SettingsStore) { From c60195d4cf1828a4c126d4c6d9a97abcc7756fd2 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 18:39:43 -0400 Subject: [PATCH 4/9] Fix provider continuation state handling --- .../Fluid/Persistence/ChatHistoryStore.swift | 51 +++++++- .../Services/CodexSubscriptionAuth.swift | 41 +++++- .../Fluid/Services/CommandModeService.swift | 50 ++++++-- Sources/Fluid/Services/LLMClient.swift | 26 +++- .../LLMClientRequestBodyTests.swift | 118 ++++++++++++++++++ 5 files changed, 267 insertions(+), 19 deletions(-) diff --git a/Sources/Fluid/Persistence/ChatHistoryStore.swift b/Sources/Fluid/Persistence/ChatHistoryStore.swift index fd73c9cf4..81d3fdbfa 100644 --- a/Sources/Fluid/Persistence/ChatHistoryStore.swift +++ b/Sources/Fluid/Persistence/ChatHistoryStore.swift @@ -15,6 +15,7 @@ struct ChatMessage: Codable, Identifiable, Equatable { let role: Role let content: String let toolCall: ToolCall? + let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] let stepType: StepType let timestamp: Date @@ -39,16 +40,64 @@ struct ChatMessage: Codable, Identifiable, Equatable { let command: String let workingDirectory: String? let purpose: String? + let thoughtSignature: String? + + init( + id: String, + command: String, + workingDirectory: String?, + purpose: String?, + thoughtSignature: String? = nil + ) { + self.id = id + self.command = command + self.workingDirectory = workingDirectory + self.purpose = purpose + self.thoughtSignature = thoughtSignature + } } - init(id: UUID = UUID(), role: Role, content: String, toolCall: ToolCall? = nil, stepType: StepType = .normal, timestamp: Date = Date()) { + init( + id: UUID = UUID(), + role: Role, + content: String, + toolCall: ToolCall? = nil, + responsesContinuationItems: [LLMClient.ResponsesContinuationItem] = [], + stepType: StepType = .normal, + timestamp: Date = Date() + ) { self.id = id self.role = role self.content = content self.toolCall = toolCall + self.responsesContinuationItems = responsesContinuationItems self.stepType = stepType self.timestamp = timestamp } + + private enum CodingKeys: String, CodingKey { + case id + case role + case content + case toolCall + case responsesContinuationItems + case stepType + case timestamp + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(UUID.self, forKey: .id) + self.role = try container.decode(Role.self, forKey: .role) + self.content = try container.decode(String.self, forKey: .content) + self.toolCall = try container.decodeIfPresent(ToolCall.self, forKey: .toolCall) + self.responsesContinuationItems = try container.decodeIfPresent( + [LLMClient.ResponsesContinuationItem].self, + forKey: .responsesContinuationItems + ) ?? [] + self.stepType = try container.decode(StepType.self, forKey: .stepType) + self.timestamp = try container.decode(Date.self, forKey: .timestamp) + } } // MARK: - Chat Session Model diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift index 9a807eab4..260cce12d 100644 --- a/Sources/Fluid/Services/CodexSubscriptionAuth.swift +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -1,6 +1,38 @@ import Foundation import Security +@MainActor +final class InFlightTaskCoalescer { + private var flight: (id: UUID, task: Task)? + + func value(operation: @escaping @MainActor () async throws -> Value) async throws -> Value { + let activeFlight: (id: UUID, task: Task) + if let flight { + activeFlight = flight + } else { + let newFlight = ( + id: UUID(), + task: Task { try await operation() } + ) + self.flight = newFlight + activeFlight = newFlight + } + + do { + let value = try await activeFlight.task.value + if self.flight?.id == activeFlight.id { + self.flight = nil + } + return value + } catch { + if self.flight?.id == activeFlight.id { + self.flight = nil + } + throw error + } + } +} + /// ChatGPT subscription authentication compatible with the public Codex device flow. /// /// FluidVoice stores its own access/refresh pair in a dedicated Keychain item. @@ -14,6 +46,7 @@ enum CodexSubscriptionAuth { private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann" private static let keychainService = "com.fluidvoice.provider-oauth" private static let expirySafetyWindow: TimeInterval = 120 + private static let refreshCoalescer = InFlightTaskCoalescer() struct DeviceAuthorization: Equatable { let deviceAuthID: String @@ -254,8 +287,12 @@ enum CodexSubscriptionAuth { ) async throws -> ResolvedCredential? { guard var oauthSession = try self.loadOAuthSession() else { return nil } if oauthSession.expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { - oauthSession = try await self.refreshOAuthSession(oauthSession, now: now, session: session) - try self.storeOAuthSession(oauthSession) + let sessionToRefresh = oauthSession + oauthSession = try await self.refreshCoalescer.value { + let refreshed = try await self.refreshOAuthSession(sessionToRefresh, now: now, session: session) + try self.storeOAuthSession(refreshed) + return refreshed + } } return ResolvedCredential( accessToken: oauthSession.accessToken, diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index d67c2e5e5..ae9eac7e1 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -93,6 +93,21 @@ final class CommandModeService: ObservableObject { let command: String let workingDirectory: String? let purpose: String? // Why this command is being run + let thoughtSignature: String? + + init( + id: String, + command: String, + workingDirectory: String?, + purpose: String?, + thoughtSignature: String? = nil + ) { + self.id = id + self.command = command + self.workingDirectory = workingDirectory + self.purpose = purpose + self.thoughtSignature = thoughtSignature + } } init( @@ -238,7 +253,8 @@ final class CommandModeService: ObservableObject { id: tc.id, command: tc.command, workingDirectory: tc.workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ) } @@ -247,6 +263,7 @@ final class CommandModeService: ObservableObject { role: role, content: msg.content, toolCall: toolCall, + responsesContinuationItems: msg.responsesContinuationItems, stepType: stepType, timestamp: msg.timestamp ) @@ -277,7 +294,8 @@ final class CommandModeService: ObservableObject { id: tc.id, command: tc.command, workingDirectory: tc.workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ) } @@ -285,6 +303,7 @@ final class CommandModeService: ObservableObject { role: role, content: chatMsg.content, toolCall: toolCall, + responsesContinuationItems: chatMsg.responsesContinuationItems, stepType: stepType ) } @@ -431,7 +450,8 @@ final class CommandModeService: ObservableObject { command: tc.command, workingDirectory: tc .workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ), responsesContinuationItems: response.responsesContinuationItems, stepType: stepType @@ -723,6 +743,7 @@ final class CommandModeService: ObservableObject { let command: String let workingDirectory: String? let purpose: String? + let thoughtSignature: String? } } @@ -852,17 +873,21 @@ final class CommandModeService: ObservableObject { DebugLogger.shared.error("Failed to encode tool call args: \(error)", source: "CommandModeService") argsJSON = "{}" } + var toolCallMessage: [String: Any] = [ + "id": tc.id, + "type": "function", + "function": [ + "name": "execute_terminal_command", + "arguments": argsJSON, + ], + ] + if let thoughtSignature = tc.thoughtSignature { + toolCallMessage["thought_signature"] = thoughtSignature + } var assistantMessage: [String: Any] = [ "role": "assistant", "content": msg.content, - "tool_calls": [[ - "id": tc.id, - "type": "function", - "function": [ - "name": "execute_terminal_command", - "arguments": argsJSON, - ], - ]], + "tool_calls": [toolCallMessage], ] if !msg.responsesContinuationItems.isEmpty { assistantMessage["responses_continuation_items"] = msg.responsesContinuationItems.map(\.inputItem) @@ -1014,7 +1039,8 @@ final class CommandModeService: ObservableObject { id: tc.id, command: command, workingDirectory: workDir, - purpose: purpose + purpose: purpose, + thoughtSignature: tc.thoughtSignature ), responsesContinuationItems: response.responsesContinuationItems ) diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index 58ffa8fd2..6e58111ab 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -100,7 +100,7 @@ final class LLMClient { } } - struct ResponsesContinuationItem: Equatable { + struct ResponsesContinuationItem: Codable, Equatable { let id: String let encryptedContent: String @@ -118,6 +118,19 @@ final class LLMClient { let id: String let name: String let arguments: [String: Any] + let thoughtSignature: String? + + init( + id: String, + name: String, + arguments: [String: Any], + thoughtSignature: String? = nil + ) { + self.id = id + self.name = name + self.arguments = arguments + self.thoughtSignature = thoughtSignature + } /// Get a string argument by key func getString(_ key: String) -> String? { @@ -663,7 +676,11 @@ final class LLMClient { let argumentsString = function["arguments"] as? String ?? "{}" let arguments = argumentsString.data(using: .utf8) .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:] - parts.append(["functionCall": ["name": name, "args": arguments]]) + var part: [String: Any] = ["functionCall": ["name": name, "args": arguments]] + if let thoughtSignature = toolCall["thought_signature"] as? String { + part["thoughtSignature"] = thoughtSignature + } + parts.append(part) } } appendContent(role: role == "assistant" ? "model" : "user", parts: parts) @@ -1361,7 +1378,7 @@ final class LLMClient { ) } - private func geminiParts(from root: [String: Any]) -> ( + func geminiParts(from root: [String: Any]) -> ( content: [String], thinking: [String], toolCalls: [ToolCall] @@ -1390,7 +1407,8 @@ final class LLMClient { tools.append(ToolCall( id: call["id"] as? String ?? "call_\(UUID().uuidString.prefix(8))", name: name, - arguments: call["args"] as? [String: Any] ?? [:] + arguments: call["args"] as? [String: Any] ?? [:], + thoughtSignature: part["thoughtSignature"] as? String )) } } diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 4976afbe5..ea1c8a455 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -243,6 +243,32 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(refreshed.accountLabel, "chatgpt@example.com") } + func testCodexRefreshCoalescerSharesOneInFlightOperation() async throws { + let coalescer = InFlightTaskCoalescer() + var refreshCount = 0 + + let first = Task { @MainActor in + try await coalescer.value { + refreshCount += 1 + try await Task.sleep(nanoseconds: 25_000_000) + return "rotated-access-token" + } + } + await Task.yield() + let second = Task { @MainActor in + try await coalescer.value { + refreshCount += 1 + return "unexpected-second-refresh" + } + } + + let firstValue = try await first.value + let secondValue = try await second.value + XCTAssertEqual(firstValue, "rotated-access-token") + XCTAssertEqual(secondValue, "rotated-access-token") + XCTAssertEqual(refreshCount, 1) + } + func testClaudeCredentialDecoderImportsOfficialCredentialShape() throws { let data = try JSONSerialization.data(withJSONObject: [ "claudeAiOauth": [ @@ -512,6 +538,98 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(generation["maxOutputTokens"] as? Int, 64) } + func testGeminiThoughtSignatureIsParsedAndReplayedWithFunctionCall() throws { + let parsed = LLMClient.shared.geminiParts(from: [ + "response": [ + "candidates": [[ + "content": [ + "parts": [[ + "functionCall": [ + "id": "call-gemini", + "name": "execute_terminal_command", + "args": ["command": "pwd"], + ], + "thoughtSignature": "opaque-gemini-thought", + ]], + ], + ]], + ], + ]) + let toolCall = try XCTUnwrap(parsed.toolCalls.first) + XCTAssertEqual(toolCall.thoughtSignature, "opaque-gemini-thought") + + let config = LLMClient.Config( + providerID: OfficialProviderAuth.geminiProviderID, + messages: [ + [ + "role": "assistant", + "content": "", + "tool_calls": [[ + "id": toolCall.id, + "type": "function", + "thought_signature": toolCall.thoughtSignature as Any, + "function": [ + "name": toolCall.name, + "arguments": "{\"command\":\"pwd\"}", + ], + ]], + ], + ["role": "tool", "tool_call_id": toolCall.id, "content": "/tmp"], + ], + model: "gemini-2.5-flash", + baseURL: "https://cloudcode-pa.googleapis.com/v1internal", + apiKey: "", + streaming: true + ) + + let body = LLMClient.shared.buildGeminiCodeAssistBody(config, project: "projects/example") + let request = try XCTUnwrap(body["request"] as? [String: Any]) + let contents = try XCTUnwrap(request["contents"] as? [[String: Any]]) + let modelParts = try XCTUnwrap(contents.first?["parts"] as? [[String: Any]]) + XCTAssertEqual(modelParts.first?["thoughtSignature"] as? String, "opaque-gemini-thought") + } + + func testCommandHistoryPersistsOpaqueProviderContinuationState() throws { + let timestamp = Date(timeIntervalSince1970: 1_800_000_000) + let message = ChatMessage( + role: .assistant, + content: "Running a command", + toolCall: ChatMessage.ToolCall( + id: "call-1", + command: "pwd", + workingDirectory: "/tmp", + purpose: "Inspect the working directory", + thoughtSignature: "opaque-gemini-thought" + ), + responsesContinuationItems: [ + LLMClient.ResponsesContinuationItem( + id: "reasoning-1", + encryptedContent: "opaque-codex-reasoning" + ), + ], + timestamp: timestamp + ) + + let decoded = try JSONDecoder().decode( + ChatMessage.self, + from: JSONEncoder().encode(message) + ) + XCTAssertEqual(decoded, message) + } + + func testCommandHistoryDecodesBeforeContinuationStateWasPersisted() throws { + let message = ChatMessage(role: .assistant, content: "Legacy history") + let encoded = try JSONEncoder().encode(message) + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + object.removeValue(forKey: "responsesContinuationItems") + + let legacyData = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(ChatMessage.self, from: legacyData) + XCTAssertEqual(decoded.responsesContinuationItems, []) + } + func testOfficialProvidersHaveBundledModelsAndBaseURLs() { for providerID in OfficialProviderAuth.providerIDs { XCTAssertTrue(ModelRepository.shared.isBuiltIn(providerID)) From ffef203e9bceb155fe368469c19d3cc7e7d23dd0 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 19:00:34 -0400 Subject: [PATCH 5/9] Fix OAuth caching and cancellation handling --- .../Services/CodexSubscriptionAuth.swift | 3 + .../Fluid/Services/GrokSubscriptionAuth.swift | 12 +- .../Fluid/Services/OfficialProviderAuth.swift | 37 +++++- .../LLMClientRequestBodyTests.swift | 106 +++++++++++++++++- 4 files changed, 154 insertions(+), 4 deletions(-) diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift index 260cce12d..a1f7c7ee2 100644 --- a/Sources/Fluid/Services/CodexSubscriptionAuth.swift +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -161,6 +161,7 @@ enum CodexSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.requestFailed(0) } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { @@ -214,6 +215,7 @@ enum CodexSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.requestFailed(0) } guard let http = response as? HTTPURLResponse else { @@ -365,6 +367,7 @@ enum CodexSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.requestFailed(0) } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { diff --git a/Sources/Fluid/Services/GrokSubscriptionAuth.swift b/Sources/Fluid/Services/GrokSubscriptionAuth.swift index 3aeb9f631..1d4165e81 100644 --- a/Sources/Fluid/Services/GrokSubscriptionAuth.swift +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -32,6 +32,7 @@ enum GrokSubscriptionAuth { private static let fallbackCredentialLifetime: TimeInterval = 30 * 24 * 60 * 60 private static let expirySafetyWindow: TimeInterval = 60 private static let fallbackClientVersion = "1.0.0" + private static let refreshCoalescer = InFlightTaskCoalescer() struct DeviceAuthorization: Equatable { let deviceCode: String @@ -241,6 +242,7 @@ enum GrokSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.oauthRequestFailed(0) } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { @@ -315,6 +317,7 @@ enum GrokSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.oauthRequestFailed(0) } @@ -387,8 +390,12 @@ enum GrokSubscriptionAuth { ) async throws -> ResolvedCredential { if var ownedSession = try self.loadOAuthSession() { if ownedSession.expiresAt.timeIntervalSince(now) <= self.expirySafetyWindow { - ownedSession = try await self.refreshOAuthSession(ownedSession, now: now, session: session) - try self.storeOAuthSession(ownedSession) + let sessionToRefresh = ownedSession + ownedSession = try await self.refreshCoalescer.value { + let refreshed = try await self.refreshOAuthSession(sessionToRefresh, now: now, session: session) + try self.storeOAuthSession(refreshed) + return refreshed + } } return ResolvedCredential( accessToken: ownedSession.accessToken, @@ -566,6 +573,7 @@ enum GrokSubscriptionAuth { do { (data, response) = try await session.data(for: request) } catch { + try OfficialProviderAuth.rethrowCancellation(error) throw AuthError.oauthRequestFailed(0) } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { diff --git a/Sources/Fluid/Services/OfficialProviderAuth.swift b/Sources/Fluid/Services/OfficialProviderAuth.swift index 88ac02409..b8793926c 100644 --- a/Sources/Fluid/Services/OfficialProviderAuth.swift +++ b/Sources/Fluid/Services/OfficialProviderAuth.swift @@ -2,6 +2,23 @@ import CryptoKit import Foundation import Security +@MainActor +final class CredentialScopedValueCache { + private var values: [String: Value] = [:] + + func value( + for credentialKey: String, + load: @MainActor () async throws -> Value + ) async throws -> Value { + if let cached = values[credentialKey] { + return cached + } + let loaded = try await load() + self.values[credentialKey] = loaded + return loaded + } +} + /// Adapters for subscription sessions created by first-party AI clients. /// /// Grok and ChatGPT additionally support public device authorization flows and @@ -67,6 +84,7 @@ enum OfficialProviderAuth { private static let expirySafetyWindow: TimeInterval = 60 private static let maximumCredentialFileSize = 1_048_576 + private static let geminiProjectCache = CredentialScopedValueCache() struct CodexCredential: Equatable { let accessToken: String let accountID: String? @@ -231,7 +249,13 @@ enum OfficialProviderAuth { case self.geminiProviderID: let stored = try self.loadGeminiCredential() let credential = try self.validGeminiCredential(stored, now: now) - let project = try await self.loadGeminiProject(accessToken: credential.accessToken, session: session) + let cacheKey = self.geminiProjectCacheKey( + accountLabel: credential.accountLabel, + accessToken: credential.accessToken + ) + let project = try await self.geminiProjectCache.value(for: cacheKey) { + try await self.loadGeminiProject(accessToken: credential.accessToken, session: session) + } return Session( providerID: providerID, accessToken: credential.accessToken, @@ -467,6 +491,7 @@ enum OfficialProviderAuth { do { (data, response) = try await session.data(for: request) } catch { + try self.rethrowCancellation(error) throw AuthError.refreshFailed("Gemini Code Assist setup check failed: \(error.localizedDescription)") } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), @@ -504,6 +529,16 @@ enum OfficialProviderAuth { return project } + static func geminiProjectCacheKey(accountLabel: String, accessToken: String) -> String { + self.sha256("gemini-project|\(accountLabel)|\(accessToken)") + } + + static func rethrowCancellation(_ error: Error) throws { + if error is CancellationError || (error as? URLError)?.code == .cancelled { + throw CancellationError() + } + } + private static func geminiAccountLabel() -> String { let fileURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".gemini", isDirectory: true) diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index ea1c8a455..3d9f711ed 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -243,7 +243,7 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(refreshed.accountLabel, "chatgpt@example.com") } - func testCodexRefreshCoalescerSharesOneInFlightOperation() async throws { + func testOAuthRefreshCoalescerSharesOneInFlightOperation() async throws { let coalescer = InFlightTaskCoalescer() var refreshCount = 0 @@ -269,6 +269,61 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(refreshCount, 1) } + func testOAuthTransportCancellationRemainsCancellation() { + XCTAssertThrowsError( + try OfficialProviderAuth.rethrowCancellation(URLError(.cancelled)) + ) { error in + XCTAssertTrue(error is CancellationError) + } + XCTAssertNoThrow( + try OfficialProviderAuth.rethrowCancellation(URLError(.notConnectedToInternet)) + ) + } + + func testCodexAndGrokPollingPreserveTransportCancellation() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [CancellingURLProtocol.self] + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + let now = Date(timeIntervalSince1970: 1_800_000_000) + + let codexAuthorization = CodexSubscriptionAuth.DeviceAuthorization( + deviceAuthID: "device-auth", + userCode: "ABCD-1234", + pollingInterval: 0, + expiresAt: now.addingTimeInterval(60) + ) + do { + _ = try await CodexSubscriptionAuth.completeDeviceAuthorization( + codexAuthorization, + session: session, + now: { now } + ) + XCTFail("Codex polling should preserve cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + + let grokAuthorization = try GrokSubscriptionAuth.DeviceAuthorization( + deviceCode: "device-code", + userCode: "ABCD-1234", + verificationURL: XCTUnwrap(URL(string: "https://auth.x.ai/device")), + verificationCompleteURL: nil, + pollingInterval: 0, + expiresAt: now.addingTimeInterval(60) + ) + do { + _ = try await GrokSubscriptionAuth.completeDeviceAuthorization( + grokAuthorization, + session: session, + now: { now } + ) + XCTFail("Grok polling should preserve cancellation") + } catch { + XCTAssertTrue(error is CancellationError) + } + } + func testClaudeCredentialDecoderImportsOfficialCredentialShape() throws { let data = try JSONSerialization.data(withJSONObject: [ "claudeAiOauth": [ @@ -316,6 +371,39 @@ final class LLMClientRequestBodyTests: XCTestCase { ) } + func testGeminiProjectCacheIsScopedToCredentialAndAvoidsRepeatedSetup() async throws { + let cache = CredentialScopedValueCache() + let firstKey = OfficialProviderAuth.geminiProjectCacheKey( + accountLabel: "first@example.com", + accessToken: "first-access-token" + ) + let secondKey = OfficialProviderAuth.geminiProjectCacheKey( + accountLabel: "second@example.com", + accessToken: "second-access-token" + ) + var loadCount = 0 + + let first = try await cache.value(for: firstKey) { + loadCount += 1 + return "projects/first" + } + let cached = try await cache.value(for: firstKey) { + loadCount += 1 + return "unexpected-reload" + } + let second = try await cache.value(for: secondKey) { + loadCount += 1 + return "projects/second" + } + + XCTAssertEqual(first, "projects/first") + XCTAssertEqual(cached, "projects/first") + XCTAssertEqual(second, "projects/second") + XCTAssertEqual(loadCount, 2) + XCTAssertNotEqual(firstKey, secondKey) + XCTAssertFalse(firstKey.contains("first-access-token")) + } + func testGrokCredentialDecoderSelectsOAuthSessionAndIgnoresAPIKey() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let data = try JSONSerialization.data(withJSONObject: [ @@ -854,3 +942,19 @@ final class LLMClientRequestBodyTests: XCTestCase { return "\(base64URL(header)).\(base64URL(payload)).signature" } } + +private class CancellingURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + self.client?.urlProtocol(self, didFailWithError: URLError(.cancelled)) + } + + override func stopLoading() {} +} From 013e2a0788955d80da6836409b6c21de697e3058 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 19:19:35 -0400 Subject: [PATCH 6/9] Harden OAuth disconnect and continuation state --- .../Fluid/Persistence/ChatHistoryStore.swift | 8 ++ .../Services/CodexSubscriptionAuth.swift | 12 +- .../Fluid/Services/CommandModeService.swift | 18 ++- .../Fluid/Services/GrokSubscriptionAuth.swift | 3 +- Sources/Fluid/Services/LLMClient.swift | 68 +++++++++- .../Fluid/Services/OfficialProviderAuth.swift | 29 ++++ .../AIEnhancementSettingsViewModel.swift | 9 +- .../LLMClientRequestBodyTests.swift | 126 +++++++++++++++++- 8 files changed, 258 insertions(+), 15 deletions(-) diff --git a/Sources/Fluid/Persistence/ChatHistoryStore.swift b/Sources/Fluid/Persistence/ChatHistoryStore.swift index 81d3fdbfa..52b557909 100644 --- a/Sources/Fluid/Persistence/ChatHistoryStore.swift +++ b/Sources/Fluid/Persistence/ChatHistoryStore.swift @@ -16,6 +16,7 @@ struct ChatMessage: Codable, Identifiable, Equatable { let content: String let toolCall: ToolCall? let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] + let responsesContinuationScope: String? let stepType: StepType let timestamp: Date @@ -63,6 +64,7 @@ struct ChatMessage: Codable, Identifiable, Equatable { content: String, toolCall: ToolCall? = nil, responsesContinuationItems: [LLMClient.ResponsesContinuationItem] = [], + responsesContinuationScope: String? = nil, stepType: StepType = .normal, timestamp: Date = Date() ) { @@ -71,6 +73,7 @@ struct ChatMessage: Codable, Identifiable, Equatable { self.content = content self.toolCall = toolCall self.responsesContinuationItems = responsesContinuationItems + self.responsesContinuationScope = responsesContinuationScope self.stepType = stepType self.timestamp = timestamp } @@ -81,6 +84,7 @@ struct ChatMessage: Codable, Identifiable, Equatable { case content case toolCall case responsesContinuationItems + case responsesContinuationScope case stepType case timestamp } @@ -95,6 +99,10 @@ struct ChatMessage: Codable, Identifiable, Equatable { [LLMClient.ResponsesContinuationItem].self, forKey: .responsesContinuationItems ) ?? [] + self.responsesContinuationScope = try container.decodeIfPresent( + String.self, + forKey: .responsesContinuationScope + ) self.stepType = try container.decode(StepType.self, forKey: .stepType) self.timestamp = try container.decode(Date.self, forKey: .timestamp) } diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift index a1f7c7ee2..b4678cd31 100644 --- a/Sources/Fluid/Services/CodexSubscriptionAuth.swift +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -31,6 +31,15 @@ final class InFlightTaskCoalescer { throw error } } + + func cancelAndWait() async { + guard let activeFlight = self.flight else { return } + activeFlight.task.cancel() + _ = try? await activeFlight.task.value + if self.flight?.id == activeFlight.id { + self.flight = nil + } + } } /// ChatGPT subscription authentication compatible with the public Codex device flow. @@ -303,7 +312,8 @@ enum CodexSubscriptionAuth { ) } - static func disconnectFluidVoiceSession() throws { + static func disconnectFluidVoiceSession() async throws { + await self.refreshCoalescer.cancelAndWait() let status = SecItemDelete(self.keychainQuery() as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw AuthError.keychainFailure(status) diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index ae9eac7e1..c2da467bd 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -69,6 +69,7 @@ final class CommandModeService: ObservableObject { let thinking: String? // Display-only: AI reasoning tokens (NOT sent to API) let toolCall: ToolCall? let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] + let responsesContinuationScope: String? let stepType: StepType let timestamp: Date @@ -116,6 +117,7 @@ final class CommandModeService: ObservableObject { thinking: String? = nil, toolCall: ToolCall? = nil, responsesContinuationItems: [LLMClient.ResponsesContinuationItem] = [], + responsesContinuationScope: String? = nil, stepType: StepType = .normal ) { self.role = role @@ -123,6 +125,7 @@ final class CommandModeService: ObservableObject { self.thinking = thinking self.toolCall = toolCall self.responsesContinuationItems = responsesContinuationItems + self.responsesContinuationScope = responsesContinuationScope self.stepType = stepType self.timestamp = Date() } @@ -264,6 +267,7 @@ final class CommandModeService: ObservableObject { content: msg.content, toolCall: toolCall, responsesContinuationItems: msg.responsesContinuationItems, + responsesContinuationScope: msg.responsesContinuationScope, stepType: stepType, timestamp: msg.timestamp ) @@ -304,6 +308,7 @@ final class CommandModeService: ObservableObject { content: chatMsg.content, toolCall: toolCall, responsesContinuationItems: chatMsg.responsesContinuationItems, + responsesContinuationScope: chatMsg.responsesContinuationScope, stepType: stepType ) } @@ -454,6 +459,7 @@ final class CommandModeService: ObservableObject { thoughtSignature: tc.thoughtSignature ), responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope, stepType: stepType )) @@ -737,6 +743,7 @@ final class CommandModeService: ObservableObject { let thinking: String? // Display-only, NOT sent back to API let toolCall: ToolCallData? let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] + let responsesContinuationScope: String? struct ToolCallData { let id: String @@ -889,8 +896,11 @@ final class CommandModeService: ObservableObject { "content": msg.content, "tool_calls": [toolCallMessage], ] - if !msg.responsesContinuationItems.isEmpty { + if !msg.responsesContinuationItems.isEmpty, + let continuationScope = msg.responsesContinuationScope + { assistantMessage["responses_continuation_items"] = msg.responsesContinuationItems.map(\.inputItem) + assistantMessage["responses_continuation_scope"] = continuationScope } messages.append(assistantMessage) } else { @@ -1042,7 +1052,8 @@ final class CommandModeService: ObservableObject { purpose: purpose, thoughtSignature: tc.thoughtSignature ), - responsesContinuationItems: response.responsesContinuationItems + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope ) } @@ -1059,7 +1070,8 @@ final class CommandModeService: ObservableObject { content: response.content, thinking: finalThinking, // Display-only toolCall: nil, - responsesContinuationItems: response.responsesContinuationItems + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope ) } } diff --git a/Sources/Fluid/Services/GrokSubscriptionAuth.swift b/Sources/Fluid/Services/GrokSubscriptionAuth.swift index 1d4165e81..82227602a 100644 --- a/Sources/Fluid/Services/GrokSubscriptionAuth.swift +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -417,7 +417,8 @@ enum GrokSubscriptionAuth { ) } - static func disconnectFluidVoiceSession() throws { + static func disconnectFluidVoiceSession() async throws { + await self.refreshCoalescer.cancelAndWait() let status = SecItemDelete(self.oauthKeychainQuery() as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { throw AuthError.keychainFailure(status) diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index 6e58111ab..f1c8e8c8d 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -86,17 +86,25 @@ final class LLMClient { let toolCalls: [ToolCall] /// Opaque reasoning state required to continue stateless Responses API tool loops. let responsesContinuationItems: [ResponsesContinuationItem] + /// Credential scope that produced opaque continuation state. + let responsesContinuationScope: String? init( thinking: String?, content: String, toolCalls: [ToolCall], - responsesContinuationItems: [ResponsesContinuationItem] = [] + responsesContinuationItems: [ResponsesContinuationItem] = [], + responsesContinuationScope: String? = nil ) { self.thinking = thinking self.content = content self.toolCalls = toolCalls self.responsesContinuationItems = responsesContinuationItems + self.responsesContinuationScope = responsesContinuationScope + } + + var hasUsableVerificationOutput: Bool { + !self.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !self.toolCalls.isEmpty } } @@ -178,6 +186,9 @@ final class LLMClient { /// These are model-specific and come from user settings var extraParameters: [String: Any] + /// Only continuation items from this credential scope may be replayed. + var responsesContinuationScope: String? + // Retry configuration var maxRetries: Int = 3 var retryDelayMs: Int = 200 @@ -202,7 +213,8 @@ final class LLMClient { tools: [[String: Any]] = [], temperature: Double? = nil, maxTokens: Int? = nil, - extraParameters: [String: Any] = [:] + extraParameters: [String: Any] = [:], + responsesContinuationScope: String? = nil ) { self.providerID = providerID self.messages = messages @@ -214,6 +226,7 @@ final class LLMClient { self.temperature = temperature self.maxTokens = maxTokens self.extraParameters = extraParameters + self.responsesContinuationScope = responsesContinuationScope } } @@ -237,6 +250,13 @@ final class LLMClient { providerID: config.providerID, requested: config.streaming ) + let continuationScope = OfficialProviderAuth.responsesContinuationScope( + providerID: config.providerID, + baseURL: config.baseURL, + apiKey: config.apiKey, + session: officialSession + ) + effectiveConfig.responsesContinuationScope = continuationScope var request = try self.buildRequest(effectiveConfig, officialSession: officialSession) let wireProtocol = officialSession?.wireProtocol @@ -250,17 +270,44 @@ final class LLMClient { // than racing a separate "timeout task". A task-group timeout wrapper can accidentally // keep the caller suspended until the full timeout elapses, which is the exact stall // we want to eliminate for overlay responsiveness. - return try await self.executeWithRetry( + let response = try await self.executeWithRetry( request: request, config: effectiveConfig, wireProtocol: wireProtocol ) + guard !response.responsesContinuationItems.isEmpty else { return response } + return Response( + thinking: response.thinking, + content: response.content, + toolCalls: response.toolCalls, + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: continuationScope + ) } static func effectiveStreaming(providerID: String, requested: Bool) -> Bool { requested || OfficialProviderAuth.requiresStreamingRequests(providerID) } + static func responsesStreamingTerminalError(from event: [String: Any]) -> LLMError? { + guard let type = event["type"] as? String, + ["error", "response.failed", "response.incomplete"].contains(type) + else { return nil } + + let response = event["response"] as? [String: Any] + let responseError = response?["error"] as? [String: Any] + let incompleteDetails = response?["incomplete_details"] as? [String: Any] + let detail = [ + event["message"] as? String, + responseError?["message"] as? String, + incompleteDetails?["reason"] as? String, + response?["status"] as? String, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? type + return .invalidRequest("Responses API stream failed: \(detail)") + } + /// Execute request with retry logic (extracted for timeout wrapper) private func executeWithRetry( request: URLRequest, @@ -479,7 +526,8 @@ final class LLMClient { "model": config.model, "input": self.responsesInput( from: config.messages, - excludingSystemMessages: usesCodexSubscription + excludingSystemMessages: usesCodexSubscription, + expectedContinuationScope: config.responsesContinuationScope ), "store": false, ] @@ -770,7 +818,8 @@ final class LLMClient { private func responsesInput( from messages: [[String: Any]], - excludingSystemMessages: Bool = false + excludingSystemMessages: Bool = false, + expectedContinuationScope: String? = nil ) -> [[String: Any]] { var input: [[String: Any]] = [] @@ -780,7 +829,10 @@ final class LLMClient { continue } - if let continuationItems = message["responses_continuation_items"] as? [[String: Any]] { + if let expectedContinuationScope, + message["responses_continuation_scope"] as? String == expectedContinuationScope, + let continuationItems = message["responses_continuation_items"] as? [[String: Any]] + { input.append(contentsOf: continuationItems) } @@ -1054,6 +1106,10 @@ final class LLMClient { continue } + if let terminalError = Self.responsesStreamingTerminalError(from: event) { + throw terminalError + } + switch type { case "response.output_text.delta": if let delta = event["delta"] as? String { diff --git a/Sources/Fluid/Services/OfficialProviderAuth.swift b/Sources/Fluid/Services/OfficialProviderAuth.swift index b8793926c..d3239e8c4 100644 --- a/Sources/Fluid/Services/OfficialProviderAuth.swift +++ b/Sources/Fluid/Services/OfficialProviderAuth.swift @@ -187,6 +187,35 @@ enum OfficialProviderAuth { return self.sha256("\(trimmedBase)|\(trimmedKey)") } + static func responsesContinuationScope( + providerID: String, + baseURL: String, + apiKey: String, + session: Session? + ) -> String { + let identity: String + if let session { + let identityHeaders = session.headers + .filter { $0.key.caseInsensitiveCompare("authorization") != .orderedSame } + .sorted { $0.key.lowercased() < $1.key.lowercased() } + .map { "\($0.key.lowercased())=\($0.value)" } + .joined(separator: "&") + identity = [ + providerID, + session.accountLabel, + session.project ?? "", + identityHeaders, + ].joined(separator: "|") + } else { + identity = [ + providerID, + baseURL.trimmingCharacters(in: .whitespacesAndNewlines), + apiKey.trimmingCharacters(in: .whitespacesAndNewlines), + ].joined(separator: "|") + } + return self.sha256("responses-continuation|\(identity)") + } + static func resolve( providerID: String, now: Date = Date(), diff --git a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift index e3b3059b2..a7333cba1 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -871,9 +871,9 @@ final class AIEnhancementSettingsViewModel: ObservableObject { do { switch providerID { case GrokSubscriptionAuth.providerID: - try GrokSubscriptionAuth.disconnectFluidVoiceSession() + try await GrokSubscriptionAuth.disconnectFluidVoiceSession() case CodexSubscriptionAuth.providerID: - try CodexSubscriptionAuth.disconnectFluidVoiceSession() + try await CodexSubscriptionAuth.disconnectFluidVoiceSession() default: return } @@ -953,7 +953,10 @@ final class AIEnhancementSettingsViewModel: ObservableObject { ) config.timeoutSeconds = 30 config.maxRetries = 1 - _ = try await LLMClient.shared.call(config) + let response = try await LLMClient.shared.call(config) + guard response.hasUsableVerificationOutput else { + throw LLMError.invalidResponse + } await MainActor.run { self.setEditingAPIKey(false, for: providerID) self.storeVerificationFingerprint(for: providerID, baseURL: baseURL, apiKey: "") diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 3d9f711ed..ee8ebb278 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -269,6 +269,33 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(refreshCount, 1) } + func testOAuthRefreshCoalescerCancelsAndAwaitsActiveRefresh() async { + let coalescer = InFlightTaskCoalescer() + var observedCancellation = false + let refresh = Task { @MainActor in + try await coalescer.value { + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + return "stale-rotated-token" + } catch is CancellationError { + observedCancellation = true + throw CancellationError() + } + } + } + await Task.yield() + + await coalescer.cancelAndWait() + + XCTAssertTrue(observedCancellation) + do { + _ = try await refresh.value + XCTFail("The refresh should be cancelled before disconnect can delete its credential") + } catch { + XCTAssertTrue(error is CancellationError) + } + } + func testOAuthTransportCancellationRemainsCancellation() { XCTAssertThrowsError( try OfficialProviderAuth.rethrowCancellation(URLError(.cancelled)) @@ -695,6 +722,7 @@ final class LLMClientRequestBodyTests: XCTestCase { encryptedContent: "opaque-codex-reasoning" ), ], + responsesContinuationScope: "credential-scope", timestamp: timestamp ) @@ -712,10 +740,12 @@ final class LLMClientRequestBodyTests: XCTestCase { JSONSerialization.jsonObject(with: encoded) as? [String: Any] ) object.removeValue(forKey: "responsesContinuationItems") + object.removeValue(forKey: "responsesContinuationScope") let legacyData = try JSONSerialization.data(withJSONObject: object) let decoded = try JSONDecoder().decode(ChatMessage.self, from: legacyData) XCTAssertEqual(decoded.responsesContinuationItems, []) + XCTAssertNil(decoded.responsesContinuationScope) } func testOfficialProvidersHaveBundledModelsAndBaseURLs() { @@ -840,6 +870,7 @@ final class LLMClientRequestBodyTests: XCTestCase { "role": "assistant", "content": "", "responses_continuation_items": [reasoningItem.inputItem], + "responses_continuation_scope": "codex-account-scope", "tool_calls": [[ "id": "call-1", "type": "function", @@ -854,7 +885,8 @@ final class LLMClientRequestBodyTests: XCTestCase { model: "gpt-5.6-sol", baseURL: "https://chatgpt.com/backend-api/codex", apiKey: "", - streaming: true + streaming: true, + responsesContinuationScope: "codex-account-scope" ) let body = LLMClient.shared.buildResponsesBody(config) @@ -864,6 +896,98 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(input[0]["encrypted_content"] as? String, "opaque-reasoning-state") XCTAssertEqual(input[1]["call_id"] as? String, "call-1") XCTAssertEqual(input[2]["call_id"] as? String, "call-1") + + let switchedConfig = LLMClient.Config( + providerID: GrokSubscriptionAuth.providerID, + messages: config.messages, + model: "grok-code-fast-1", + baseURL: GrokSubscriptionAuth.proxyBaseURL, + apiKey: "", + streaming: true, + responsesContinuationScope: "different-provider-scope" + ) + let switchedBody = LLMClient.shared.buildResponsesBody(switchedConfig) + let switchedInput = try XCTUnwrap(switchedBody["input"] as? [[String: Any]]) + XCTAssertEqual(switchedInput.map { $0["type"] as? String }, ["function_call", "function_call_output"]) + } + + func testResponsesContinuationScopeChangesAcrossAccountsAndProviders() { + let firstSession = OfficialProviderAuth.Session( + providerID: OfficialProviderAuth.codexProviderID, + accessToken: "rotating-access-token", + baseURL: CodexSubscriptionAuth.baseURL, + wireProtocol: .responses, + headers: ["ChatGPT-Account-ID": "account-1"], + accountLabel: "first@example.com", + project: nil + ) + let rotatedSession = OfficialProviderAuth.Session( + providerID: OfficialProviderAuth.codexProviderID, + accessToken: "new-access-token", + baseURL: CodexSubscriptionAuth.baseURL, + wireProtocol: .responses, + headers: ["ChatGPT-Account-ID": "account-1"], + accountLabel: "first@example.com", + project: nil + ) + let secondSession = OfficialProviderAuth.Session( + providerID: OfficialProviderAuth.codexProviderID, + accessToken: "other-access-token", + baseURL: CodexSubscriptionAuth.baseURL, + wireProtocol: .responses, + headers: ["ChatGPT-Account-ID": "account-2"], + accountLabel: "second@example.com", + project: nil + ) + + let firstScope = OfficialProviderAuth.responsesContinuationScope( + providerID: OfficialProviderAuth.codexProviderID, + baseURL: CodexSubscriptionAuth.baseURL, + apiKey: "", + session: firstSession + ) + let rotatedScope = OfficialProviderAuth.responsesContinuationScope( + providerID: OfficialProviderAuth.codexProviderID, + baseURL: CodexSubscriptionAuth.baseURL, + apiKey: "", + session: rotatedSession + ) + let secondScope = OfficialProviderAuth.responsesContinuationScope( + providerID: OfficialProviderAuth.codexProviderID, + baseURL: CodexSubscriptionAuth.baseURL, + apiKey: "", + session: secondSession + ) + let apiKeyScope = OfficialProviderAuth.responsesContinuationScope( + providerID: "openai", + baseURL: "https://api.openai.com/v1", + apiKey: "api-key", + session: nil + ) + + XCTAssertEqual(firstScope, rotatedScope) + XCTAssertNotEqual(firstScope, secondScope) + XCTAssertNotEqual(firstScope, apiKeyScope) + XCTAssertFalse(firstScope.contains("rotating-access-token")) + } + + func testResponsesStreamingRejectsTerminalFailuresAndVerificationRequiresOutput() throws { + let events: [[String: Any]] = [ + ["type": "error", "message": "quota exhausted"], + ["type": "response.failed", "response": ["error": ["message": "model unavailable"]]], + ["type": "response.incomplete", "response": ["incomplete_details": ["reason": "max_output_tokens"]]], + ] + + for event in events { + let error = try XCTUnwrap(LLMClient.responsesStreamingTerminalError(from: event)) + guard case let .invalidRequest(message) = error else { + return XCTFail("Expected a Responses terminal failure") + } + XCTAssertTrue(message.contains("Responses API stream failed")) + } + XCTAssertNil(LLMClient.responsesStreamingTerminalError(from: ["type": "response.completed"])) + XCTAssertFalse(LLMClient.Response(thinking: nil, content: "", toolCalls: []).hasUsableVerificationOutput) + XCTAssertTrue(LLMClient.Response(thinking: nil, content: "OK", toolCalls: []).hasUsableVerificationOutput) } func testOfficialProviderGuideLabelsUseGuideIconContract() throws { From b9bc97d77af2eec496cfd07f3f71a1b956118993 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 19:32:03 -0400 Subject: [PATCH 7/9] Close remaining OAuth stream races --- .../Services/CodexSubscriptionAuth.swift | 1 + .../Fluid/Services/GrokSubscriptionAuth.swift | 1 + Sources/Fluid/Services/LLMClient.swift | 18 ++++++++++ .../LLMClientRequestBodyTests.swift | 36 ++++++++++++++++++- 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Sources/Fluid/Services/CodexSubscriptionAuth.swift b/Sources/Fluid/Services/CodexSubscriptionAuth.swift index b4678cd31..abf3db132 100644 --- a/Sources/Fluid/Services/CodexSubscriptionAuth.swift +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -250,6 +250,7 @@ enum CodexSubscriptionAuth { now: now(), session: session ) + await self.refreshCoalescer.cancelAndWait() try self.storeOAuthSession(oauthSession) return oauthSession } diff --git a/Sources/Fluid/Services/GrokSubscriptionAuth.swift b/Sources/Fluid/Services/GrokSubscriptionAuth.swift index 82227602a..496819c00 100644 --- a/Sources/Fluid/Services/GrokSubscriptionAuth.swift +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -326,6 +326,7 @@ enum GrokSubscriptionAuth { } if (200..<300).contains(http.statusCode) { let oauthSession = try self.decodeOAuthSession(from: data, now: now()) + await self.refreshCoalescer.cancelAndWait() try self.storeOAuthSession(oauthSession) return oauthSession } diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index f1c8e8c8d..2b2b36dd0 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -308,6 +308,19 @@ final class LLMClient { return .invalidRequest("Responses API stream failed: \(detail)") } + static func anthropicStreamingError(from event: [String: Any]) -> LLMError? { + guard event["type"] as? String == "error" else { return nil } + let providerError = event["error"] as? [String: Any] + let detail = [ + providerError?["message"] as? String, + event["message"] as? String, + providerError?["type"] as? String, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? "unknown Anthropic streaming error" + return .invalidRequest("Anthropic stream failed: \(detail)") + } + /// Execute request with retry logic (extracted for timeout wrapper) private func executeWithRetry( request: URLRequest, @@ -547,6 +560,7 @@ final class LLMClient { if !config.tools.isEmpty { body["tools"] = self.responsesTools(from: config.tools) body["tool_choice"] = "auto" + body["parallel_tool_calls"] = false } // The ChatGPT Codex subscription backend follows the official Codex @@ -934,6 +948,10 @@ final class LLMClient { let type = event["type"] as? String else { continue } + if let streamError = Self.anthropicStreamingError(from: event) { + throw streamError + } + let index = event["index"] as? Int ?? 0 switch type { case "content_block_start": diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index ee8ebb278..c99e63b57 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -269,13 +269,15 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(refreshCount, 1) } - func testOAuthRefreshCoalescerCancelsAndAwaitsActiveRefresh() async { + func testOAuthRefreshCoalescerCancelsBeforeCredentialReplacement() async { let coalescer = InFlightTaskCoalescer() var observedCancellation = false + var storedCredential: String? let refresh = Task { @MainActor in try await coalescer.value { do { try await Task.sleep(nanoseconds: 30_000_000_000) + storedCredential = "stale-rotated-token" return "stale-rotated-token" } catch is CancellationError { observedCancellation = true @@ -286,6 +288,7 @@ final class LLMClientRequestBodyTests: XCTestCase { await Task.yield() await coalescer.cancelAndWait() + storedCredential = "new-login-token" XCTAssertTrue(observedCancellation) do { @@ -294,6 +297,7 @@ final class LLMClientRequestBodyTests: XCTestCase { } catch { XCTAssertTrue(error is CancellationError) } + XCTAssertEqual(storedCredential, "new-login-token") } func testOAuthTransportCancellationRemainsCancellation() { @@ -911,6 +915,21 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertEqual(switchedInput.map { $0["type"] as? String }, ["function_call", "function_call_output"]) } + func testGrokResponsesToolsDisableParallelCallsForSingleCallConsumer() { + let config = LLMClient.Config( + providerID: GrokSubscriptionAuth.providerID, + messages: [["role": "user", "content": "Run pwd"]], + model: "grok-code-fast-1", + baseURL: GrokSubscriptionAuth.proxyBaseURL, + apiKey: "", + streaming: true, + tools: [TerminalService.toolDefinition] + ) + + let body = LLMClient.shared.buildResponsesBody(config) + XCTAssertEqual(body["parallel_tool_calls"] as? Bool, false) + } + func testResponsesContinuationScopeChangesAcrossAccountsAndProviders() { let firstSession = OfficialProviderAuth.Session( providerID: OfficialProviderAuth.codexProviderID, @@ -990,6 +1009,21 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertTrue(LLMClient.Response(thinking: nil, content: "OK", toolCalls: []).hasUsableVerificationOutput) } + func testAnthropicStreamingRejectsProviderErrorEvents() throws { + let error = try XCTUnwrap(LLMClient.anthropicStreamingError(from: [ + "type": "error", + "error": [ + "type": "overloaded_error", + "message": "Provider overloaded", + ], + ])) + guard case let .invalidRequest(message) = error else { + return XCTFail("Expected an Anthropic streaming error") + } + XCTAssertEqual(message, "Anthropic stream failed: Provider overloaded") + XCTAssertNil(LLMClient.anthropicStreamingError(from: ["type": "message_stop"])) + } + func testOfficialProviderGuideLabelsUseGuideIconContract() throws { for providerID in OfficialProviderAuth.providerIDs { let info = try XCTUnwrap(OfficialProviderAuth.info(for: providerID)) From 178a1e642a3e6c19866947aeb7dd4d25a000221b Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 19:47:42 -0400 Subject: [PATCH 8/9] Scope Gemini tool continuations --- .../Fluid/Services/CommandModeService.swift | 3 ++ Sources/Fluid/Services/LLMClient.swift | 27 ++++++++++++-- .../LLMClientRequestBodyTests.swift | 35 ++++++++++++++++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index c2da467bd..7b2fd91c9 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -896,6 +896,9 @@ final class CommandModeService: ObservableObject { "content": msg.content, "tool_calls": [toolCallMessage], ] + if let continuationScope = msg.responsesContinuationScope { + assistantMessage["tool_continuation_scope"] = continuationScope + } if !msg.responsesContinuationItems.isEmpty, let continuationScope = msg.responsesContinuationScope { diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index 2b2b36dd0..d5ac4e30d 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -275,7 +275,7 @@ final class LLMClient { config: effectiveConfig, wireProtocol: wireProtocol ) - guard !response.responsesContinuationItems.isEmpty else { return response } + guard !response.responsesContinuationItems.isEmpty || !response.toolCalls.isEmpty else { return response } return Response( thinking: response.thinking, content: response.content, @@ -321,6 +321,17 @@ final class LLMClient { return .invalidRequest("Anthropic stream failed: \(detail)") } + static func geminiStreamingError(from root: [String: Any]) -> LLMError? { + guard let providerError = root["error"] as? [String: Any] else { return nil } + let detail = [ + providerError["message"] as? String, + providerError["status"] as? String, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? "unknown Gemini streaming error" + return .invalidRequest("Gemini stream failed: \(detail)") + } + /// Execute request with retry logic (extracted for timeout wrapper) private func executeWithRetry( request: URLRequest, @@ -681,8 +692,12 @@ final class LLMClient { var systemParts: [String] = [] var contents: [[String: Any]] = [] var toolNamesByCallID: [String: String] = [:] + var scopedToolCallIDs: Set = [] for message in config.messages { + guard let expectedScope = config.responsesContinuationScope, + message["tool_continuation_scope"] as? String == expectedScope + else { continue } guard let toolCalls = message["tool_calls"] as? [[String: Any]] else { continue } for toolCall in toolCalls { guard let callID = toolCall["id"] as? String, @@ -690,6 +705,7 @@ final class LLMClient { let name = function["name"] as? String else { continue } toolNamesByCallID[callID] = name + scopedToolCallIDs.insert(callID) } } @@ -717,6 +733,7 @@ final class LLMClient { } if role == "tool" { let callID = message["tool_call_id"] as? String ?? "call_unknown" + guard scopedToolCallIDs.contains(callID) else { continue } appendContent(role: "user", parts: [[ "functionResponse": [ "name": toolNamesByCallID[callID] ?? "tool", @@ -732,7 +749,9 @@ final class LLMClient { } if let toolCalls = message["tool_calls"] as? [[String: Any]] { for toolCall in toolCalls { - guard let function = toolCall["function"] as? [String: Any], + guard let callID = toolCall["id"] as? String, + scopedToolCallIDs.contains(callID), + let function = toolCall["function"] as? [String: Any], let name = function["name"] as? String else { continue } let argumentsString = function["arguments"] as? String ?? "{}" @@ -1058,6 +1077,10 @@ final class LLMClient { let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue } + if let streamError = Self.geminiStreamingError(from: root) { + throw streamError + } + let parsed = self.geminiParts(from: root) for text in parsed.content { content.append(text) diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index c99e63b57..8d23c5297 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -683,6 +683,7 @@ final class LLMClientRequestBodyTests: XCTestCase { [ "role": "assistant", "content": "", + "tool_continuation_scope": "gemini-account-scope", "tool_calls": [[ "id": toolCall.id, "type": "function", @@ -698,7 +699,8 @@ final class LLMClientRequestBodyTests: XCTestCase { model: "gemini-2.5-flash", baseURL: "https://cloudcode-pa.googleapis.com/v1internal", apiKey: "", - streaming: true + streaming: true, + responsesContinuationScope: "gemini-account-scope" ) let body = LLMClient.shared.buildGeminiCodeAssistBody(config, project: "projects/example") @@ -706,6 +708,22 @@ final class LLMClientRequestBodyTests: XCTestCase { let contents = try XCTUnwrap(request["contents"] as? [[String: Any]]) let modelParts = try XCTUnwrap(contents.first?["parts"] as? [[String: Any]]) XCTAssertEqual(modelParts.first?["thoughtSignature"] as? String, "opaque-gemini-thought") + + let switchedAccountConfig = LLMClient.Config( + providerID: OfficialProviderAuth.geminiProviderID, + messages: config.messages, + model: "gemini-2.5-flash", + baseURL: "https://cloudcode-pa.googleapis.com/v1internal", + apiKey: "", + streaming: true, + responsesContinuationScope: "different-account-scope" + ) + let switchedBody = LLMClient.shared.buildGeminiCodeAssistBody( + switchedAccountConfig, + project: "projects/other" + ) + let switchedRequest = try XCTUnwrap(switchedBody["request"] as? [String: Any]) + XCTAssertTrue(((switchedRequest["contents"] as? [[String: Any]]) ?? []).isEmpty) } func testCommandHistoryPersistsOpaqueProviderContinuationState() throws { @@ -1024,6 +1042,21 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertNil(LLMClient.anthropicStreamingError(from: ["type": "message_stop"])) } + func testGeminiStreamingRejectsProviderErrorPayloads() throws { + let error = try XCTUnwrap(LLMClient.geminiStreamingError(from: [ + "error": [ + "code": 429, + "message": "Resource exhausted", + "status": "RESOURCE_EXHAUSTED", + ], + ])) + guard case let .invalidRequest(message) = error else { + return XCTFail("Expected a Gemini streaming error") + } + XCTAssertEqual(message, "Gemini stream failed: Resource exhausted") + XCTAssertNil(LLMClient.geminiStreamingError(from: ["response": ["candidates": []]])) + } + func testOfficialProviderGuideLabelsUseGuideIconContract() throws { for providerID in OfficialProviderAuth.providerIDs { let info = try XCTUnwrap(OfficialProviderAuth.info(for: providerID)) From 8865f4b0a0077782633871089cdd77372f393563 Mon Sep 17 00:00:00 2001 From: Jacob Rudolph Date: Sun, 9 Aug 2026 20:07:05 -0400 Subject: [PATCH 9/9] Validate Responses continuations --- .../Fluid/Services/CommandModeService.swift | 26 ++++++++--- Sources/Fluid/Services/LLMClient.swift | 19 ++++++++ .../LLMClientRequestBodyTests.swift | 44 +++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/Sources/Fluid/Services/CommandModeService.swift b/Sources/Fluid/Services/CommandModeService.swift index 7b2fd91c9..7a4eb7ba7 100644 --- a/Sources/Fluid/Services/CommandModeService.swift +++ b/Sources/Fluid/Services/CommandModeService.swift @@ -754,6 +754,22 @@ final class CommandModeService: ObservableObject { } } + static func terminalToolArguments( + command: String, + workingDirectory: String?, + purpose: String? + ) throws -> String { + var arguments: [String: Any] = ["command": command] + if let workingDirectory { + arguments["workingDirectory"] = workingDirectory + } + if let purpose { + arguments["purpose"] = purpose + } + let data = try JSONSerialization.data(withJSONObject: arguments) + return String(data: data, encoding: .utf8) ?? "{}" + } + private func callLLM() async throws -> LLMResponse { let settings = SettingsStore.shared if let issue = settings.commandModeReadinessIssue { @@ -871,11 +887,11 @@ final class CommandModeService: ObservableObject { lastToolCallId = tc.id let argsJSON: String do { - let data = try JSONSerialization.data(withJSONObject: [ - "command": tc.command, - "workingDirectory": tc.workingDirectory ?? "", - ]) - argsJSON = String(data: data, encoding: .utf8) ?? "{}" + argsJSON = try Self.terminalToolArguments( + command: tc.command, + workingDirectory: tc.workingDirectory, + purpose: tc.purpose + ) } catch { DebugLogger.shared.error("Failed to encode tool call args: \(error)", source: "CommandModeService") argsJSON = "{}" diff --git a/Sources/Fluid/Services/LLMClient.swift b/Sources/Fluid/Services/LLMClient.swift index d5ac4e30d..70c467e4e 100644 --- a/Sources/Fluid/Services/LLMClient.swift +++ b/Sources/Fluid/Services/LLMClient.swift @@ -308,6 +308,22 @@ final class LLMClient { return .invalidRequest("Responses API stream failed: \(detail)") } + static func responsesNonStreamingTerminalError(from response: [String: Any]) -> LLMError? { + let status = response["status"] as? String + let providerError = response["error"] as? [String: Any] + guard providerError != nil || (status != nil && status != "completed") else { return nil } + + let incompleteDetails = response["incomplete_details"] as? [String: Any] + let detail = [ + providerError?["message"] as? String, + incompleteDetails?["reason"] as? String, + status, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? "unknown Responses API error" + return .invalidRequest("Responses API request failed: \(detail)") + } + static func anthropicStreamingError(from event: [String: Any]) -> LLMError? { guard event["type"] as? String == "error" else { return nil } let providerError = event["error"] as? [String: Any] @@ -932,6 +948,9 @@ final class LLMClient { return try self.parseGeminiResponse(json) } if wireProtocol == .responses || self.isResponsesRequest(request) { + if let terminalError = Self.responsesNonStreamingTerminalError(from: json) { + throw terminalError + } return try self.parseResponsesResponse(json) } diff --git a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift index 8d23c5297..2b807be4f 100644 --- a/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift +++ b/Tests/FluidDictationIntegrationTests/LLMClientRequestBodyTests.swift @@ -1027,6 +1027,50 @@ final class LLMClientRequestBodyTests: XCTestCase { XCTAssertTrue(LLMClient.Response(thinking: nil, content: "OK", toolCalls: []).hasUsableVerificationOutput) } + func testResponsesNonStreamingRejectsIncompleteAndFailedResults() throws { + let responses: [[String: Any]] = [ + [ + "status": "incomplete", + "incomplete_details": ["reason": "max_output_tokens"], + "output": [["type": "message"]], + ], + [ + "status": "failed", + "error": ["message": "model unavailable"], + "output": [], + ], + ] + + for response in responses { + let error = try XCTUnwrap(LLMClient.responsesNonStreamingTerminalError(from: response)) + guard case let .invalidRequest(message) = error else { + return XCTFail("Expected a non-streaming Responses terminal failure") + } + XCTAssertTrue(message.contains("Responses API request failed")) + } + XCTAssertNil(LLMClient.responsesNonStreamingTerminalError(from: [ + "status": "completed", + "output": [], + ])) + XCTAssertNil(LLMClient.responsesNonStreamingTerminalError(from: ["output": []])) + } + + func testTerminalContinuationReplaysPurposeWithoutSynthesizingOptionalDirectory() throws { + let argumentsJSON = try CommandModeService.terminalToolArguments( + command: "pwd", + workingDirectory: nil, + purpose: "Inspect the working directory" + ) + let data = try XCTUnwrap(argumentsJSON.data(using: .utf8)) + let arguments = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + XCTAssertEqual(arguments["command"] as? String, "pwd") + XCTAssertEqual(arguments["purpose"] as? String, "Inspect the working directory") + XCTAssertNil(arguments["workingDirectory"]) + } + func testAnthropicStreamingRejectsProviderErrorEvents() throws { let error = try XCTUnwrap(LLMClient.anthropicStreamingError(from: [ "type": "error",