import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct CodexOpenAIWorkspaceIdentity: Equatable, Sendable { public let workspaceAccountID: String public let workspaceLabel: String? public init(workspaceAccountID: String, workspaceLabel: String?) { self.workspaceAccountID = Self.normalizeWorkspaceAccountID(workspaceAccountID) self.workspaceLabel = Self.normalizeWorkspaceLabel(workspaceLabel) } public static func normalizeWorkspaceAccountID(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } public static func normalizeWorkspaceLabel(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed } } public enum CodexOpenAIWorkspaceResolver { private struct AccountsResponse: Decodable { let items: [AccountItem] } private struct AccountItem: Decodable { let id: String let name: String? } private static let accountsURL = URL(string: "https://chatgpt.com/backend-api/accounts")! public static func resolve(credentials: CodexOAuthCredentials) async throws -> CodexOpenAIWorkspaceIdentity? { try await self.resolve(credentials: credentials, session: CodexAuthenticatedHTTPTransport.current) } public static func resolve( credentials: CodexOAuthCredentials, session transport: any ProviderHTTPTransport) async throws -> CodexOpenAIWorkspaceIdentity? { guard let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) else { return nil } let identities = try await self.listWorkspaces(credentials: credentials, session: transport) if let identity = identities.first(where: { $0.workspaceAccountID == workspaceAccountID }) { return identity } return CodexOpenAIWorkspaceIdentity( workspaceAccountID: workspaceAccountID, workspaceLabel: nil) } public static func listWorkspaces( credentials: CodexOAuthCredentials) async throws -> [CodexOpenAIWorkspaceIdentity] { try await self.listWorkspaces(credentials: credentials, session: CodexAuthenticatedHTTPTransport.current) } public static func listWorkspaces( credentials: CodexOAuthCredentials, session transport: any ProviderHTTPTransport) async throws -> [CodexOpenAIWorkspaceIdentity] { var request = URLRequest( url: self.accountsURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20) request.httpMethod = "GET" request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") if let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) { request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") } let response = try await transport.response(for: request) guard (200..<300).contains(response.statusCode) else { throw CodexOpenAIWorkspaceResolverError.invalidResponse } let decoded = try JSONDecoder().decode(AccountsResponse.self, from: response.data) return decoded.items.compactMap { account in guard let workspaceAccountID = self.normalizeWorkspaceAccountID(account.id) else { return nil } return CodexOpenAIWorkspaceIdentity( workspaceAccountID: workspaceAccountID, workspaceLabel: self.resolveWorkspaceLabel(from: account)) } } public static func normalizeWorkspaceAccountID(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed.lowercased() } private static func resolveWorkspaceLabel(from account: AccountItem) -> String? { let name = account.name?.trimmingCharacters(in: .whitespacesAndNewlines) if let name, !name.isEmpty { return name } return "Personal" } } public enum CodexOpenAIWorkspaceResolverError: LocalizedError, Sendable { case invalidResponse public var errorDescription: String? { switch self { case .invalidResponse: "OpenAI account lookup returned an invalid response." } } }