Skip to content

Add multi-account token support for Codex and API-key providers#461

Closed
Minoo7 wants to merge 14 commits intosteipete:mainfrom
Minoo7:feature/multi-account-api-providers
Closed

Add multi-account token support for Codex and API-key providers#461
Minoo7 wants to merge 14 commits intosteipete:mainfrom
Minoo7:feature/multi-account-api-providers

Conversation

@Minoo7
Copy link
Copy Markdown

@Minoo7 Minoo7 commented Mar 2, 2026

Summary

  • extend token-account support catalog for Codex plus API-key providers (Copilot, Kimi K2, Synthetic, Warp, OpenRouter)
  • enable Codex token-account settings visibility when cookie source is manual so users can add accounts before any token exists
  • add Codex manual-cookie actions in Preferences: Test cookie (validates immediately) and Save as account (appends token accounts)
  • surface Codex cookie import/test status directly in the Codex settings pane subtitle
  • keep token-account injection consistent so selected accounts override configured API keys in app and CLI environments
  • add regression tests for Codex catalog support, Codex manual cookie actions, >2 account appends, Codex CLI snapshot cookie-source forcing, Codex settings visibility, and API-provider precedence

Verification

  • swift test --filter ProviderSettingsDescriptorTests/codexManualCookieFieldExposesTestAndSaveActions
  • swift test --filter ProviderSettingsDescriptorTests/codexSaveCookieActionAppendsAccountsBeyondTwo
  • swift test --filter TokenAccountEnvironmentPrecedenceTests
  • swift test --filter ProviderSettingsDescriptorTests/codex
  • swiftformat Sources Tests && swiftlint --strict
  • pnpm check
  • ./Scripts/compile_and_run.sh

