diff --git a/.github/screenshots/oauth-login-providers.jpeg b/.github/screenshots/oauth-login-providers.jpeg new file mode 100644 index 000000000..e50700384 Binary files /dev/null and b/.github/screenshots/oauth-login-providers.jpeg differ 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/ChatHistoryStore.swift b/Sources/Fluid/Persistence/ChatHistoryStore.swift index fd73c9cf4..52b557909 100644 --- a/Sources/Fluid/Persistence/ChatHistoryStore.swift +++ b/Sources/Fluid/Persistence/ChatHistoryStore.swift @@ -15,6 +15,8 @@ struct ChatMessage: Codable, Identifiable, Equatable { let role: Role let content: String let toolCall: ToolCall? + let responsesContinuationItems: [LLMClient.ResponsesContinuationItem] + let responsesContinuationScope: String? let stepType: StepType let timestamp: Date @@ -39,16 +41,71 @@ 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] = [], + responsesContinuationScope: String? = nil, + stepType: StepType = .normal, + timestamp: Date = Date() + ) { self.id = id self.role = role self.content = content self.toolCall = toolCall + self.responsesContinuationItems = responsesContinuationItems + self.responsesContinuationScope = responsesContinuationScope self.stepType = stepType self.timestamp = timestamp } + + private enum CodingKeys: String, CodingKey { + case id + case role + case content + case toolCall + case responsesContinuationItems + case responsesContinuationScope + 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.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) + } } // MARK: - Chat Session Model 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..abf3db132 --- /dev/null +++ b/Sources/Fluid/Services/CodexSubscriptionAuth.swift @@ -0,0 +1,488 @@ +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 + } + } + + 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. +/// +/// 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 + private static let refreshCoalescer = InFlightTaskCoalescer() + + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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 + ) + await self.refreshCoalescer.cancelAndWait() + 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.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) + ?? 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 { + 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, + accountID: oauthSession.accountID, + accountLabel: oauthSession.accountLabel + ) + } + + 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) + } + } + + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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 [:] } + 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 [:] } + 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..7a4eb7ba7 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,8 @@ 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 responsesContinuationScope: String? let stepType: StepType let timestamp: Date @@ -92,13 +94,38 @@ 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(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] = [], + responsesContinuationScope: String? = nil, + stepType: StepType = .normal + ) { self.role = role self.content = content self.thinking = thinking self.toolCall = toolCall + self.responsesContinuationItems = responsesContinuationItems + self.responsesContinuationScope = responsesContinuationScope self.stepType = stepType self.timestamp = Date() } @@ -229,7 +256,8 @@ final class CommandModeService: ObservableObject { id: tc.id, command: tc.command, workingDirectory: tc.workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ) } @@ -238,6 +266,8 @@ final class CommandModeService: ObservableObject { role: role, content: msg.content, toolCall: toolCall, + responsesContinuationItems: msg.responsesContinuationItems, + responsesContinuationScope: msg.responsesContinuationScope, stepType: stepType, timestamp: msg.timestamp ) @@ -268,7 +298,8 @@ final class CommandModeService: ObservableObject { id: tc.id, command: tc.command, workingDirectory: tc.workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ) } @@ -276,6 +307,8 @@ final class CommandModeService: ObservableObject { role: role, content: chatMsg.content, toolCall: toolCall, + responsesContinuationItems: chatMsg.responsesContinuationItems, + responsesContinuationScope: chatMsg.responsesContinuationScope, stepType: stepType ) } @@ -297,7 +330,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) } @@ -420,8 +455,11 @@ final class CommandModeService: ObservableObject { command: tc.command, workingDirectory: tc .workingDirectory, - purpose: tc.purpose + purpose: tc.purpose, + thoughtSignature: tc.thoughtSignature ), + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope, stepType: stepType )) @@ -704,13 +742,32 @@ final class CommandModeService: ObservableObject { let content: String 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 let command: String let workingDirectory: String? let purpose: String? + let thoughtSignature: String? + } + } + + 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 { @@ -830,27 +887,41 @@ 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 = "{}" } - messages.append([ + 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 let continuationScope = msg.responsesContinuationScope { + assistantMessage["tool_continuation_scope"] = continuationScope + } + 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 { messages.append(["role": "assistant", "content": msg.content]) } @@ -892,6 +963,7 @@ final class CommandModeService: ObservableObject { // Build LLMClient configuration var config = LLMClient.Config( + providerID: providerID, messages: messages, model: model, baseURL: baseURL, @@ -996,8 +1068,11 @@ final class CommandModeService: ObservableObject { id: tc.id, command: command, workingDirectory: workDir, - purpose: purpose - ) + purpose: purpose, + thoughtSignature: tc.thoughtSignature + ), + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope ) } @@ -1013,7 +1088,9 @@ final class CommandModeService: ObservableObject { return LLMResponse( content: response.content, thinking: finalThinking, // Display-only - toolCall: nil + toolCall: nil, + responsesContinuationItems: response.responsesContinuationItems, + responsesContinuationScope: response.responsesContinuationScope ) } } 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..496819c00 --- /dev/null +++ b/Sources/Fluid/Services/GrokSubscriptionAuth.swift @@ -0,0 +1,760 @@ +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" + private static let refreshCoalescer = InFlightTaskCoalescer() + + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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()) + await self.refreshCoalescer.cancelAndWait() + 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, + previousUserID: 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) + guard !accessToken.isEmpty else { + throw AuthError.invalidOAuthResponse + } + + 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) + + 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 { + 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, + 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() async throws { + await self.refreshCoalescer.cancelAndWait() + 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 { + try OfficialProviderAuth.rethrowCancellation(error) + 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, + previousUserID: oauthSession.userID, + previousEmail: oauthSession.email, + 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") + } + + private nonisolated static func jwtClaims(from token: String) -> [String: Any] { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + 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 [:] } + 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) + } + + private nonisolated 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[.. String? { @@ -110,14 +159,23 @@ final class LLMClient { var arguments: String = "" } + private struct AnthropicToolCallAccumulator { + var id: String? + var name: String? + var arguments: String = "" + } + // MARK: - Configuration struct Config { + /// Provider identity is required for non-OpenAI wire protocols and + /// first-party subscription logins. Empty preserves legacy behavior. + let providerID: String let messages: [[String: Any]] let model: String let baseURL: String let apiKey: String - let streaming: Bool + var streaming: Bool let tools: [[String: Any]] let temperature: Double? @@ -128,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 @@ -143,6 +204,7 @@ final class LLMClient { var onToolCallStart: ((String) -> Void)? init( + providerID: String = "", messages: [[String: Any]], model: String, baseURL: String, @@ -151,8 +213,10 @@ 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 self.model = model self.baseURL = baseURL @@ -162,6 +226,7 @@ final class LLMClient { self.temperature = temperature self.maxTokens = maxTokens self.extraParameters = extraParameters + self.responsesContinuationScope = responsesContinuationScope } } @@ -171,7 +236,29 @@ 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 + ) + 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 // Apply timeout to the request itself let timeout = config.timeoutSeconds ?? Self.defaultTimeoutSeconds @@ -183,21 +270,106 @@ 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) + let response = try await self.executeWithRetry( + request: request, + config: effectiveConfig, + wireProtocol: wireProtocol + ) + guard !response.responsesContinuationItems.isEmpty || !response.toolCalls.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)") + } + + 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] + 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)") + } + + 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, 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 +394,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 +453,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,21 +554,46 @@ 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, + expectedContinuationScope: config.responsesContinuationScope + ), "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 if !config.tools.isEmpty { body["tools"] = self.responsesTools(from: config.tools) body["tool_choice"] = "auto" + body["parallel_tool_calls"] = false } - 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 +612,226 @@ 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 { + 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 { continue } + var converted: [String: Any] = ["name": name, "input_schema": parameters] + if let description = function["description"] as? String { + converted["description"] = description + } + convertedTools.append(converted) + } + body["tools"] = convertedTools + } + 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] = [:] + 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, + let function = toolCall["function"] as? [String: Any], + let name = function["name"] as? String + else { continue } + toolNamesByCallID[callID] = name + scopedToolCallIDs.insert(callID) + } + } + + 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" + guard scopedToolCallIDs.contains(callID) else { continue } + 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 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 ?? "{}" + let arguments = argumentsString.data(using: .utf8) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } ?? [:] + 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) + } + + 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 { + 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 { continue } + var declaration: [String: Any] = ["name": name, "parameters": parameters] + if let description = function["description"] as? String { + declaration["description"] = description + } + declarations.append(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 +865,25 @@ final class LLMClient { return tools } - private func responsesInput(from messages: [[String: Any]]) -> [[String: Any]] { + private func responsesInput( + from messages: [[String: Any]], + excludingSystemMessages: Bool = false, + expectedContinuationScope: String? = nil + ) -> [[String: Any]] { var input: [[String: Any]] = [] for message in messages { let role = message["role"] as? String ?? "user" + if excludingSystemMessages, role == "system" { + continue + } + + if let expectedContinuationScope, + message["responses_continuation_scope"] as? String == expectedContinuationScope, + let continuationItems = message["responses_continuation_items"] as? [[String: Any]] + { + input.append(contentsOf: continuationItems) + } if role == "tool" { input.append([ @@ -462,7 +921,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 +941,16 @@ 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) { + if let terminalError = Self.responsesNonStreamingTerminalError(from: json) { + throw terminalError + } return try self.parseResponsesResponse(json) } @@ -491,6 +962,173 @@ 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 } + + if let streamError = Self.anthropicStreamingError(from: event) { + throw streamError + } + + 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 } + + if let streamError = Self.geminiStreamingError(from: root) { + throw streamError + } + + 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") @@ -507,6 +1145,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) @@ -527,6 +1166,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 { @@ -534,10 +1177,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 @@ -590,7 +1238,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] + } ) } @@ -792,6 +1443,95 @@ 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 + ) + } + + 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] ?? [:], + thoughtSignature: part["thoughtSignature"] as? String + )) + } + } + } + return (content, thinking, tools) + } + private func parseResponsesResponse(_ json: [String: Any]) throws -> Response { guard let output = json["output"] as? [[String: Any]] else { throw LLMError.invalidResponse @@ -799,6 +1539,7 @@ final class LLMClient { var contentParts: [String] = [] var parsedToolCalls: [ToolCall] = [] + var continuationItems: [ResponsesContinuationItem] = [] for item in output { switch item["type"] as? String { @@ -825,6 +1566,10 @@ final class LLMClient { arguments: args ) ) + case "reasoning": + if let continuationItem = self.responsesContinuationItem(from: item) { + continuationItems.append(continuationItem) + } default: continue } @@ -836,10 +1581,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 ?? "" @@ -1003,7 +1760,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..d3239e8c4 --- /dev/null +++ b/Sources/Fluid/Services/OfficialProviderAuth.swift @@ -0,0 +1,681 @@ +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 +/// 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 + private static let geminiProjectCache = CredentialScopedValueCache() + 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 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(), + 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 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, + 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.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"]) + 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 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: resolvedLabel, + 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 { + 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), + 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 + } + + 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) + .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 nonisolated static func jwtClaims(from token: String) -> [String: Any] { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + 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 [:] } + return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + 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 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, + !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..a7333cba1 100644 --- a/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/AIEnhancementSettingsViewModel.swift @@ -1,10 +1,50 @@ import AppKit import Combine -import CryptoKit 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 @@ -60,6 +100,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 let officialProviderSignInTasks = OfficialProviderSignInTaskCoordinator() // UI State @Published var showHelp: Bool = false @@ -188,7 +232,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 @@ -200,7 +246,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 @@ -247,9 +295,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)" } @@ -275,6 +327,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 +363,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. @@ -527,8 +591,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) } @@ -734,10 +802,100 @@ final class AIEnhancementSettingsViewModel: ObservableObject { // MARK: - API Connection Testing + func beginOfficialProviderSignIn(_ providerID: String) { + self.officialProviderSignInTasks.start(providerID: providerID) { [weak self] in + await self?.signInWithOfficialProvider(providerID) + } + } + + func cancelOfficialProviderSignIn(_ providerID: String) { + self.officialProviderSignInTasks.cancel(providerID: providerID) + } + + 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.isTestingConnection = false + + await self.testAPIConnection(providerID: providerID) + } catch is CancellationError { + self.oauthSignInMessageByProvider.removeValue(forKey: providerID) + self.oauthSignInURLByProvider.removeValue(forKey: providerID) + self.signingInProviderID = 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.isTestingConnection = false + self.updateConnectionStatus(.failed, for: providerID) + self.setConnectionError(message, for: providerID) + } + } + + func disconnectOfficialProvider(_ providerID: String) async { + await self.officialProviderSignInTasks.cancelAndWait(providerID: providerID) + + do { + switch providerID { + case GrokSubscriptionAuth.providerID: + try await GrokSubscriptionAuth.disconnectFluidVoiceSession() + case CodexSubscriptionAuth.providerID: + try await 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 { + 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) { @@ -758,7 +916,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) @@ -766,7 +924,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) @@ -781,6 +939,44 @@ 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 + 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: "") + 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 @@ -950,6 +1146,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)? @@ -1129,7 +1339,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 @@ -1230,7 +1442,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) } @@ -1344,14 +1558,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 +1580,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 +1594,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 @@ -1411,12 +1623,14 @@ 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) 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 { @@ -1889,7 +2103,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/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift b/Sources/Fluid/UI/AISettingsView+AIConfiguration.swift index 72b451118..72291f4d7 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,105 @@ 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") { + Task { + await 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..2b807be4f 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,958 @@ 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 testOAuthRefreshCoalescerSharesOneInFlightOperation() 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 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 + throw CancellationError() + } + } + } + await Task.yield() + + await coalescer.cancelAndWait() + storedCredential = "new-login-token" + + XCTAssertTrue(observedCancellation) + do { + _ = try await refresh.value + XCTFail("The refresh should be cancelled before disconnect can delete its credential") + } catch { + XCTAssertTrue(error is CancellationError) + } + XCTAssertEqual(storedCredential, "new-login-token") + } + + 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": [ + "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 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: [ + "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, + 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() { + 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 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_continuation_scope": "gemini-account-scope", + "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, + responsesContinuationScope: "gemini-account-scope" + ) + + 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") + + 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 { + 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" + ), + ], + responsesContinuationScope: "credential-scope", + 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") + 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() { + 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"]) + } + + 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], + "responses_continuation_scope": "codex-account-scope", + "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, + responsesContinuationScope: "codex-account-scope" + ) + + 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") + + 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 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, + 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 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", + "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 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)) + XCTAssertTrue(info.setupLabel.contains("Guide")) + } + } + + 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) { @@ -196,4 +1165,31 @@ 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" + } +} + +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() {} }