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." } }}
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."
}
}
}