@Minoo7 Minoo7 changed the title Add token-account support for API-key providers Add multi-account token support for Codex and API-key providers Mar 2, 2026
if self.settings.codexCookieSource == .manual,
!self.settings.tokenAccounts(for: .codex).isEmpty
{
return nil
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this targetEmail becoming nil in manual token-account mode interact with the account-switch path in a way that keeps stale dashboard state after switching accounts?

!previous.isEmpty,
previous != normalized
{
let emailChanged = if let previous, !previous.isEmpty, let normalizedOrNil {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should transitions like previous email -> nil or previous tokenAccountID nil -> non-nil be treated as account changes here, or is there a reason we want to keep existing dashboard state in that first switch case?

private static let headerPatterns: [String] = [
#"(?i)-H\s*'Cookie:\s*([^']+)'"#,
#"(?i)-H\s*\"Cookie:\s*([^\"]+)\""#,
#"(?i)(?:^|\s)-H\s+'Cookie:\s*([^']+)'"#,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we intentionally requiring whitespace after -H now, or should compact forms like -H'Cookie: ...' still be accepted?

#"(?i)(?:--cookie|-b)\s*'([^']+)'"#,
#"(?i)(?:--cookie|-b)\s*\"([^\"]+)\""#,
#"(?i)(?:--cookie|-b)\s*([^\s]+)"#,
#"(?i)(?:^|\s)(?:--cookie|-b)\s+'([^']+)'"#,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For --cookie / -b, do we still want to accept compact input like -b'...', or should it only match when there is whitespace?

Copilot AI review requested due to automatic review settings March 22, 2026 22:17
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends CodexBar’s “token accounts” feature to support multi-account workflows for Codex (cookie/manual/OAuth) and several API-key providers, while improving UI/CLI flows for adding, selecting, and validating accounts.

Changes:

  • Add token-account support metadata for Codex plus additional API-key providers (Copilot, Kimi K2, Synthetic, Warp, OpenRouter) and ensure token-account selection overrides configured API keys in app/CLI environments.
  • Expand Codex manual-cookie and OAuth handling: settings visibility when cookie source is manual, actions to test/save cookies, and improved cookie import status surfacing.
  • Add a new CLI subcommand to append token accounts to the config, plus substantial regression test coverage.

Reviewed changes

Copilot reviewed 44 out of 45 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift Adds coverage for env precedence across more providers and Codex catalog/CLI snapshot behaviors.
Tests/CodexBarTests/StatusMenuTests.swift Adds tests for token-account switching behavior and snapshot preference rules in the status menu.
Tests/CodexBarTests/ProviderVersionDetectorTests.swift Adds tests for version detector output parsing and timeout behavior.
Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift Tests Codex manual-cookie actions (test/save) and token-account visibility rules.
Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift Tests Codex token-account changes don’t force OpenAI dashboard target email and clear stale dashboard state.
Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift Adds coverage for manual-header session-cookie detection and distinct error messaging.
Tests/CodexBarTests/CookieHeaderNormalizerTests.swift Tests normalization/pair parsing edge cases for cookie headers.
Tests/CodexBarTests/CodexOAuthTests.swift Adds tests for loading OAuth creds from JSON/path and resolving JWT claims.
Tests/CodexBarTests/CodexbarTests.swift Adds tests for Codex OAuth account directory creation and OAuth source-mode preference.
Tests/CodexBarTests/CLIWebFallbackTests.swift Tests Codex web import input selection and app auto-strategy ordering with/without manual cookies.
Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift Extends token-account support catalog for Codex + additional API-key providers.
Sources/CodexBarCore/Providers/ProviderVersionDetector.swift Adds test hook and ensures process exit is awaited before reading output.
Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift Adds Codex oauthCredentialSource to snapshots.
Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift Introduces manual-vs-browser cookie import input and direct manual-cookie usage fetch path.
Sources/CodexBarCore/Providers/Codex/CodexProviderDescriptor.swift Adjusts Codex fetch pipeline ordering based on manual cookie source; adds shared response mapper.
Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift Adds manual header parsing for cookie/bearer extraction and usage fetch support.
Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift Adds load/save support for raw JSON or alternate auth file locations.
Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthClaimResolver.swift Adds centralized JWT claim resolution for account/email/plan.
Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift Adds manual cookie validation and captures cookieHeader; improves import candidate selection and Chromium support.
Sources/CodexBarCore/CookieHeaderNormalizer.swift Tightens parsing patterns to avoid accidental flag matches within cookie values.
Sources/CodexBarCLI/TokenAccountCLI.swift Adds Codex OAuth detection and snapshot shaping for CLI token-account selection.
Sources/CodexBarCLI/CLIOptions.swift Adds CLI options for config add-account.
Sources/CodexBarCLI/CLIHelp.swift Updates CLI help text to include the new config subcommand.
Sources/CodexBarCLI/CLIEntry.swift Wires up the new config add-account command.
Sources/CodexBarCLI/CLIConfigCommand.swift Implements config token-account append logic and structured output.
Sources/CodexBar/UsageStore+TokenAccounts.swift Adds supplementary per-account data (credits/dashboard), prioritization, and Codex-specific repair flow.
Sources/CodexBar/UsageStore+Refresh.swift Routes refresh into token-account refresh path whenever accounts exist.
Sources/CodexBar/UsageStore.swift Improves dashboard state invalidation on token-account changes and adds cookie test helper.
Sources/CodexBar/StatusItemController+Menu.swift Improves menu snapshot selection for token accounts and primes token-account snapshots when needed.
Sources/CodexBar/StatusItemController+Actions.swift Updates Codex login error messaging.
Sources/CodexBar/SettingsStore+TokenAccounts.swift Adds updateTokenAccount helper for updating stored token/label.
Sources/CodexBar/SettingsStore.swift Adjusts defaults initialization behavior and keychain gate wiring.
Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift Adds optional “add account via login” hook to token-account descriptors.
Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift Adds OAuth credential source inference into Codex settings snapshots.
Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift Adds Codex token-account visibility rules and manual-cookie actions (test/save).
Sources/CodexBar/Providers/Codex/CodexLoginFlow.swift Updates login flow to report phases while running.
Sources/CodexBar/PreferencesProvidersPane+Testing.swift Updates test harness for new descriptor field.
Sources/CodexBar/PreferencesProvidersPane.swift Adds “Add with browser” login-based account creation for Codex.
Sources/CodexBar/PreferencesProviderSettingsRows.swift Adds UI for login-based account creation and disables UI during in-flight login.
Sources/CodexBar/MenuHighlightStyle.swift Refactors menu highlight environment key implementation.
Sources/CodexBar/CodexOAuthAccountStore.swift Adds filesystem helpers for per-account OAuth profile directories.
Sources/CodexBar/CodexLoginRunner.swift Replaces CLI-based login with native OAuth via AppAuth and local redirect handler.
Scripts/package_app.sh Adjusts code signing args for dev identity without hardened runtime.
Package.swift Adds AppAuth dependency to the macOS app target.
Package.resolved Pins AppAuth-iOS dependency.
Comments suppressed due to low confidence (1)

Sources/CodexBar/UsageStore+Refresh.swift:44

  • refreshProvider now refreshes token accounts whenever any exist, ignoring showAllTokenAccountsInMenu / shouldFetchAllTokenAccounts. This means periodic refreshes can fetch multiple accounts (up to the limit) even when the UI is configured to show only the switcher bar, which can add unnecessary network/keychain/process work and slow refreshes. Consider restoring the previous gating (only fetch all when showAllTokenAccountsInMenu is enabled) and otherwise fetch just the selected account, or make the behavior conditional on whether the menu is open / provider is visible.
        let tokenAccounts = self.tokenAccounts(for: provider)
        if !tokenAccounts.isEmpty {
            await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts)
            return
        } else {
            _ = await MainActor.run {
                self.accountSnapshots.removeValue(forKey: provider)
            }
        }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}
group.addTask {
let nanos = UInt64(max(0, timeout) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout task uses try? await Task.sleep(...) and then unconditionally calls coordinator.cancelForTimeout(). If the task gets cancelled (e.g., because the login task completed first and group.cancelAll() runs), Task.sleep throws CancellationError which is swallowed by try?, so this task will still run cancelForTimeout() and may cancel the HTTP listener / finish the coordinator after a successful completion. Handle cancellation explicitly (e.g., do/try/catch around Task.sleep and return early on cancellation) so cancelForTimeout() only runs when the sleep actually completes without cancellation.

Suggested change
try? await Task.sleep(nanoseconds: nanos)
do {
try await Task.sleep(nanoseconds: nanos)
} catch is CancellationError {
// The timeout task was cancelled (e.g., because login completed first).
// Return a benign result without touching the coordinator.
return Result(outcome: .timedOut, output: "Timeout task cancelled.")
} catch {
// Any other unexpected error from Task.sleep; also avoid coordinator side-effects.
return Result(outcome: .failed(status: 1), output: "Timeout task failed: \(error)")
}

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +81
public static func load(rawSource: String) throws -> CodexOAuthCredentials {
let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CodexOAuthCredentialsError.notFound
}

if let authURL = self.authFileURL(forRawSource: trimmed) {
let data = try Data(contentsOf: authURL)
return try self.parse(data: data)
}

return try self.parse(data: Data(trimmed.utf8))
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authFileURL(forRawSource:) returns nil unless the path already exists. As a result, load(rawSource:) will attempt to parse a non-existent file path string as JSON and throw decodeFailed("Invalid JSON") instead of a more accurate .notFound. This also prevents saving refreshed credentials back to a user-supplied file path that doesn't exist yet. Consider detecting JSON vs path by shape (e.g., starts with “{” / contains newlines), otherwise treat the value as a path (tilde-expanded) and throw .notFound if it can’t be read; and allow save(..., rawSource:) to create the file if needed.

Suggested change
public static func load(rawSource: String) throws -> CodexOAuthCredentials {
let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CodexOAuthCredentialsError.notFound
}
if let authURL = self.authFileURL(forRawSource: trimmed) {
let data = try Data(contentsOf: authURL)
return try self.parse(data: data)
}
return try self.parse(data: Data(trimmed.utf8))
/// Heuristically determine whether the given raw string is likely JSON content
/// rather than a file path.
private static func looksLikeJSON(_ raw: String) -> Bool {
// Ignore leading whitespace when checking the first significant character.
let trimmedLeading = raw.drop { $0.isWhitespace }
guard let first = trimmedLeading.first else {
return false
}
if first == "{" || first == "[" || first == "\"" {
return true
}
// Multiline or structured content is more likely to be JSON than a file path.
if raw.contains("\n") || raw.contains("\r") {
return true
}
return false
}
public static func load(rawSource: String) throws -> CodexOAuthCredentials {
let trimmed = rawSource.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
throw CodexOAuthCredentialsError.notFound
}
// If the input looks like JSON, parse it directly as JSON.
if self.looksLikeJSON(trimmed) {
return try self.parse(data: Data(trimmed.utf8))
}
// Otherwise, treat it as a path: first try existing authFileURL logic.
if let authURL = self.authFileURL(forRawSource: trimmed) {
let data = try Data(contentsOf: authURL)
return try self.parse(data: data)
}
// Fall back to interpreting the raw source as a plain file path (tilde-expanded).
let expandedPath = (trimmed as NSString).expandingTildeInPath
let url = URL(fileURLWithPath: expandedPath)
guard FileManager.default.fileExists(atPath: url.path) else {
throw CodexOAuthCredentialsError.notFound
}
let data = try Data(contentsOf: url)
return try self.parse(data: data)

Copilot uses AI. Check for mistakes.

private static func decodeSingleProvider(from values: ParsedValues) -> UsageProvider? {
guard let raw = values.options["provider"]?.last else { return nil }
guard case let .single(provider) = ProviderSelection(argument: raw) else { return nil }
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProviderSelection(argument:) is a failable initializer, so it returns ProviderSelection?. The pattern match case let .single(provider) = ProviderSelection(argument: raw) won’t compile because it’s matching against an optional. Unwrap first (e.g., guard let selection = ProviderSelection(argument: raw), case let .single(provider) = selection else { ... }).

Suggested change
guard case let .single(provider) = ProviderSelection(argument: raw) else { return nil }
guard let selection = ProviderSelection(argument: raw),
case let .single(provider) = selection else { return nil }

Copilot uses AI. Check for mistakes.
@ratulsarna
Copy link
Copy Markdown
Collaborator

Created and merged #613 to add this support. Thanks @Minoo7 !
Could you please split this PR for the additional work done for providers other than Codex?

@ratulsarna ratulsarna closed this Mar 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants