diff --git a/Sources/Tachikoma/Auth/AuthManager.swift b/Sources/Tachikoma/Auth/AuthManager.swift index e188a4e..592f57c 100644 --- a/Sources/Tachikoma/Auth/AuthManager.swift +++ b/Sources/Tachikoma/Auth/AuthManager.swift @@ -45,7 +45,12 @@ public enum TKProviderId: String, CaseIterable, Sendable { public var credentialKeys: [String] { switch self { - case .openai: ["OPENAI_API_KEY", "OPENAI_ACCESS_TOKEN"] + case .openai: [ + "OPENAI_API_KEY", + "OPENAI_ACCESS_TOKEN", + "OPENAI_REFRESH_TOKEN", + "OPENAI_ACCESS_EXPIRES", + ] // swiftformat:disable indent case .anthropic: [ "ANTHROPIC_API_KEY", @@ -71,6 +76,123 @@ public enum TKAuthValue: Sendable { case bearer(String, betaHeader: String?) } +struct TKOpenAICodexAuth: Sendable, Equatable { + let accessToken: String + let accountID: String +} + +private struct TKOpenAICodexCredentialSnapshot: Sendable, Equatable { + enum Source: Sendable, Equatable { + case environment + case store + } + + let source: Source + let accessToken: String? + let refreshToken: String? + let expiration: Date? + + var accountID: String? { + self.accessToken.flatMap(TKOpenAICodexJWT.accountID(from:)) + } + + var hasRefreshToken: Bool { + self.refreshToken?.isEmpty == false + } + + func hasValidAccessToken(at date: Date) -> Bool { + self.accessToken?.isEmpty == false && + self.accountID != nil && + (self.expiration.map { $0 > date.addingTimeInterval(60) } ?? true) + } + + var refreshTaskKey: String? { + guard let refreshToken, !refreshToken.isEmpty else { return nil } + let digest = Data(TKHasher.hash(data: Data(refreshToken.utf8))).base64EncodedString() + let accountID = self.accountID ?? "unknown-account" + return "\(self.source)-\(accountID)-\(digest)" + } +} + +private struct TKOpenAICodexEnvironmentCredentialCache: Sendable, Equatable { + let source: TKOpenAICodexCredentialSnapshot + let refreshed: TKOpenAICodexCredentialSnapshot +} + +enum TKOpenAICodexJWT { + private static let authClaim = "https://api.openai.com/auth" + + static func accountID(from token: String) -> String? { + guard + let payload = self.payload(from: token), + let auth = payload[self.authClaim] as? [String: Any], + let accountID = auth["chatgpt_account_id"] as? String, + !accountID.isEmpty + else { + return nil + } + return accountID + } + + static func expiration(from token: String) -> Date? { + guard + let payload = self.payload(from: token), + let expiration = payload["exp"] as? NSNumber + else { + return nil + } + return Date(timeIntervalSince1970: expiration.doubleValue) + } + + private static func payload(from token: String) -> [String: Any]? { + let parts = token.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, !parts[1].isEmpty else { return nil } + + var encoded = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - encoded.count % 4) % 4 + encoded.append(String(repeating: "=", count: padding)) + + guard + let data = Data(base64Encoded: encoded), + let json = try? JSONSerialization.jsonObject(with: data), + let payload = json as? [String: Any] + else { + return nil + } + return payload + } +} + +private actor TKOpenAICodexRefreshCoordinator { + static let shared = TKOpenAICodexRefreshCoordinator() + + private var refreshTasks: [String: Task] = [:] + + func resolve( + key: String, + current: @Sendable () throws -> TKOpenAICodexAuth?, + refresh: @Sendable @escaping () async throws -> TKOpenAICodexAuth, + ) async throws + -> TKOpenAICodexAuth + { + if let auth = try current() { + return auth + } + if let refreshTask = self.refreshTasks[key] { + let auth = try await refreshTask.value + return try current() ?? auth + } + + let refreshTask = Task { try await refresh() } + self.refreshTasks[key] = refreshTask + defer { self.refreshTasks[key] = nil } + let auth = try await refreshTask.value + return try current() ?? auth + } +} + public enum TKValidationResult: Sendable { case success case failure(String) @@ -129,13 +251,15 @@ public struct TKCredentialStore { } } -public final class TKAuthManager { - public nonisolated(unsafe) static let shared = TKAuthManager() +public final class TKAuthManager: @unchecked Sendable { + public static let shared = TKAuthManager() private let store = TKCredentialStore() private let lock = NSLock() private var ignoreEnv = false private var ignoreStore = false + private var credentialGeneration: UInt64 = 0 + private var openAICodexEnvironmentCredentialCache: TKOpenAICodexEnvironmentCredentialCache? private init() {} @@ -162,9 +286,14 @@ public final class TKAuthManager { @discardableResult public func setIgnoreEnvironment(_ value: Bool) -> Bool { self.lock.lock() + defer { self.lock.unlock() } let previous = self.ignoreEnv + guard previous != value else { + return previous + } self.ignoreEnv = value - self.lock.unlock() + self.openAICodexEnvironmentCredentialCache = nil + self.credentialGeneration &+= 1 return previous } @@ -191,9 +320,6 @@ public final class TKAuthManager { if let env = self.environmentValue(for: "OPENAI_API_KEY", ignoringEnvironment: state.ignoreEnv) { return .bearer(env, betaHeader: nil) } - if let access = creds["OPENAI_ACCESS_TOKEN"], !access.isEmpty { - return .bearer(access, betaHeader: nil) - } if let key = creds["OPENAI_API_KEY"], !key.isEmpty { return .apiKey(key) } @@ -234,12 +360,307 @@ public final class TKAuthManager { return nil } - public func setCredential(key: String, value: String) throws { + @discardableResult + private func updateCredentials( + _ values: [String: String], + expectedGeneration: UInt64? = nil, + ) throws + -> Bool + { self.lock.lock() + defer { self.lock.unlock() } + if let expectedGeneration, expectedGeneration != self.credentialGeneration { + return false + } var creds = self.store.load() - creds[key] = value + values.forEach { creds[$0.key] = $0.value } try self.store.save(creds) + self.credentialGeneration &+= 1 + return true + } + + public func setCredentials(_ values: [String: String]) throws { + try self.updateCredentials(values) + } + + public func setCredential(key: String, value: String) throws { + try self.setCredentials([key: value]) + } + + func hasOpenAICodexCredentials() -> Bool { + self.openAICodexCredentialState().snapshots.contains { + $0.accessToken?.isEmpty == false || $0.refreshToken?.isEmpty == false + } + } + + func resolveOpenAICodexAuth( + timeout: Double = 30, + session: URLSession? = nil, + ) async throws + -> TKOpenAICodexAuth + { + if let auth = try self.currentOpenAICodexAuth() { + return auth + } + + let credentialState = self.openAICodexCredentialState() + let prioritizedSnapshots = self.prioritizedOpenAICodexSnapshots(credentialState.snapshots) + guard + let snapshot = prioritizedSnapshots.first(where: \.hasRefreshToken), + let refreshTaskKey = snapshot.refreshTaskKey + else { + throw TachikomaError.authenticationFailed("OpenAI OAuth token expired; sign in again") + } + + return try await TKOpenAICodexRefreshCoordinator.shared.resolve( + key: refreshTaskKey, + current: { try self.currentOpenAICodexAuth() }, + refresh: { + try await self.refreshOpenAICodexAuth( + credentialState: credentialState, + snapshot: snapshot, + timeout: timeout, + session: session, + ) + }, + ) + } + + private func currentOpenAICodexAuth() throws -> TKOpenAICodexAuth? { + let snapshots = self.prioritizedOpenAICodexSnapshots( + self.openAICodexCredentialState().snapshots, + ) + for snapshot in snapshots { + guard + let accessToken = snapshot.accessToken, + snapshot.hasValidAccessToken(at: Date()), + let accountID = snapshot.accountID + else { + continue + } + return TKOpenAICodexAuth(accessToken: accessToken, accountID: accountID) + } + + if snapshots.contains(where: \.hasRefreshToken) { + return nil + } + if snapshots.contains(where: { $0.accessToken?.isEmpty == false }) { + throw TachikomaError.authenticationFailed( + "OpenAI OAuth token is missing the ChatGPT account identifier; sign in again", + ) + } + return nil + } + + private func refreshOpenAICodexAuth( + credentialState: ( + snapshots: [TKOpenAICodexCredentialSnapshot], + generation: UInt64, + environmentSource: TKOpenAICodexCredentialSnapshot?), + snapshot: TKOpenAICodexCredentialSnapshot, + timeout: Double, + session: URLSession?, + ) async throws + -> TKOpenAICodexAuth + { + guard let refreshToken = snapshot.refreshToken, !refreshToken.isEmpty else { + throw TachikomaError.authenticationFailed("OpenAI OAuth token expired; sign in again") + } + guard let url = URL(string: "https://auth.openai.com/oauth/token") else { + throw TachikomaError.invalidConfiguration("Invalid OpenAI OAuth token endpoint") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = timeout + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "client_id": "app_EMoamEEZ73f0CkXaXp7hrann", + "grant_type": "refresh_token", + "refresh_token": refreshToken, + ]) + + let (data, response) = try await (session ?? .shared).data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw TachikomaError.networkError(NSError(domain: "Invalid OAuth response", code: 0)) + } + guard (200...299).contains(httpResponse.statusCode) else { + let detail = String(data: data, encoding: .utf8) ?? "Unknown error" + throw TachikomaError.authenticationFailed( + "OpenAI OAuth refresh failed (HTTP \(httpResponse.statusCode)): \(detail.prefix(500))", + ) + } + guard + let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessToken = payload["access_token"] as? String, + !accessToken.isEmpty, + let accountID = TKOpenAICodexJWT.accountID(from: accessToken) + else { + throw TachikomaError.authenticationFailed("OpenAI OAuth refresh returned an invalid token") + } + + let rotatedRefreshToken = (payload["refresh_token"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? refreshToken + let expiresIn = (payload["expires_in"] as? NSNumber)?.doubleValue + let expiration = TKOpenAICodexJWT.expiration(from: accessToken) + ?? Date().addingTimeInterval(expiresIn ?? 3600) + + if let currentAuth = try self.currentOpenAICodexAuth() { + return currentAuth + } + let latestCredentialState = self.openAICodexCredentialState() + guard + latestCredentialState.generation == credentialState.generation, + latestCredentialState.snapshots == credentialState.snapshots + else { + throw TachikomaError.authenticationFailed("OpenAI OAuth credentials changed while refreshing; retry") + } + + let refreshedSnapshot = TKOpenAICodexCredentialSnapshot( + source: snapshot.source, + accessToken: accessToken, + refreshToken: rotatedRefreshToken, + expiration: expiration, + ) + let didUpdateCredentials = switch snapshot.source { + case .environment: + self.updateOpenAICodexEnvironmentCredentials( + refreshedSnapshot, + source: credentialState.environmentSource, + expectedGeneration: credentialState.generation, + ) + case .store: + try self.updateCredentials([ + "OPENAI_ACCESS_TOKEN": accessToken, + "OPENAI_REFRESH_TOKEN": rotatedRefreshToken, + "OPENAI_ACCESS_EXPIRES": String(Int(expiration.timeIntervalSince1970)), + ], expectedGeneration: credentialState.generation) + } + guard didUpdateCredentials else { + if let currentAuth = try self.currentOpenAICodexAuth() { + return currentAuth + } + throw TachikomaError.authenticationFailed("OpenAI OAuth credentials changed while refreshing; retry") + } + + return TKOpenAICodexAuth(accessToken: accessToken, accountID: accountID) + } + + private func prioritizedOpenAICodexSnapshots( + _ snapshots: [TKOpenAICodexCredentialSnapshot], + ) -> [TKOpenAICodexCredentialSnapshot] { + guard let environment = snapshots.first(where: { $0.source == .environment }) else { + return snapshots + } + guard let stored = snapshots.first(where: { $0.source == .store }) else { + return [environment] + } + + if let environmentAccountID = environment.accountID, + environmentAccountID == stored.accountID + { + return [environment, stored].sorted { lhs, rhs in + let lhsExpiration = lhs.expiration ?? .distantPast + let rhsExpiration = rhs.expiration ?? .distantPast + if lhsExpiration != rhsExpiration { + return lhsExpiration > rhsExpiration + } + return lhs.source == .store && rhs.source != .store + } + } + + if environment.hasValidAccessToken(at: Date()) || environment.hasRefreshToken { + return [environment] + } + return [stored, environment] + } + + private func openAICodexCredentialState() -> ( + snapshots: [TKOpenAICodexCredentialSnapshot], + generation: UInt64, + environmentSource: TKOpenAICodexCredentialSnapshot?) + { + self.lock.lock() + let ignoreEnvironment = self.ignoreEnv + let credentials = self.ignoreStore ? [:] : self.store.load() + let environmentSource = ignoreEnvironment ? nil : self.openAICodexEnvironmentSnapshot() + let environmentSnapshot: TKOpenAICodexCredentialSnapshot? + if let cache = self.openAICodexEnvironmentCredentialCache { + if cache.source == environmentSource { + environmentSnapshot = cache.refreshed + } else { + self.openAICodexEnvironmentCredentialCache = nil + self.credentialGeneration &+= 1 + environmentSnapshot = environmentSource + } + } else { + environmentSnapshot = environmentSource + } + let generation = self.credentialGeneration self.lock.unlock() + + var snapshots: [TKOpenAICodexCredentialSnapshot] = [] + if let environmentSnapshot { + snapshots.append(environmentSnapshot) + } + + let storedAccessToken = credentials["OPENAI_ACCESS_TOKEN"].flatMap { $0.isEmpty ? nil : $0 } + let storedRefreshToken = credentials["OPENAI_REFRESH_TOKEN"].flatMap { $0.isEmpty ? nil : $0 } + let storedExpiration = credentials["OPENAI_ACCESS_EXPIRES"] + .flatMap(TimeInterval.init) + .map(Date.init(timeIntervalSince1970:)) + ?? storedAccessToken.flatMap(TKOpenAICodexJWT.expiration(from:)) + if storedAccessToken != nil || storedRefreshToken != nil { + snapshots.append(TKOpenAICodexCredentialSnapshot( + source: .store, + accessToken: storedAccessToken, + refreshToken: storedRefreshToken, + expiration: storedExpiration, + )) + } + return (snapshots, generation, environmentSource) + } + + private func openAICodexEnvironmentSnapshot() -> TKOpenAICodexCredentialSnapshot? { + let accessToken = self.environmentValue(for: "OPENAI_ACCESS_TOKEN", ignoringEnvironment: false) + let refreshToken = self.environmentValue(for: "OPENAI_REFRESH_TOKEN", ignoringEnvironment: false) + let expiration = self.environmentValue(for: "OPENAI_ACCESS_EXPIRES", ignoringEnvironment: false) + .flatMap(TimeInterval.init) + .map(Date.init(timeIntervalSince1970:)) + ?? accessToken.flatMap(TKOpenAICodexJWT.expiration(from:)) + guard accessToken?.isEmpty == false || refreshToken?.isEmpty == false else { + return nil + } + return TKOpenAICodexCredentialSnapshot( + source: .environment, + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration, + ) + } + + private func updateOpenAICodexEnvironmentCredentials( + _ refreshed: TKOpenAICodexCredentialSnapshot, + source: TKOpenAICodexCredentialSnapshot?, + expectedGeneration: UInt64, + ) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + guard + expectedGeneration == self.credentialGeneration, + !self.ignoreEnv, + let source, + source == self.openAICodexEnvironmentSnapshot() + else { + return false + } + self.openAICodexEnvironmentCredentialCache = TKOpenAICodexEnvironmentCredentialCache( + source: source, + refreshed: refreshed, + ) + self.credentialGeneration &+= 1 + return true } // MARK: Validation @@ -305,7 +726,11 @@ public final class TKAuthManager { clientId: "app_EMoamEEZ73f0CkXaXp7hrann", scope: "openid profile email offline_access", redirect: "http://localhost:1455/auth/callback", - extraAuthorize: [:], + extraAuthorize: [ + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "originator": "peekaboo", + ], extraToken: [:], betaHeader: nil, pkce: pkce, @@ -371,15 +796,15 @@ public final class TKAuthManager { switch result { case let .success(token): do { - try self.setCredential(key: "\(config.prefix)_ACCESS_TOKEN", value: token.access) - try self.setCredential(key: "\(config.prefix)_REFRESH_TOKEN", value: token.refresh) - try self.setCredential( - key: "\(config.prefix)_ACCESS_EXPIRES", - value: String(Int(token.expires.timeIntervalSince1970)), - ) + var credentials = [ + "\(config.prefix)_ACCESS_TOKEN": token.access, + "\(config.prefix)_REFRESH_TOKEN": token.refresh, + "\(config.prefix)_ACCESS_EXPIRES": String(Int(token.expires.timeIntervalSince1970)), + ] if let beta = config.betaHeader { - try self.setCredential(key: "\(config.prefix)_BETA_HEADER", value: beta) + credentials["\(config.prefix)_BETA_HEADER"] = beta } + try self.setCredentials(credentials) return .success(()) } catch { return .failure(.general("Failed to store tokens: \(error)")) diff --git a/Sources/Tachikoma/Providers/OpenAI/OpenAIResponsesProvider.swift b/Sources/Tachikoma/Providers/OpenAI/OpenAIResponsesProvider.swift index 6ca838b..771a001 100644 --- a/Sources/Tachikoma/Providers/OpenAI/OpenAIResponsesProvider.swift +++ b/Sources/Tachikoma/Providers/OpenAI/OpenAIResponsesProvider.swift @@ -5,7 +5,7 @@ import FoundationNetworking /// Provider for OpenAI Responses API (GPT-5) @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) -public final class OpenAIResponsesProvider: ModelProvider { +public final class OpenAIResponsesProvider: ModelProvider, ResponseCacheSafetyProviding { public let modelId: String public let baseURL: String? public let apiKey: String? @@ -14,10 +14,27 @@ public final class OpenAIResponsesProvider: ModelProvider { private let model: LanguageModel.OpenAI private let configuration: TachikomaConfiguration private let session: URLSession - private let auth: TKAuthValue + private let transport: Transport + + private enum Transport: Sendable { + case platform(TKAuthValue) + case codexOAuth + } + + private struct RequestAuthentication: Sendable { + let baseURL: String + let accessToken: String + let accountID: String? + let isCodex: Bool + } + + private static let platformBaseURL = "https://api.openai.com/v1" + private static let codexBaseURL = "https://chatgpt.com/backend-api/codex" private static let debugLogURL = URL(fileURLWithPath: "/tmp/tachikoma-gpt5.log") + var isResponseCacheSafe: Bool { false } + // Provider options (immutable for Sendable conformance) private let reasoningEffort: ReasoningEffort = .medium private let verbosity: TextVerbosity = .high // Set to high for preambles @@ -37,22 +54,28 @@ public final class OpenAIResponsesProvider: ModelProvider { self.modelId = model.modelId self.configuration = configuration self.session = session - self.baseURL = configuration.getBaseURL(for: .openai) ?? "https://api.openai.com/v1" - // Prefer configuration-provided key first (test configs use this), then shared OAuth/API-key auth. + // Prefer API keys when explicitly configured. ChatGPT OAuth uses the Codex backend, + // which is a different transport from the public OpenAI API. if let key = configuration.getAPIKey(for: .openai) { - self.auth = .bearer(key, betaHeader: nil) + self.transport = .platform(.bearer(key, betaHeader: nil)) self.apiKey = key + self.baseURL = configuration.getBaseURL(for: .openai) ?? Self.platformBaseURL } else if let auth = TKAuthManager.shared.resolveAuth(for: .openai) { - self.auth = auth + self.transport = .platform(auth) switch auth { case let .apiKey(key): self.apiKey = key case let .bearer(token, _): self.apiKey = token } + self.baseURL = configuration.getBaseURL(for: .openai) ?? Self.platformBaseURL + } else if TKAuthManager.shared.hasOpenAICodexCredentials() { + self.transport = .codexOAuth + self.apiKey = nil + self.baseURL = Self.codexBaseURL } else { - throw TachikomaError.authenticationFailed("OPENAI_API_KEY not found") + throw TachikomaError.authenticationFailed("OPENAI_API_KEY or OpenAI OAuth login not found") } // Set capabilities based on model @@ -71,14 +94,21 @@ public final class OpenAIResponsesProvider: ModelProvider { } public func generateText(request: ProviderRequest) async throws -> ProviderResponse { + if case .codexOAuth = self.transport { + return try await self.generateCodexText(request: request) + } + // Build Responses API request - let responsesRequest = try buildResponsesRequest(request: request) + let responsesRequest = try self.buildResponsesRequest(request: request) // Create URL for Responses API endpoint - let url = URL(string: "\(baseURL!)/responses")! + let url = URL(string: "\(self.baseURL!)/responses")! var urlRequest = URLRequest(url: url) urlRequest.httpMethod = "POST" - let (authHeaderName, prefix, secret) = self.authHeader() + guard case let .platform(auth) = self.transport else { + throw TachikomaError.authenticationFailed("Invalid OpenAI authentication transport") + } + let (authHeaderName, prefix, secret) = Self.authHeader(for: auth) urlRequest.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName) urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -115,7 +145,7 @@ public final class OpenAIResponsesProvider: ModelProvider { } #else // macOS/iOS: Use async API - let (data, response) = try await session.data(for: urlRequest) + let (data, response) = try await self.session.data(for: urlRequest) #endif guard let httpResponse = response as? HTTPURLResponse else { @@ -140,21 +170,32 @@ public final class OpenAIResponsesProvider: ModelProvider { } public func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream { + let requestAuthentication = try await self.resolveRequestAuthentication() + // Build Responses API request with streaming enabled - let responsesRequest = try buildResponsesRequest(request: request, streaming: true) + let responsesRequest = try self.buildResponsesRequest( + request: request, + streaming: true, + codex: requestAuthentication.isCodex, + ) - // Add streaming flag (though not explicitly in request, handled by SSE) - let url = URL(string: "\(baseURL!)/responses")! + let url = URL(string: "\(requestAuthentication.baseURL)/responses")! let finalURLRequest: URLRequest = { var req = URLRequest(url: url) req.httpMethod = "POST" - let (authHeaderName, prefix, secret) = self.authHeader() - req.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName) + req.setValue( + "Bearer \(requestAuthentication.accessToken)", + forHTTPHeaderField: "Authorization", + ) req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("text/event-stream", forHTTPHeaderField: "Accept") - // Add OpenAI-specific headers - if let orgId = ProcessInfo.processInfo.environment["OPENAI_ORG_ID"] { + if requestAuthentication.isCodex { + req.setValue(requestAuthentication.accountID, forHTTPHeaderField: "ChatGPT-Account-ID") + req.setValue("peekaboo", forHTTPHeaderField: "originator") + req.setValue("responses=experimental", forHTTPHeaderField: "OpenAI-Beta") + req.setValue("peekaboo", forHTTPHeaderField: "User-Agent") + } else if let orgId = ProcessInfo.processInfo.environment["OPENAI_ORG_ID"] { req.setValue(orgId, forHTTPHeaderField: "OpenAI-Organization") } @@ -281,6 +322,58 @@ public final class OpenAIResponsesProvider: ModelProvider { } } + private func generateCodexText(request: ProviderRequest) async throws -> ProviderResponse { + let stream = try await self.streamText(request: request) + var text = "" + var usage: Usage? + var finishReason: FinishReason? + var toolCalls: [AgentToolCall] = [] + + for try await delta in stream { + switch delta.type { + case .textDelta: + text.append(delta.content ?? "") + case .toolCall: + if let toolCall = delta.toolCall { + toolCalls.append(toolCall) + } + case .done: + usage = delta.usage ?? usage + finishReason = delta.finishReason ?? finishReason + case .toolResult, .reasoning: + break + } + } + + return ProviderResponse( + text: text, + usage: usage, + finishReason: finishReason, + toolCalls: toolCalls.isEmpty ? nil : toolCalls, + ) + } + + private func resolveRequestAuthentication() async throws -> RequestAuthentication { + switch self.transport { + case let .platform(auth): + let (_, _, accessToken) = Self.authHeader(for: auth) + return RequestAuthentication( + baseURL: self.baseURL ?? Self.platformBaseURL, + accessToken: accessToken, + accountID: nil, + isCodex: false, + ) + case .codexOAuth: + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth(session: self.session) + return RequestAuthentication( + baseURL: self.baseURL ?? Self.codexBaseURL, + accessToken: auth.accessToken, + accountID: auth.accountID, + isCodex: true, + ) + } + } + private struct ResponsesStreamState { struct PartialToolCall { var id: String @@ -402,13 +495,19 @@ public final class OpenAIResponsesProvider: ModelProvider { let finishReason: FinishReason = state.didReceiveRefusal ? .contentFilter : (state.didYieldToolCall ? .toolCalls : .stop) - continuation.yield(.done(finishReason: finishReason)) + continuation.yield(.done( + usage: Self.usageForResponseStreamEvent(event), + finishReason: finishReason, + )) continuation.finish() return true case "response.incomplete": let finishReason = Self.finishReasonForIncompleteResponseEvent(event) - continuation.yield(.done(finishReason: finishReason)) + continuation.yield(.done( + usage: Self.usageForResponseStreamEvent(event), + finishReason: finishReason, + )) continuation.finish() return true @@ -463,6 +562,18 @@ public final class OpenAIResponsesProvider: ModelProvider { } } + private static func usageForResponseStreamEvent(_ event: [String: Any]) -> Usage? { + guard + let response = event["response"] as? [String: Any], + let usage = response["usage"] as? [String: Any], + let inputTokens = (usage["input_tokens"] as? NSNumber)?.intValue, + let outputTokens = (usage["output_tokens"] as? NSNumber)?.intValue + else { + return nil + } + return Usage(inputTokens: inputTokens, outputTokens: outputTokens) + } + private static func errorMessageForResponseStreamEvent(_ event: [String: Any]) -> String { let eventType = event["type"] as? String ?? "error" let errorPayload = (event["error"] as? [String: Any]) ?? @@ -476,8 +587,8 @@ public final class OpenAIResponsesProvider: ModelProvider { return "OpenAI Responses API stream \(eventType)" } - private func authHeader() -> (String, String, String) { - switch self.auth { + private static func authHeader(for auth: TKAuthValue) -> (String, String, String) { + switch auth { case let .apiKey(key): ("Authorization", "Bearer ", key) case let .bearer(token, _): @@ -490,11 +601,13 @@ public final class OpenAIResponsesProvider: ModelProvider { private func buildResponsesRequest( request: ProviderRequest, streaming: Bool = false, + codex: Bool = false, ) throws -> OpenAIResponsesRequest { // Convert messages to Responses API format - let messages = try sanitizeInputs(convertMessages(request.messages)) + let inputMessages = codex ? request.messages.filter { $0.role != .system } : request.messages + let messages = try self.sanitizeInputs(self.convertMessages(inputMessages)) // Convert tools if present let tools = try request.tools?.compactMap { tool in @@ -508,22 +621,26 @@ public final class OpenAIResponsesProvider: ModelProvider { // Determine reasoning configuration let reasoning: ReasoningConfig? if Self.isReasoningModel(self.model) || Self.isGPT5Model(self.model) { - let effort: OpenAIReasoningEffort - if let optionEffort = openaiOptions?.reasoningEffort { - if Self.isGPT56Model(self.model), optionEffort == .minimal { - throw TachikomaError.invalidConfiguration( - "GPT-5.6 does not support 'minimal' reasoning effort; use 'low' or higher", - ) - } - // Convert from public API to internal type - effort = OpenAIReasoningEffort(rawValue: optionEffort.rawValue) ?? .medium + if codex, openaiOptions?.reasoningEffort == nil { + reasoning = nil } else { - effort = .medium // Default + let effort: OpenAIReasoningEffort + if let optionEffort = openaiOptions?.reasoningEffort { + if Self.isGPT56Model(self.model), optionEffort == .minimal { + throw TachikomaError.invalidConfiguration( + "GPT-5.6 does not support 'minimal' reasoning effort; use 'low' or higher", + ) + } + // Convert from public API to internal type + effort = OpenAIReasoningEffort(rawValue: optionEffort.rawValue) ?? .medium + } else { + effort = .medium // Public API default + } + reasoning = ReasoningConfig( + effort: effort, + summary: .auto, + ) } - reasoning = ReasoningConfig( - effort: effort, - summary: .auto, - ) } else { reasoning = nil } @@ -535,33 +652,34 @@ public final class OpenAIResponsesProvider: ModelProvider { // Convert from public API to internal type TextVerbosity(rawValue: optionVerbosity.rawValue) ?? .high } else { - .high // Default for preambles + codex ? .low : .high } textConfig = TextConfig(verbosity: verbosity) } else { textConfig = nil } + let hasTools = tools?.isEmpty == false let responsesRequest = OpenAIResponsesRequest( - model: modelId, + model: self.modelId, input: messages, temperature: validatedSettings.temperature, topP: validatedSettings.topP, - maxOutputTokens: validatedSettings.maxTokens, + maxOutputTokens: codex ? nil : validatedSettings.maxTokens, text: textConfig, tools: tools, - toolChoice: nil, // TODO: Add tool choice support + toolChoice: codex && hasTools ? "auto" : nil, metadata: nil, - parallelToolCalls: openaiOptions?.parallelToolCalls ?? true, + parallelToolCalls: codex && !hasTools ? nil : (openaiOptions?.parallelToolCalls ?? true), previousResponseId: openaiOptions?.previousResponseId ?? self.previousResponseId, store: false, user: nil, - instructions: nil, + instructions: codex ? self.codexInstructions(from: request.messages) : nil, serviceTier: nil, - include: nil, + include: codex ? ["reasoning.encrypted_content"] : nil, reasoning: reasoning, - truncation: Self.isReasoningModel(self.model) ? "auto" : nil, - stream: streaming, + truncation: !codex && Self.isReasoningModel(self.model) ? "auto" : nil, + stream: streaming || codex, ) if @@ -575,6 +693,18 @@ public final class OpenAIResponsesProvider: ModelProvider { return responsesRequest } + private func codexInstructions(from messages: [ModelMessage]) -> String { + let instructions = messages + .filter { $0.role == .system } + .flatMap(\.content) + .compactMap { part -> String? in + guard case let .text(text) = part, !text.isEmpty else { return nil } + return text + } + .joined(separator: "\n\n") + return instructions.isEmpty ? "You are a helpful assistant." : instructions + } + private func convertMessages(_ messages: [ModelMessage]) throws -> [ResponsesInputItem] { var inputs: [ResponsesInputItem] = [] diff --git a/Tests/TachikomaTests/Auth/AuthManagerTests.swift b/Tests/TachikomaTests/Auth/AuthManagerTests.swift index 09a7f55..aea69bf 100644 --- a/Tests/TachikomaTests/Auth/AuthManagerTests.swift +++ b/Tests/TachikomaTests/Auth/AuthManagerTests.swift @@ -40,6 +40,8 @@ struct AuthManagerTests { unsetenv("GROK_API_KEY") unsetenv("OPENAI_API_KEY") unsetenv("OPENAI_ACCESS_TOKEN") + unsetenv("OPENAI_REFRESH_TOKEN") + unsetenv("OPENAI_ACCESS_EXPIRES") unsetenv("ANTHROPIC_API_KEY") unsetenv("ANTHROPIC_ACCESS_TOKEN") unsetenv("GEMINI_API_KEY") @@ -103,6 +105,467 @@ struct AuthManagerTests { } } + @Test + func `OpenAI Codex JWT exposes account and expiration claims`() { + let expiration = Date().addingTimeInterval(3600) + let token = Self.openAIJWT(accountID: "account-123", expiration: expiration) + + #expect(TKOpenAICodexJWT.accountID(from: token) == "account-123") + let parsedExpiration = TKOpenAICodexJWT.expiration(from: token) + #expect(parsedExpiration != nil) + #expect(abs((parsedExpiration?.timeIntervalSince1970 ?? 0) - expiration.timeIntervalSince1970) < 1) + } + + @Test + func `OpenAI Codex OAuth prefers a valid environment account`() async throws { + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let environmentToken = Self.openAIJWT( + accountID: "environment-account", + expiration: Date().addingTimeInterval(3600), + ) + let storedToken = Self.openAIJWT( + accountID: "stored-account", + expiration: Date().addingTimeInterval(7200), + ) + setenv("OPENAI_ACCESS_TOKEN", environmentToken, 1) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": storedToken, + "OPENAI_REFRESH_TOKEN": "stored-refresh-token", + ]) + + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth() + + #expect(auth == TKOpenAICodexAuth( + accessToken: environmentToken, + accountID: "environment-account", + )) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth refresh rotates and persists credentials`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(-3600), + ) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": expiredToken, + "OPENAI_REFRESH_TOKEN": "old-refresh-token", + "OPENAI_ACCESS_EXPIRES": String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)), + ]) + + let refreshedToken = Self.openAIJWT( + accountID: "account-456", + expiration: Date().addingTimeInterval(7200), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": refreshedToken, + "refresh_token": "rotated-refresh-token", + ]) + + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth( + timeout: 5, + session: session, + ) + + #expect(auth == TKOpenAICodexAuth(accessToken: refreshedToken, accountID: "account-456")) + let request = try #require(OAuthMockURLProtocol.lastRequest) + #expect(request.url?.absoluteString == "https://auth.openai.com/oauth/token") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["client_id"] == "app_EMoamEEZ73f0CkXaXp7hrann") + #expect(json["grant_type"] == "refresh_token") + #expect(json["refresh_token"] == "old-refresh-token") + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_TOKEN") == refreshedToken) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_REFRESH_TOKEN") == "rotated-refresh-token") + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth caches refreshed environment credentials without persisting`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "account-env", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", expiredToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "environment-refresh-token", 1) + setenv( + "OPENAI_ACCESS_EXPIRES", + String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)), + 1, + ) + + let refreshedToken = Self.openAIJWT( + accountID: "account-env", + expiration: Date().addingTimeInterval(7200), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": refreshedToken, + "refresh_token": "rotated-refresh-token", + ]) + + let first = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + let second = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(first.accessToken == refreshedToken) + #expect(second.accessToken == refreshedToken) + #expect(OAuthMockURLProtocol.requestCount == 1) + #expect(TKCredentialStore().load().isEmpty) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth continues an environment refresh chain in memory`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredEnvironmentToken = Self.openAIJWT( + accountID: "same-account", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", expiredEnvironmentToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "original-refresh-token", 1) + + let shortLivedToken = Self.openAIJWT( + accountID: "same-account", + expiration: Date().addingTimeInterval(30), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": shortLivedToken, + "refresh_token": "rotated-refresh-token", + ]) + _ = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + let finalToken = Self.openAIJWT( + accountID: "same-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": finalToken, + "refresh_token": "second-rotated-refresh-token", + ]) + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(auth.accessToken == finalToken) + #expect(OAuthMockURLProtocol.requestCount == 2) + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["refresh_token"] == "rotated-refresh-token") + #expect(TKCredentialStore().load().isEmpty) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth environment changes invalidate the in-memory refresh chain`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let firstExpiredToken = Self.openAIJWT( + accountID: "first-account", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", firstExpiredToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "first-refresh-token", 1) + + let firstRefreshedToken = Self.openAIJWT( + accountID: "first-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": firstRefreshedToken, + "refresh_token": "first-rotated-refresh-token", + ]) + let firstAuth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + let secondExpiredToken = Self.openAIJWT( + accountID: "second-account", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", secondExpiredToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "second-refresh-token", 1) + let secondRefreshedToken = Self.openAIJWT( + accountID: "second-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": secondRefreshedToken, + "refresh_token": "second-rotated-refresh-token", + ]) + let secondAuth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(firstAuth.accountID == "first-account") + #expect(secondAuth.accountID == "second-account") + #expect(OAuthMockURLProtocol.requestCount == 2) + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["refresh_token"] == "second-refresh-token") + #expect(TKCredentialStore().load().isEmpty) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth ignore-environment changes invalidate the in-memory refresh chain`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "ignored-environment-account", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", expiredToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "ignored-environment-refresh-token", 1) + + let firstRefreshedToken = Self.openAIJWT( + accountID: "ignored-environment-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": firstRefreshedToken, + "refresh_token": "rotated-refresh-token", + ]) + _ = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + TKAuthManager.shared.setIgnoreEnvironment(true) + TKAuthManager.shared.setIgnoreEnvironment(false) + let secondRefreshedToken = Self.openAIJWT( + accountID: "ignored-environment-account", + expiration: Date().addingTimeInterval(7200), + ) + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": secondRefreshedToken, + "refresh_token": "second-rotated-refresh-token", + ]) + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(auth.accessToken == secondRefreshedToken) + #expect(OAuthMockURLProtocol.requestCount == 2) + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["refresh_token"] == "ignored-environment-refresh-token") + #expect(TKCredentialStore().load().isEmpty) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth refreshes an explicit environment account over another stored account`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredEnvironmentToken = Self.openAIJWT( + accountID: "environment-account", + expiration: Date().addingTimeInterval(-3600), + ) + setenv("OPENAI_ACCESS_TOKEN", expiredEnvironmentToken, 1) + setenv("OPENAI_REFRESH_TOKEN", "environment-refresh-token", 1) + + let storedToken = Self.openAIJWT( + accountID: "stored-account", + expiration: Date().addingTimeInterval(3600), + ) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": storedToken, + "OPENAI_REFRESH_TOKEN": "stored-refresh-token", + ]) + + let refreshedEnvironmentToken = Self.openAIJWT( + accountID: "environment-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": refreshedEnvironmentToken, + "refresh_token": "rotated-environment-refresh-token", + ]) + + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(auth.accountID == "environment-account") + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["refresh_token"] == "environment-refresh-token") + let storedCredentials = TKCredentialStore().load() + #expect(storedCredentials["OPENAI_ACCESS_TOKEN"] == storedToken) + #expect(storedCredentials["OPENAI_REFRESH_TOKEN"] == "stored-refresh-token") + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth refresh ignores an unrefreshable environment token`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + setenv("OPENAI_ACCESS_TOKEN", "malformed-environment-token", 1) + let storedExpiredToken = Self.openAIJWT( + accountID: "stored-account", + expiration: Date().addingTimeInterval(-3600), + ) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": storedExpiredToken, + "OPENAI_REFRESH_TOKEN": "stored-refresh-token", + ]) + + let refreshedToken = Self.openAIJWT( + accountID: "refreshed-account", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": refreshedToken, + "refresh_token": "rotated-refresh-token", + ]) + + let auth = try await TKAuthManager.shared.resolveOpenAICodexAuth(timeout: 5, session: session) + + #expect(auth.accountID == "refreshed-account") + let body = try #require(OAuthMockURLProtocol.lastBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: String]) + #expect(json["refresh_token"] == "stored-refresh-token") + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth refresh HTTP failure preserves credentials`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(-3600), + ) + let expiredAt = String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": expiredToken, + "OPENAI_REFRESH_TOKEN": "original-refresh-token", + "OPENAI_ACCESS_EXPIRES": expiredAt, + ]) + + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.statusCode = 401 + OAuthMockURLProtocol.responseData = Data(#"{"error":"invalid_grant"}"#.utf8) + + await #expect(throws: TachikomaError.self) { + _ = try await TKAuthManager.shared.resolveOpenAICodexAuth( + timeout: 5, + session: session, + ) + } + + #expect(OAuthMockURLProtocol.requestCount == 1) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_TOKEN") == expiredToken) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_REFRESH_TOKEN") == "original-refresh-token") + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_EXPIRES") == expiredAt) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth invalid refresh payload preserves credentials`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(-3600), + ) + let expiredAt = String(Int(Date().addingTimeInterval(-3600).timeIntervalSince1970)) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": expiredToken, + "OPENAI_REFRESH_TOKEN": "original-refresh-token", + "OPENAI_ACCESS_EXPIRES": expiredAt, + ]) + + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseData = Data(#"{"refresh_token":"partial-rotation"}"#.utf8) + + await #expect(throws: TachikomaError.self) { + _ = try await TKAuthManager.shared.resolveOpenAICodexAuth( + timeout: 5, + session: session, + ) + } + + #expect(OAuthMockURLProtocol.requestCount == 1) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_TOKEN") == expiredToken) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_REFRESH_TOKEN") == "original-refresh-token") + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_EXPIRES") == expiredAt) + } + } + + @Test + @MainActor + func `OpenAI Codex OAuth concurrent callers share one refresh`() async throws { + let session = URLSession.oauthMock() + try await self.withIsolatedAuthState { + self.resetAuthEnv() + let expiredToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(-3600), + ) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": expiredToken, + "OPENAI_REFRESH_TOKEN": "original-refresh-token", + ]) + + let refreshedToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(3600), + ) + OAuthMockURLProtocol.reset() + OAuthMockURLProtocol.responseDelayNanoseconds = 50_000_000 + OAuthMockURLProtocol.responseData = try JSONSerialization.data(withJSONObject: [ + "access_token": refreshedToken, + "refresh_token": "rotated-refresh-token", + ]) + + let results = try await withThrowingTaskGroup(of: TKOpenAICodexAuth.self) { group in + for _ in 0..<8 { + group.addTask { + try await TKAuthManager.shared.resolveOpenAICodexAuth( + timeout: 5, + session: session, + ) + } + } + + var authValues: [TKOpenAICodexAuth] = [] + for try await auth in group { + authValues.append(auth) + } + return authValues + } + + #expect(results.count == 8) + #expect(results.allSatisfy { + $0 == TKOpenAICodexAuth(accessToken: refreshedToken, accountID: "account-123") + }) + #expect(OAuthMockURLProtocol.requestCount == 1) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_ACCESS_TOKEN") == refreshedToken) + #expect(TKAuthManager.shared.credentialValue(for: "OPENAI_REFRESH_TOKEN") == "rotated-refresh-token") + } + } + @Test @MainActor func `validate success mock`() async throws { @@ -220,6 +683,23 @@ struct AuthManagerTests { #expect(json["redirect_uri"] as? String == "https://example.com/callback") #expect(json["code_verifier"] as? String == config.pkce.verifier) } + + private static func openAIJWT(accountID: String, expiration: Date) -> String { + let header = ["alg": "none", "typ": "JWT"] + let payload: [String: Any] = [ + "exp": Int(expiration.timeIntervalSince1970), + "https://api.openai.com/auth": ["chatgpt_account_id": accountID], + ] + return "\(self.base64URLJSON(header)).\(self.base64URLJSON(payload)).signature" + } + + private static func base64URLJSON(_ value: Any) -> String { + let data = try! JSONSerialization.data(withJSONObject: value) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } } // MARK: - URLSession mocking @@ -272,10 +752,26 @@ extension URLSession { private final class OAuthMockURLProtocol: URLProtocol { nonisolated(unsafe) static var lastRequest: URLRequest? nonisolated(unsafe) static var lastBody: Data? - - static func reset() { + nonisolated(unsafe) static var requestCount = 0 + nonisolated(unsafe) static var statusCode = 200 + nonisolated(unsafe) static var responseDelayNanoseconds: UInt64 = 0 + nonisolated(unsafe) static var responseData = try! JSONSerialization.data(withJSONObject: [ + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 3600, + ]) + + nonisolated static func reset() { self.lastRequest = nil self.lastBody = nil + self.requestCount = 0 + self.statusCode = 200 + self.responseDelayNanoseconds = 0 + self.responseData = try! JSONSerialization.data(withJSONObject: [ + "access_token": "access", + "refresh_token": "refresh", + "expires_in": 3600, + ]) } override class func canInit(with _: URLRequest) -> Bool { @@ -287,6 +783,7 @@ private final class OAuthMockURLProtocol: URLProtocol { } override func startLoading() { + OAuthMockURLProtocol.requestCount += 1 OAuthMockURLProtocol.lastRequest = self.request if let body = self.request.httpBody { OAuthMockURLProtocol.lastBody = body @@ -304,20 +801,20 @@ private final class OAuthMockURLProtocol: URLProtocol { } OAuthMockURLProtocol.lastBody = data } - let body: [String: Any] = [ - "access_token": "access", - "refresh_token": "refresh", - "expires_in": 3600, - ] - let data = try! JSONSerialization.data(withJSONObject: body) + let statusCode = Self.statusCode + let responseData = Self.responseData + let responseDelayNanoseconds = Self.responseDelayNanoseconds + if responseDelayNanoseconds > 0 { + Thread.sleep(forTimeInterval: TimeInterval(responseDelayNanoseconds) / 1_000_000_000) + } let response = HTTPURLResponse( url: self.request.url!, - statusCode: 200, + statusCode: statusCode, httpVersion: nil, headerFields: ["Content-Type": "application/json"], )! self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocol(self, didLoad: responseData) self.client?.urlProtocolDidFinishLoading(self) } diff --git a/Tests/TachikomaTests/Providers/Integration/ProviderIntegrationTests.swift b/Tests/TachikomaTests/Providers/Integration/ProviderIntegrationTests.swift index 218baed..f7b087f 100644 --- a/Tests/TachikomaTests/Providers/Integration/ProviderIntegrationTests.swift +++ b/Tests/TachikomaTests/Providers/Integration/ProviderIntegrationTests.swift @@ -430,6 +430,42 @@ struct ProviderIntegrationTests { #expect(normalized.contains("red")) } + @Test + func `OpenAI Codex OAuth - GPT-5_6 Sol Vision Support`() async throws { + let previousProfileDirectory = TachikomaConfiguration.profileDirectoryName + if let profileDirectory = ProcessInfo.processInfo.environment["TACHIKOMA_INTEGRATION_PROFILE_DIR"] { + TachikomaConfiguration.profileDirectoryName = profileDirectory + } + defer { + TachikomaConfiguration.profileDirectoryName = previousProfileDirectory + } + + guard TKAuthManager.shared.hasOpenAICodexCredentials() else { + throw TestSkipped("OpenAI Codex OAuth credentials are not configured") + } + guard TKAuthManager.shared.resolveAuth(for: .openai) == nil else { + throw TestSkipped("A configured OpenAI API key would take precedence over Codex OAuth") + } + + let config = TachikomaConfiguration(loadFromEnvironment: false) + let provider = try OpenAIResponsesProvider(model: .gpt56Sol, configuration: config) + let redPixelPNG = + "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAF0lEQVR4nGP4z8BAEiJN9aiGUQ1DSgMAkPn/Afnh+ngAAAAASUVORK5CYII=" + let request = ProviderRequest( + messages: [ + ModelMessage.user( + text: "Reply with the single lowercase word red if this image is red.", + images: [.init(data: redPixelPNG, mimeType: "image/png")], + ), + ], + settings: .init(maxTokens: 32), + ) + + let response = try await provider.generateText(request: request) + + #expect(response.text.lowercased().contains("red")) + } + // MARK: - Helper Methods private static func warn(_ message: String) { diff --git a/Tests/TachikomaTests/Providers/OpenAIResponsesProviderTests.swift b/Tests/TachikomaTests/Providers/OpenAIResponsesProviderTests.swift index c2a3da9..69654ec 100644 --- a/Tests/TachikomaTests/Providers/OpenAIResponsesProviderTests.swift +++ b/Tests/TachikomaTests/Providers/OpenAIResponsesProviderTests.swift @@ -515,19 +515,86 @@ struct OpenAIResponsesProviderTests { } } - @Test - func `Responses provider resolves OAuth access token`() async throws { + @Test(arguments: [ + (LanguageModel.OpenAI.gpt55, "gpt-5.5"), + (.gpt56Sol, "gpt-5.6-sol"), + (.gpt56Terra, "gpt-5.6-terra"), + (.gpt56Luna, "gpt-5.6-luna"), + ]) + func `Codex OAuth provider sends image input through ChatGPT Responses transport`( + model: LanguageModel.OpenAI, + expectedModelID: String, + ) async throws { try await self.withIsolatedAuthState { - try TKAuthManager.shared.setCredential(key: "OPENAI_ACCESS_TOKEN", value: "oauth-access-token") + let accessToken = Self.openAIJWT( + accountID: "account-123", + expiration: Date().addingTimeInterval(3600), + ) + try TKAuthManager.shared.setCredentials([ + "OPENAI_ACCESS_TOKEN": accessToken, + "OPENAI_REFRESH_TOKEN": "oauth-refresh-token", + "OPENAI_ACCESS_EXPIRES": String(Int(Date().addingTimeInterval(3600).timeIntervalSince1970)), + ]) let config = TachikomaConfiguration(loadFromEnvironment: false) try await self.withMockedSession { request in - #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-access-token") - return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "oauth ok")) + #expect(request.url?.host == "chatgpt.com") + #expect(request.url?.path == "/backend-api/codex/responses") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(accessToken)") + #expect(request.value(forHTTPHeaderField: "ChatGPT-Account-ID") == "account-123") + #expect(request.value(forHTTPHeaderField: "originator") == "peekaboo") + #expect(request.value(forHTTPHeaderField: "OpenAI-Beta") == "responses=experimental") + #expect(request.value(forHTTPHeaderField: "Accept") == "text/event-stream") + + let body = try #require(Self.bodyData(from: request)) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(json["model"] as? String == expectedModelID) + #expect(json["stream"] as? Bool == true) + #expect(json["store"] as? Bool == false) + #expect(json["instructions"] as? String == "You are a helpful assistant.") + #expect(json["include"] as? [String] == ["reasoning.encrypted_content"]) + #expect(json["reasoning"] == nil) + #expect((json["text"] as? [String: String])?["verbosity"] == "low") + #expect(json["truncation"] == nil) + #expect(json["max_output_tokens"] == nil) + + let input = try #require(json["input"] as? [[String: Any]]) + let content = try #require(input.first?["content"] as? [[String: Any]]) + let image = try #require(content.first { $0["type"] as? String == "input_image" }) + #expect(image["image_url"] as? String == "data:image/png;base64,BASE64DATA") + + let payload = Self.responsesStreamPayload(chunks: [ + Self.streamChunkJSON(content: "vision through oauth", finishReason: nil), + Self.streamEventJSON([ + "type": "response.completed", + "response": [ + "usage": [ + "input_tokens": 12, + "output_tokens": 4, + ], + ], + ]), + ]) + return NetworkMocking.streamResponse(for: request, data: payload) } operation: { session in - let provider = try OpenAIResponsesProvider(model: .gpt5Mini, configuration: config, session: session) - let response = try await provider.generateText(request: self.sampleRequest) - #expect(response.text.contains("oauth ok")) + let provider = try OpenAIResponsesProvider(model: model, configuration: config, session: session) + #expect(provider.isResponseCacheSafe == false) + let request = ProviderRequest( + messages: [ + ModelMessage.user( + text: "What do you see?", + images: [ModelMessage.ContentPart.ImageContent( + data: "BASE64DATA", + mimeType: "image/png", + )], + ), + ], + settings: .init(maxTokens: 32), + ) + let response = try await provider.generateText(request: request) + #expect(response.text == "vision through oauth") + #expect(response.finishReason == .stop) + #expect(response.usage == Usage(inputTokens: 12, outputTokens: 4)) } } } @@ -1066,6 +1133,23 @@ struct OpenAIResponsesProviderTests { return String(data: data, encoding: .utf8)! } + private static func openAIJWT(accountID: String, expiration: Date) -> String { + let header = ["alg": "none", "typ": "JWT"] + let payload: [String: Any] = [ + "exp": Int(expiration.timeIntervalSince1970), + "https://api.openai.com/auth": ["chatgpt_account_id": accountID], + ] + return "\(self.base64URLJSON(header)).\(self.base64URLJSON(payload)).signature" + } + + private static func base64URLJSON(_ value: Any) -> String { + let data = try! JSONSerialization.data(withJSONObject: value) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + private func withMockedSession( handler: @Sendable @escaping (URLRequest) throws -> (HTTPURLResponse, Data), operation: (URLSession) async throws -> T, @@ -1104,17 +1188,27 @@ struct OpenAIResponsesProviderTests { let profilePath = NSString(string: "~/" + profileDirectory).expandingTildeInPath let previousIgnoreEnvironment = TKAuthManager.shared.setIgnoreEnvironment(false) let previousIgnoreCredentialStore = TKAuthManager.shared.setIgnoreCredentialStore(false) - let savedOpenAIKey = getenv("OPENAI_API_KEY").map { String(cString: $0) } + let environmentKeys = [ + "OPENAI_API_KEY", + "OPENAI_ACCESS_TOKEN", + "OPENAI_REFRESH_TOKEN", + "OPENAI_ACCESS_EXPIRES", + ] + let savedEnvironment = Dictionary(uniqueKeysWithValues: environmentKeys.map { key in + (key, getenv(key).map { String(cString: $0) }) + }) TachikomaConfiguration.profileDirectoryName = profileDirectory - unsetenv("OPENAI_API_KEY") + environmentKeys.forEach { unsetenv($0) } try? FileManager.default.removeItem(atPath: profilePath) defer { - if let savedOpenAIKey { - setenv("OPENAI_API_KEY", savedOpenAIKey, 1) - } else { - unsetenv("OPENAI_API_KEY") + for key in environmentKeys { + if case let value?? = savedEnvironment[key] { + setenv(key, value, 1) + } else { + unsetenv(key) + } } TKAuthManager.shared.setIgnoreEnvironment(previousIgnoreEnvironment) TKAuthManager.shared.setIgnoreCredentialStore(previousIgnoreCredentialStore)