Files
steipete--codexbar/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift
T
2026-07-13 12:22:33 +08:00

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