124 lines
4.6 KiB
Swift
124 lines
4.6 KiB
Swift
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."
|
|
}
|
|
}
|
|
}
|