From 1f88336dcf5a2ea485e7d3af4b9ee575a88c4c10 Mon Sep 17 00:00:00 2001 From: leezhuuuuu Date: Sun, 26 Apr 2026 22:49:20 +0800 Subject: [PATCH 001/314] Fix Codex workspace account matching Treat provider-backed Codex account identities as email plus workspace account ID instead of assuming the workspace ID is globally unique. Different OpenAI users can belong to the same team workspace, so matching only on providerAccountID caused add-account flows to replace an existing email with the newly authenticated email. Prompt for a workspace when the login exposes multiple OpenAI workspaces, persist the selected workspace ID into the managed auth file, and keep workspace labels cached for display. Reconciliation, visible account projection, and promotion no-op checks now use email-aware provider identity matching so live and managed rows for different emails in the same workspace do not collapse. Add regression coverage for selecting and persisting workspaces, preserving same-workspace accounts with different emails, and avoiding live/managed row merging when provider account IDs match but emails differ. --- .../CodexAccountPromotionPlanning.swift | 29 +++- .../CodexAccountPromotionService.swift | 11 +- .../CodexBar/CodexAccountReconciliation.swift | 6 +- .../CodexBar/ManagedCodexAccountService.swift | 140 +++++++++++++++++- .../CodexBar/PreferencesProvidersPane.swift | 2 + .../StatusItemController+Actions.swift | 2 + .../CodexBarCore/CodexManagedAccounts.swift | 10 +- .../Codex/CodexAccountReconciliation.swift | 26 +++- .../Codex/CodexOpenAIWorkspaceResolver.swift | 27 +++- ...tProviderIdentityReconciliationTests.swift | 48 ++++++ .../ManagedCodexAccountServiceTests.swift | 131 +++++++++++++++- .../ManagedCodexAccountStoreTests.swift | 30 ++++ 12 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift diff --git a/Sources/CodexBar/CodexAccountPromotionPlanning.swift b/Sources/CodexBar/CodexAccountPromotionPlanning.swift index ed5427485..8fd94ea83 100644 --- a/Sources/CodexBar/CodexAccountPromotionPlanning.swift +++ b/Sources/CodexBar/CodexAccountPromotionPlanning.swift @@ -58,7 +58,11 @@ struct CodexDisplacedLivePreservationPlanner { } if let targetAuthIdentity = context.target.authIdentity, - CodexIdentityMatcher.matches(targetAuthIdentity.identity, liveAuthIdentity.identity) + CodexIdentityMatcher.matches( + targetAuthIdentity.identity, + lhsEmail: targetAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) { return .none(reason: .targetMatchesLiveAuthIdentity) } @@ -96,7 +100,11 @@ struct CodexDisplacedLivePreservationPlanner { { candidates.first { candidate in guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } @@ -108,8 +116,12 @@ struct CodexDisplacedLivePreservationPlanner { switch liveAuthIdentity.identity { case let .providerAccount(id): let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) - if let destination = candidates.first(where: { $0.persisted.providerAccountID == providerAccountID }), - let reason = self.providerRepairReason(for: destination) + if let destination = candidates.first(where: { + guard $0.persisted.providerAccountID == providerAccountID else { return false } + guard let liveEmail = liveAuthIdentity.email else { return true } + return $0.persisted.email == liveEmail + }), + let reason = self.providerRepairReason(for: destination) { return (destination, reason) } @@ -149,9 +161,16 @@ struct CodexDisplacedLivePreservationPlanner { let providerAccountID = ManagedCodexAccount.normalizeProviderAccountID(id) return candidates.contains { candidate in guard candidate.persisted.providerAccountID == providerAccountID else { return false } + if let liveEmail = liveAuthIdentity.email, candidate.persisted.email != liveEmail { + return false + } guard case .readable = candidate.homeState else { return false } guard let candidateAuthIdentity = candidate.authIdentity else { return false } - return !CodexIdentityMatcher.matches(candidateAuthIdentity.identity, liveAuthIdentity.identity) + return !CodexIdentityMatcher.matches( + candidateAuthIdentity.identity, + lhsEmail: candidateAuthIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) } } diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index c8a4abb73..c8bf58969 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -259,7 +259,12 @@ final class CodexAccountPromotionService { private func convergedActiveSource(for context: PreparedPromotionContext) -> CodexActiveSource? { if let liveAuthIdentity = context.live.authIdentity { let targetIdentity = context.target.authIdentity ?? context.target.persistedIdentity - guard CodexIdentityMatcher.matches(targetIdentity.identity, liveAuthIdentity.identity) else { + guard CodexIdentityMatcher.matches( + targetIdentity.identity, + lhsEmail: targetIdentity.email, + liveAuthIdentity.identity, + rhsEmail: liveAuthIdentity.email) + else { return nil } @@ -280,7 +285,9 @@ final class CodexAccountPromotionService { guard CodexIdentityMatcher.matches( context.snapshot.runtimeIdentity(for: context.target.persisted), - context.snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: context.snapshot.runtimeEmail(for: context.target.persisted), + context.snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) else { return nil } diff --git a/Sources/CodexBar/CodexAccountReconciliation.swift b/Sources/CodexBar/CodexAccountReconciliation.swift index 782ec12ac..69d59b3a5 100644 --- a/Sources/CodexBar/CodexAccountReconciliation.swift +++ b/Sources/CodexBar/CodexAccountReconciliation.swift @@ -102,7 +102,11 @@ extension CodexVisibleAccountProjection { let normalizedEmail = Self.normalizeVisibleEmail(liveSystemAccount.email) let liveIdentity = snapshot.runtimeIdentity(for: liveSystemAccount) if let existingIndex = drafts.firstIndex(where: { draft in - CodexIdentityMatcher.matches(draft.identity, liveIdentity) + CodexIdentityMatcher.matches( + draft.identity, + lhsEmail: draft.email, + liveIdentity, + rhsEmail: normalizedEmail) }) { let existingDraft = drafts[existingIndex] let liveWorkspaceLabel = Self.normalizeWorkspaceLabel(liveSystemAccount.workspaceLabel) diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index b28bd58e2..11b0c2b78 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -1,3 +1,4 @@ +import AppKit import CodexBarCore import Foundation @@ -16,11 +17,27 @@ protocol ManagedCodexIdentityReading: Sendable { protocol ManagedCodexWorkspaceResolving: Sendable { func resolveWorkspaceIdentity(homePath: String, providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] +} + +extension ManagedCodexWorkspaceResolving { + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + [] + } +} + +protocol ManagedCodexWorkspaceSelecting: Sendable { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? } enum ManagedCodexAccountServiceError: Error, Equatable { case loginFailed case missingEmail + case workspaceSelectionCancelled case unsafeManagedHome(String) } @@ -102,6 +119,65 @@ struct DefaultManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { workspaceAccountID: normalizedProviderAccountID, workspaceLabel: cachedLabel) } + + func availableWorkspaceIdentities(homePath: String) async -> [CodexOpenAIWorkspaceIdentity] { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + guard let credentials = try? CodexOAuthCredentialsStore.load(env: env), + let identities = try? await CodexOpenAIWorkspaceResolver.listWorkspaces(credentials: credentials) + else { + return [] + } + + for identity in identities { + try? self.workspaceCache.store(identity) + } + return identities + } +} + +struct CodexWorkspaceAlertSelector: ManagedCodexWorkspaceSelecting { + @MainActor + func selectWorkspace( + email: String, + currentWorkspaceID: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + guard workspaces.count > 1 else { return workspaces.first } + + let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 26), pullsDown: false) + let sortedWorkspaces = workspaces.sorted { lhs, rhs in + self.workspaceTitle(lhs) < self.workspaceTitle(rhs) + } + for workspace in sortedWorkspaces { + popup.addItem(withTitle: self.workspaceTitle(workspace)) + popup.lastItem?.representedObject = workspace.workspaceAccountID + } + if let currentWorkspaceID, + let selectedIndex = sortedWorkspaces.firstIndex(where: { $0.workspaceAccountID == currentWorkspaceID }) + { + popup.selectItem(at: selectedIndex) + } + + let alert = NSAlert() + alert.messageText = "Choose Codex workspace" + alert.informativeText = "CodexBar found multiple workspaces for \(email). Choose the one to add." + alert.alertStyle = .informational + alert.accessoryView = popup + alert.addButton(withTitle: "Add Workspace") + alert.addButton(withTitle: "Cancel") + + guard alert.runModal() == .alertFirstButtonReturn else { + return nil + } + let selectedWorkspaceID = popup.selectedItem?.representedObject as? String + return sortedWorkspaces.first { $0.workspaceAccountID == selectedWorkspaceID } + } + + private func workspaceTitle(_ workspace: CodexOpenAIWorkspaceIdentity) -> String { + workspace.workspaceLabel ?? workspace.workspaceAccountID + } } @MainActor @@ -111,6 +187,7 @@ final class ManagedCodexAccountService { private let loginRunner: any ManagedCodexLoginRunning private let identityReader: any ManagedCodexIdentityReading private let workspaceResolver: any ManagedCodexWorkspaceResolving + private let workspaceSelector: any ManagedCodexWorkspaceSelecting private let fileManager: FileManager init( @@ -119,6 +196,7 @@ final class ManagedCodexAccountService { loginRunner: any ManagedCodexLoginRunning, identityReader: any ManagedCodexIdentityReading, workspaceResolver: any ManagedCodexWorkspaceResolving = DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: any ManagedCodexWorkspaceSelecting = CodexWorkspaceAlertSelector(), fileManager: FileManager = .default) { self.store = store @@ -126,6 +204,7 @@ final class ManagedCodexAccountService { self.loginRunner = loginRunner self.identityReader = identityReader self.workspaceResolver = workspaceResolver + self.workspaceSelector = workspaceSelector self.fileManager = fileManager } @@ -136,6 +215,7 @@ final class ManagedCodexAccountService { loginRunner: DefaultManagedCodexLoginRunner(), identityReader: DefaultManagedCodexIdentityReader(), workspaceResolver: DefaultManagedCodexWorkspaceResolver(), + workspaceSelector: CodexWorkspaceAlertSelector(), fileManager: fileManager) } @@ -160,18 +240,23 @@ final class ManagedCodexAccountService { else { throw ManagedCodexAccountServiceError.missingEmail } - let providerAccountID: String? = switch identity.identity { + let authenticatedProviderAccountID: String? = switch identity.identity { case let .providerAccount(id): ManagedCodexAccount.normalizeProviderAccountID(id) case .emailOnly, .unresolved: nil } - let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let providerAccountID { - await self.workspaceResolver.resolveWorkspaceIdentity( + let selectedWorkspace = try await self.selectedWorkspaceIdentity( + email: rawEmail, + homePath: homeURL.path, + authenticatedProviderAccountID: authenticatedProviderAccountID) + let providerAccountID = selectedWorkspace?.workspaceAccountID ?? authenticatedProviderAccountID + let workspaceIdentity: CodexOpenAIWorkspaceIdentity? = if let selectedWorkspace { + selectedWorkspace + } else { + await self.resolvedWorkspaceIdentity( homePath: homeURL.path, providerAccountID: providerAccountID) - } else { - nil } let now = Date().timeIntervalSince1970 @@ -245,6 +330,51 @@ final class ManagedCodexAccountService { } } + private func selectedWorkspaceIdentity( + email: String, + homePath: String, + authenticatedProviderAccountID: String?) async throws -> CodexOpenAIWorkspaceIdentity? + { + let workspaces = await self.workspaceResolver.availableWorkspaceIdentities(homePath: homePath) + guard workspaces.count > 1 else { + return workspaces.first { $0.workspaceAccountID == authenticatedProviderAccountID } + } + guard let selected = await self.workspaceSelector.selectWorkspace( + email: email, + currentWorkspaceID: authenticatedProviderAccountID, + workspaces: workspaces) + else { + throw ManagedCodexAccountServiceError.workspaceSelectionCancelled + } + try self.persistSelectedWorkspaceID(selected.workspaceAccountID, homePath: homePath) + return selected + } + + private func resolvedWorkspaceIdentity( + homePath: String, + providerAccountID: String?) async -> CodexOpenAIWorkspaceIdentity? + { + guard let providerAccountID else { return nil } + return await self.workspaceResolver.resolveWorkspaceIdentity( + homePath: homePath, + providerAccountID: providerAccountID) + } + + private func persistSelectedWorkspaceID(_ workspaceID: String, homePath: String) throws { + let env = CodexHomeScope.scopedEnvironment( + base: ProcessInfo.processInfo.environment, + codexHome: homePath) + let credentials = try CodexOAuthCredentialsStore.load(env: env) + try CodexOAuthCredentialsStore.save( + CodexOAuthCredentials( + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + idToken: credentials.idToken, + accountId: workspaceID, + lastRefresh: credentials.lastRefresh), + env: env) + } + private func reconciledExistingAccount( authenticatedEmail: String, providerAccountID: String?, diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index a5738086c..19e50a924 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -611,6 +611,8 @@ struct ProvidersPane: View { case .missingEmail: "Codex login completed, but no account email was available. Try again after confirming " + "the account is fully signed in." + case .workspaceSelectionCancelled: + "CodexBar found multiple workspaces, but no workspace was selected." case let .unsafeManagedHome(path): "CodexBar refused to modify an unexpected managed home path: \(path)" } diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index e9fcf6f66..44fa57950 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -292,6 +292,8 @@ extension StatusItemController { case .missingEmail: "Codex login completed, but no account email was available. " + "Try again after confirming the account is fully signed in." + case .workspaceSelectionCancelled: + "CodexBar found multiple workspaces, but no workspace was selected." case let .unsafeManagedHome(path): "CodexBar refused to modify an unexpected managed home path: \(path)" } diff --git a/Sources/CodexBarCore/CodexManagedAccounts.swift b/Sources/CodexBarCore/CodexManagedAccounts.swift index cdd104a43..2833544d5 100644 --- a/Sources/CodexBarCore/CodexManagedAccounts.swift +++ b/Sources/CodexBarCore/CodexManagedAccounts.swift @@ -86,7 +86,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { public func account(email: String, providerAccountID: String? = nil) -> ManagedCodexAccount? { let normalizedEmail = ManagedCodexAccount.normalizeEmail(email) if let normalizedProviderAccountID = ManagedCodexAccount.normalizeProviderAccountID(providerAccountID), - let exactMatch = self.accounts.first(where: { $0.providerAccountID == normalizedProviderAccountID }) + let exactMatch = self.accounts.first(where: { + $0.email == normalizedEmail && $0.providerAccountID == normalizedProviderAccountID + }) { return exactMatch } @@ -104,7 +106,7 @@ public struct ManagedCodexAccountSet: Codable, Sendable { private static func sanitizedAccounts(_ accounts: [ManagedCodexAccount]) -> [ManagedCodexAccount] { var seenIDs: Set = [] - var seenProviderAccountIDs: Set = [] + var seenProviderAccountKeys: Set = [] var seenLegacyEmails: Set = [] var sanitized: [ManagedCodexAccount] = [] sanitized.reserveCapacity(accounts.count) @@ -112,7 +114,9 @@ public struct ManagedCodexAccountSet: Codable, Sendable { for account in accounts { guard seenIDs.insert(account.id).inserted else { continue } if let providerAccountID = account.providerAccountID { - guard seenProviderAccountIDs.insert(providerAccountID).inserted else { continue } + guard seenProviderAccountKeys.insert("\(account.email)\u{0}\(providerAccountID)").inserted else { + continue + } } else { guard seenLegacyEmails.insert(account.email).inserted else { continue } } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift index c8cb60795..532dbf520 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexAccountReconciliation.swift @@ -44,7 +44,9 @@ public enum CodexActiveSourceResolver { guard let liveSystemAccount else { return false } return CodexIdentityMatcher.matches( snapshot.runtimeIdentity(for: storedAccount), - snapshot.runtimeIdentity(for: liveSystemAccount)) + lhsEmail: snapshot.runtimeEmail(for: storedAccount), + snapshot.runtimeIdentity(for: liveSystemAccount), + rhsEmail: liveSystemAccount.email) } } @@ -155,7 +157,11 @@ public struct DefaultCodexAccountReconciler { let matchingStoredAccountForLiveSystemAccount = liveSystemAccount.flatMap { liveAccount in accounts.accounts.first { account in guard let runtimeAccount = runtimeAccounts[account.id] else { return false } - return CodexIdentityMatcher.matches(runtimeAccount.identity, self.runtimeIdentity(for: liveAccount)) + return CodexIdentityMatcher.matches( + runtimeAccount.identity, + lhsEmail: runtimeAccount.email, + self.runtimeIdentity(for: liveAccount), + rhsEmail: liveAccount.email) } } @@ -234,6 +240,22 @@ public enum CodexIdentityMatcher { } } + public static func matches( + _ lhs: CodexIdentity, + lhsEmail: String?, + _ rhs: CodexIdentity, + rhsEmail: String?) -> Bool + { + guard self.matches(lhs, rhs) else { return false } + guard case .providerAccount = lhs, case .providerAccount = rhs else { return true } + guard let normalizedLeftEmail = CodexIdentityResolver.normalizeEmail(lhsEmail), + let normalizedRightEmail = CodexIdentityResolver.normalizeEmail(rhsEmail) + else { + return true + } + return normalizedLeftEmail == normalizedRightEmail + } + public static func normalized(_ identity: CodexIdentity, fallbackEmail: String) -> CodexIdentity { switch identity { case .providerAccount: diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift index 6887b4ce9..fabf412a3 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift @@ -44,13 +44,29 @@ public enum CodexOpenAIWorkspaceResolver { return nil } + let identities = try await self.listWorkspaces(credentials: credentials, session: session) + if let identity = identities.first(where: { $0.workspaceAccountID == workspaceAccountID }) { + return identity + } + + return CodexOpenAIWorkspaceIdentity( + workspaceAccountID: workspaceAccountID, + workspaceLabel: nil) + } + + public static func listWorkspaces( + credentials: CodexOAuthCredentials, + session: URLSession = .shared) async throws -> [CodexOpenAIWorkspaceIdentity] + { var request = URLRequest(url: self.accountsURL) request.httpMethod = "GET" request.timeoutInterval = 20 request.setValue("Bearer \(credentials.accessToken)", forHTTPHeaderField: "Authorization") request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + if let workspaceAccountID = normalizeWorkspaceAccountID(credentials.accountId) { + request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") + } let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse, @@ -60,17 +76,12 @@ public enum CodexOpenAIWorkspaceResolver { } let decoded = try JSONDecoder().decode(AccountsResponse.self, from: data) - if let account = decoded.items.first(where: { - Self.normalizeWorkspaceAccountID($0.id) == workspaceAccountID - }) { + 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)) } - - return CodexOpenAIWorkspaceIdentity( - workspaceAccountID: workspaceAccountID, - workspaceLabel: nil) } public static func normalizeWorkspaceAccountID(_ value: String?) -> String? { diff --git a/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift new file mode 100644 index 000000000..79517f800 --- /dev/null +++ b/Tests/CodexBarTests/CodexAccountProviderIdentityReconciliationTests.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct CodexAccountProviderIdentityReconciliationTests { + @Test + func `same provider account id with different email does not merge live and managed rows`() { + let stored = ManagedCodexAccount( + id: UUID(), + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: "/tmp/managed-a", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let live = ObservedSystemCodexAccount( + email: "mich.aelfmk5542@gmail.com", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "team-4107")) + let snapshot = CodexAccountReconciliationSnapshot( + storedAccounts: [stored], + activeStoredAccount: stored, + liveSystemAccount: live, + matchingStoredAccountForLiveSystemAccount: nil, + activeSource: .managedAccount(id: stored.id), + hasUnreadableAddedAccountStore: false, + storedAccountRuntimeIdentities: [stored.id: .providerAccount(id: "team-4107")], + storedAccountRuntimeEmails: [stored.id: "mi.chaelfmk5542@gmail.com"]) + + let resolution = CodexActiveSourceResolver.resolve(from: snapshot) + let projection = CodexVisibleAccountProjection.make(from: snapshot) + + #expect(resolution.resolvedSource == .managedAccount(id: stored.id)) + #expect(projection.visibleAccounts.count == 2) + #expect(projection.visibleAccounts.map(\.email).sorted() == [ + "mi.chaelfmk5542@gmail.com", + "mich.aelfmk5542@gmail.com", + ]) + #expect(projection.activeVisibleAccountID == "mi.chaelfmk5542@gmail.com") + #expect(projection.liveVisibleAccountID == "mich.aelfmk5542@gmail.com") + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 48c0063af..834c76693 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -113,6 +113,101 @@ struct ManagedCodexAccountServiceTests { #expect(FileManager.default.fileExists(atPath: storedTeam.managedHomePath)) } + @Test + func `same workspace provider id with different emails does not overwrite existing account`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingID = try #require(UUID(uuidString: "10101010-2020-3030-4040-505050505050")) + let existingAccount = ManagedCodexAccount( + id: existingID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "team-4107"), email: "mich.aelfmk5542@gmail.com", plan: "Team"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "team-4107": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "team-4107", + workspaceLabel: "4107"), + ])) + + let added = try await service.authenticateManagedAccount() + + let original = try #require( + store.snapshot.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")) + let newAccount = try #require( + store.snapshot.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")) + #expect(store.snapshot.accounts.count == 2) + #expect(original.id == existingID) + #expect(original.managedHomePath == existingHome.path) + #expect(newAccount.id == added.id) + #expect(newAccount.id != existingID) + #expect(newAccount.workspaceLabel == "4107") + #expect(FileManager.default.fileExists(atPath: existingHome.path)) + #expect(FileManager.default.fileExists(atPath: newAccount.managedHomePath)) + } + + @Test + func `selected workspace is persisted and used as account identity`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + let workspaces = [ + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-personal", + workspaceLabel: "Personal"), + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ] + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: WritingManagedCodexLoginRunner( + credentials: CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "workspace-personal", + lastRefresh: nil)), + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-personal"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver( + identities: Dictionary(uniqueKeysWithValues: workspaces.map { ($0.workspaceAccountID, $0) }), + availableIdentities: workspaces), + workspaceSelector: StubManagedCodexWorkspaceSelector(selectedWorkspaceID: "workspace-team")) + + let account = try await service.authenticateManagedAccount() + let credentials = try CodexOAuthCredentialsStore.load(env: ["CODEX_HOME": account.managedHomePath]) + + #expect(account.providerAccountID == "workspace-team") + #expect(account.workspaceLabel == "Team") + #expect(credentials.accountId == "workspace-team") + #expect(store.snapshot.accounts.count == 1) + } + @Test func `reauth keeps previous home when store write fails`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -750,6 +845,19 @@ private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning { result: CodexLoginRunner.Result(outcome: .success, output: "ok")) } +private struct WritingManagedCodexLoginRunner: ManagedCodexLoginRunning { + let credentials: CodexOAuthCredentials + + func run(homePath: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + do { + try CodexOAuthCredentialsStore.save(self.credentials, env: ["CODEX_HOME": homePath]) + return CodexLoginRunner.Result(outcome: .success, output: "ok") + } catch { + return CodexLoginRunner.Result(outcome: .failed(status: 1), output: String(describing: error)) + } + } +} + private enum TestManagedCodexAccountStoreError: Error, Equatable { case writeFailed } @@ -787,9 +895,14 @@ private final class StubManagedCodexIdentityReader: ManagedCodexIdentityReading, private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { let identities: [String: CodexOpenAIWorkspaceIdentity] + let availableIdentities: [CodexOpenAIWorkspaceIdentity] - init(identities: [String: CodexOpenAIWorkspaceIdentity] = [:]) { + init( + identities: [String: CodexOpenAIWorkspaceIdentity] = [:], + availableIdentities: [CodexOpenAIWorkspaceIdentity] = []) + { self.identities = identities + self.availableIdentities = availableIdentities } func resolveWorkspaceIdentity( @@ -798,4 +911,20 @@ private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { self.identities[providerAccountID] } + + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + self.availableIdentities + } +} + +private struct StubManagedCodexWorkspaceSelector: ManagedCodexWorkspaceSelecting { + let selectedWorkspaceID: String? + + func selectWorkspace( + email _: String, + currentWorkspaceID _: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + workspaces.first { $0.workspaceAccountID == self.selectedWorkspaceID } + } } diff --git a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift index b07720ff0..274761cf5 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift @@ -204,6 +204,36 @@ func `FileManagedCodexAccountStore keeps same email rows when hydrated provider #expect(loaded.account(email: "user@example.com", providerAccountID: "account-beta")?.id == secondID) } +@Test +func `managed account set keeps same provider account I D when emails differ`() { + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-1", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let second = ManagedCodexAccount( + id: secondID, + email: "mich.aelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-2", + createdAt: 30, + updatedAt: 40, + lastAuthenticatedAt: nil) + + let set = ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [first, second]) + + #expect(set.accounts.count == 2) + #expect(set.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == firstID) + #expect(set.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == secondID) +} + @Test func `FileManagedCodexAccountStore hydrates provider account I D from id token when account field is absent`() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) From 166f1cd18e170a6aa065b06e020a7a3d28777a09 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 01:03:34 +0530 Subject: [PATCH 002/314] Update changelog for Codex workspace account matching --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee814c1d2..1a05f9921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.24 — Unreleased +### Fixes +- Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! + ## 0.23 — 2026-04-26 ### Highlights From 4839ca3003c825ef482c2c6ecabe4eda7350f3da Mon Sep 17 00:00:00 2001 From: hello-amed <79072382+hello-amed@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:54:12 +1000 Subject: [PATCH 003/314] feat: add claudePeakHoursEnabled setting and integrate into UsageMenu (#611) * feat: add claudePeakHoursEnabled setting and integrate into UsageMenuCardView and ClaudeProviderImplementation - Introduced `claudePeakHoursEnabled` property in SettingsStore and SettingsStoreState. - Updated UsageMenuCardView to utilize the new setting for displaying peak hours status. - Added toggle for peak hours visibility in ClaudeProviderImplementation. - Ensured persistence of the setting in user defaults. * Resolved PR feedback: - Updated `claudePeakHoursEnabled` to default to true in the UsageMenuCardView model. - Added tests to verify peak hours note visibility when the setting is enabled and disabled. * Update ProvidersPane to include claudePeakHoursEnabled in UsageMenuCardView model * Refine subtitle for peak hours indicator in ClaudeProviderImplementation * Refactor peak hours calculation in ClaudePeakHours to improve accuracy and readability. Introduced a new method for determining the next peak start time and updated related tests for consistency. * refactor: update MenuCardModelTests to use CodexConsumerProjection for credits handling * fix: adjust date handling in ClaudePeakHours and enhance tests for second precision * Fix Claude peak hours lint --------- Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuCardView.swift | 8 + .../CodexBar/PreferencesProvidersPane.swift | 1 + .../Claude/ClaudeProviderImplementation.swift | 16 ++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 1 + .../Providers/Claude/ClaudePeakHours.swift | 83 +++++++++ .../CodexBarTests/ClaudePeakHoursTests.swift | 159 ++++++++++++++++++ .../MenuCardOptionalUsageModelTests.swift | 140 +++++++++++++++ 10 files changed, 419 insertions(+) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift create mode 100644 Tests/CodexBarTests/ClaudePeakHoursTests.swift create mode 100644 Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index c97aed64b..297dd71bd 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -670,6 +670,7 @@ extension UsageMenuCardView.Model { let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool + let claudePeakHoursEnabled: Bool let weeklyPace: UsagePace? let now: Date @@ -694,6 +695,7 @@ extension UsageMenuCardView.Model { sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, + claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, now: Date) { @@ -717,6 +719,7 @@ extension UsageMenuCardView.Model { self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo + self.claudePeakHoursEnabled = claudePeakHoursEnabled self.weeklyPace = weeklyPace self.now = now } @@ -788,6 +791,11 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .claude, input.claudePeakHoursEnabled { + let peakStatus = ClaudePeakHours.status(at: input.now) + return [peakStatus.label] + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 19e50a924..8cf0ff608 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -579,6 +579,7 @@ struct ProvidersPane: View { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index cc85f8fa0..da82c1734 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -26,6 +26,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudePeakHoursEnabled } @MainActor @@ -77,6 +78,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let peakHoursBinding = Binding( + get: { context.settings.claudePeakHoursEnabled }, + set: { context.settings.claudePeakHoursEnabled = $0 }) + return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", @@ -89,6 +94,17 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-peak-hours", + title: "Show peak hours indicator", + subtitle: "Show whether Claude is in peak usage hours.", + binding: peakHoursBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index ab32e5797..c4705bcc6 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -257,6 +257,14 @@ extension SettingsStore { } } + var claudePeakHoursEnabled: Bool { + get { self.defaultsState.claudePeakHoursEnabled } + set { + self.defaultsState.claudePeakHoursEnabled = newValue + self.userDefaults.set(newValue, forKey: "claudePeakHoursEnabled") + } + } + var showOptionalCreditsAndExtraUsage: Bool { get { self.defaultsState.showOptionalCreditsAndExtraUsage } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index b006f6d0e..6d3e76e4f 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -263,6 +263,7 @@ extension SettingsStore { let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false + let claudePeakHoursEnabled = userDefaults.object(forKey: "claudePeakHoursEnabled") as? Bool ?? true let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } @@ -308,6 +309,7 @@ extension SettingsStore { claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, + claudePeakHoursEnabled: claudePeakHoursEnabled, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index a65fb45d5..c28de8f63 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -26,6 +26,7 @@ struct SettingsDefaultsState { var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool + var claudePeakHoursEnabled: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 48a295e5e..f3c9247c3 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1437,6 +1437,7 @@ extension StatusItemController { sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift new file mode 100644 index 000000000..166c331ad --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift @@ -0,0 +1,83 @@ +import Foundation + +public enum ClaudePeakHours: Sendable { + private static let peakTimeZone = TimeZone(identifier: "America/New_York")! + private static let peakStartHour = 8 + private static let peakEndHour = 14 + + public struct Status: Sendable, Equatable { + public let isPeak: Bool + public let label: String + } + + public static func status(at date: Date) -> Status { + let calendar = self.calendar() + let date = calendar.dateInterval(of: .minute, for: date)?.start ?? date + let components = calendar.dateComponents([.hour, .minute, .weekday], from: date) + + guard let hour = components.hour, + let minute = components.minute, + let weekday = components.weekday + else { + return Status(isPeak: false, label: "Off-peak") + } + + let isWeekday = weekday >= 2 && weekday <= 6 + let nowMinutes = hour * 60 + minute + let peakStartMinutes = self.peakStartHour * 60 + let peakEndMinutes = self.peakEndHour * 60 + let isInPeakWindow = nowMinutes >= peakStartMinutes && nowMinutes < peakEndMinutes + + if isWeekday, isInPeakWindow { + let remaining = peakEndMinutes - nowMinutes + return Status( + isPeak: true, + label: "Peak · ends in \(self.formatDuration(minutes: remaining))") + } + + let nextPeak = self.nextPeakStart(after: date, calendar: calendar) + let seconds = nextPeak.timeIntervalSince(date) + let minutes = max(Int(seconds / 60), 0) + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: minutes))") + } + + private static func nextPeakStart(after date: Date, calendar: Calendar) -> Date { + guard let todayPeak = calendar.date( + bySettingHour: self.peakStartHour, + minute: 0, + second: 0, + of: date) else { return date } + + let anchor = todayPeak > date ? todayPeak : calendar.date(byAdding: .day, value: 1, to: todayPeak) ?? date + let weekday = calendar.component(.weekday, from: anchor) + + let skip = switch weekday { + case 1: 1 + case 7: 2 + default: 0 + } + + if skip == 0 { return anchor } + return calendar.date(byAdding: .day, value: skip, to: anchor) ?? anchor + } + + private static func formatDuration(minutes: Int) -> String { + let h = minutes / 60 + let m = minutes % 60 + if h == 0 { + return "\(m)m" + } + if m == 0 { + return "\(h)h" + } + return "\(h)h \(m)m" + } + + private static func calendar() -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = self.peakTimeZone + return cal + } +} diff --git a/Tests/CodexBarTests/ClaudePeakHoursTests.swift b/Tests/CodexBarTests/ClaudePeakHoursTests.swift new file mode 100644 index 000000000..5abb93346 --- /dev/null +++ b/Tests/CodexBarTests/ClaudePeakHoursTests.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudePeakHoursTests { + private static let eastern = TimeZone(identifier: "America/New_York")! + + private func date( + year: Int = 2026, + month: Int = 3, + day: Int, + hour: Int, + minute: Int = 0, + second: Int = 0) -> Date + { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = Self.eastern + return cal.date(from: DateComponents( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second))! + } + + @Test + func weekdayMorningBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1h") + } + + @Test + func weekdayJustBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func weekdayPeakStart() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } + + @Test + func weekdayMidPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 11, minute: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h 30m") + } + + @Test + func weekdayPeakEndBoundary() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 13, minute: 59)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1m") + } + + @Test + func weekdayAfterPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 14)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 18h") + } + + @Test + func weekdayLateEvening() { + let status = ClaudePeakHours.status(at: self.date(day: 26, hour: 23)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 9h") + } + + @Test + func saturdayMorning() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 46h") + } + + @Test + func sundayEvening() { + let status = ClaudePeakHours.status(at: self.date(day: 29, hour: 21)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 11h") + } + + @Test + func fridayAfterPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 15)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 65h") + } + + @Test + func fridayPeak() { + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 12)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h") + } + + @Test + func springForwardWeekend() { + let status = ClaudePeakHours.status(at: self.date(day: 7, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 45h") + } + + @Test + func mondayMidnight() { + let status = ClaudePeakHours.status(at: self.date(day: 23, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 8h") + } + + @Test + func peakWithMinuteGranularity() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 12, minute: 15)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1h 45m") + } + + @Test + func saturdayMidnight() { + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 56h") + } + + @Test + func weekdayJustBeforePeakWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func weekdayOneMinuteBeforePeakWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 30)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func weekdayLastSecondBeforePeak() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 59)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1m") + } + + @Test + func weekdayPeakStartWithSeconds() { + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8, minute: 0, second: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } +} diff --git a/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift new file mode 100644 index 000000000..a6faa8694 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift @@ -0,0 +1,140 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardOptionalUsageModelTests { + @Test + func `hides codex credits when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let credits = CreditsSnapshot(remaining: 12, events: [], updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: credits, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: credits, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `claude model shows peak hours note when enabled`() throws { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = try #require(TimeZone(identifier: "America/New_York")) + let now = try #require(cal.date(from: DateComponents(year: 2026, month: 3, day: 25, hour: 10))) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: true, + now: now)) + + #expect(model.usageNotes.count == 1) + #expect(model.usageNotes.first?.contains("Peak") == true) + } + + @Test + func `claude model hides peak hours note when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: false, + now: now)) + + #expect(model.usageNotes.isEmpty) + } +} From eb8cc9db8477aac04ed93229fe186e48cf12cbf5 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 10:32:36 +0530 Subject: [PATCH 004/314] Add Claude peak-hours note to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a05f9921..dd708ac54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.24 — Unreleased +### Providers & Usage +- Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! + ### Fixes - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! From c676e165f0518e48a2a894fa9ebd01cca1ac7daf Mon Sep 17 00:00:00 2001 From: iam-brain <94809115+iam-brain@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:37:46 -0400 Subject: [PATCH 005/314] Introduce vertical cost detail list in cost utilization submenu (#513) * Introduce vertical cost detail list * Preserve cost detail ranking --------- Co-authored-by: Ratul Sarna --- .../CodexBar/CostHistoryChartMenuView.swift | 143 +++++++++++++----- 1 file changed, 108 insertions(+), 35 deletions(-) diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fec1135dc..6dec06769 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -20,6 +20,18 @@ struct CostHistoryChartMenuView: View { } } + private struct DetailRow: Identifiable { + let id: String + let title: String + let subtitle: String? + let accentColor: Color + } + + private struct DetailContent { + let primary: String + let rows: [DetailRow] + } + private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? @@ -88,22 +100,48 @@ struct CostHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) - VStack(alignment: .leading, spacing: 0) { + let detail = self.detailContent(model: model) + VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary ?? " ") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - .opacity(detail.secondary == nil ? 0 : 1) + .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) + ForEach(detail.rows) { row in + HStack(alignment: .top, spacing: 8) { + Rectangle() + .fill(row.accentColor) + .frame(width: 2, height: row.subtitle == nil ? 14 : Self.detailRowHeight) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 1) { + Text(row.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle = row.subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(height: Self.detailRowHeight, alignment: .leading) + } + ForEach(0.. Double { maxValue * 0.05 @@ -150,6 +193,7 @@ struct CostHistoryChartMenuView: View { var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 + var maxRenderedBreakdownRows = 0 for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } @@ -158,6 +202,7 @@ struct CostHistoryChartMenuView: View { pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) + maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, Self.renderedBreakdownRowCount(for: entry)) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { @@ -181,7 +226,8 @@ struct CostHistoryChartMenuView: View { axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, - maxCostUSD: maxCostUSD) + maxCostUSD: maxCostUSD, + maxRenderedBreakdownRows: maxRenderedBreakdownRows) } private static func barColor(for provider: UsageProvider) -> Color { @@ -211,6 +257,18 @@ struct CostHistoryChartMenuView: View { return model.pointsByDateKey[key] } + private static func renderedBreakdownRowCount(for entry: DailyEntry) -> Int { + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return 0 } + return min(breakdown.count, self.maxVisibleDetailLines) + } + + private static func detailBlockHeight(maxBreakdownRows: Int) -> CGFloat { + guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } + return self.detailPrimaryLineHeight + + (CGFloat(maxBreakdownRows) * self.detailRowHeight) + + (CGFloat(maxBreakdownRows) * self.detailSpacing) + } + private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } @@ -286,41 +344,56 @@ struct CostHistoryChartMenuView: View { return best?.key } - private func detailLines(model: Model) -> (primary: String, secondary: String?) { + private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return DetailContent(primary: "Hover a bar for details", rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = UsageFormatter.usdString(point.costUSD) - if let tokens = point.totalTokens { - let primary = "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + let primary = if let tokens = point.totalTokens { + "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" + } else { + "\(dayLabel): \(cost)" } - let primary = "\(dayLabel): \(cost)" - let secondary = self.topModelsText(key: key, model: model) - return (primary, secondary) + return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } - private func topModelsText(key: String, model: Model) -> String? { - guard let entry = model.entriesByDateKey[key] else { return nil } - guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return nil } - let parts = breakdown - .compactMap { item -> String? in - let name = UsageFormatter.modelDisplayName(item.modelName) - guard let detail = UsageFormatter.modelCostDetail( - item.modelName, - costUSD: item.costUSD, - totalTokens: item.totalTokens) - else { return nil } - return "\(name) \(detail)" + private func breakdownRows(key: String, model: Model) -> [DetailRow] { + guard let entry = model.entriesByDateKey[key] else { return [] } + guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } + + return breakdown + .sorted { lhs, rhs in + let lCost = lhs.costUSD ?? -1 + let rCost = rhs.costUSD ?? -1 + if lCost != rCost { return lCost > rCost } + + let lTokens = lhs.totalTokens ?? -1 + let rTokens = rhs.totalTokens ?? -1 + if lTokens != rTokens { return lTokens > rTokens } + + return lhs.modelName > rhs.modelName } - .prefix(3) - guard !parts.isEmpty else { return nil } - return "Top: \(parts.joined(separator: " · "))" + .prefix(Self.maxVisibleDetailLines) + .enumerated() + .map { index, item in + DetailRow( + id: "\(item.modelName)-\(index)", + title: UsageFormatter.modelDisplayName(item.modelName), + subtitle: UsageFormatter.modelCostDetail( + item.modelName, + costUSD: item.costUSD, + totalTokens: item.totalTokens), + accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) + } + } + + private static func breakdownAccentOpacity(for index: Int) -> Double { + let opacity = 0.75 - (Double(index) * 0.12) + return max(0.3, opacity) } } From 26316483a851398bcd0bdf19d2e26057e994f968 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Tue, 28 Apr 2026 23:12:23 +0530 Subject: [PATCH 006/314] Add cost history detail changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd708ac54..c30b03a16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Providers & Usage - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! +- Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! ### Fixes - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! From b6b77b4b8ea803b671dea666bc76135e6af0c057 Mon Sep 17 00:00:00 2001 From: Willy Date: Tue, 28 Apr 2026 23:55:56 -0500 Subject: [PATCH 007/314] feat: add DeepSeek provider with credit balance tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GET https://api.deepseek.com/user/balance and surfaces credit balance (paid vs. granted breakdown) in the menu bar. - Reads DEEPSEEK_API_KEY (primary) or DEEPSEEK_KEY (fallback) from env or Settings → Providers - Balance shown in identity card (loginMethod); account-unavailable and zero-balance states handled with appropriate messages - SVG whale icon (ProviderIcon-deepseek.svg, MIT) using currentColor - Disabled by default (defaultEnabled: false) - Wired into TokenAccountSupportCatalog, SettingsStore+MenuObservation, UsageStore+Accessors, and ProviderConfigEnvironment - 21 tests covering parse, error, currency, and env-var priority Closes #795 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + README.md | 1 + .../DeepSeekProviderImplementation.swift | 57 +++++ .../DeepSeek/DeepSeekSettingsStore.swift | 16 ++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-deepseek.svg | 3 + .../SettingsStore+MenuObservation.swift | 1 + Sources/CodexBar/UsageStore+Accessors.swift | 2 + Sources/CodexBar/UsageStore.swift | 51 +++-- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../Config/ProviderConfigEnvironment.swift | 2 + .../CodexBarCore/Logging/LogCategories.swift | 2 + .../DeepSeek/DeepSeekProviderDescriptor.swift | 70 ++++++ .../DeepSeek/DeepSeekSettingsReader.swift | 34 +++ .../DeepSeek/DeepSeekUsageFetcher.swift | 190 ++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 12 + .../CodexBarCore/Providers/Providers.swift | 2 + .../TokenAccountSupportCatalog+Data.swift | 7 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../DeepSeekSettingsReaderTests.swift | 73 ++++++ .../DeepSeekUsageFetcherTests.swift | 209 ++++++++++++++++++ .../ProviderConfigEnvironmentTests.swift | 29 +++ docs/deepseek.md | 40 ++++ docs/providers.md | 10 +- 27 files changed, 805 insertions(+), 17 deletions(-) create mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-deepseek.svg create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift create mode 100644 Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift create mode 100644 Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift create mode 100644 docs/deepseek.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c30b03a16..0b3a19d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage +- DeepSeek: add provider support with API key balance tracking, paid vs. granted credit breakdown, and CLI support (#795). - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! diff --git a/README.md b/README.md index d0e0474de..607d5970e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Linux support via Omarchy: community Waybar module and TUI, driven by the `codex - [JetBrains AI](docs/jetbrains.md) — Local XML-based quota from JetBrains IDE configuration; monthly credits tracking. - [OpenRouter](docs/openrouter.md) — API token for credit-based usage tracking across multiple AI providers. - [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. +- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown). - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift new file mode 100644 index 000000000..13d7d83f3 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -0,0 +1,57 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct DeepSeekProviderImplementation: ProviderImplementation { + let id: UsageProvider = .deepseek + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.deepSeekAPIToken + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + context.settings.ensureDeepSeekAPITokenLoaded() + return !context.settings.deepSeekAPIToken + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "deepseek-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Generate one at platform.deepseek.com/api_keys.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.deepSeekAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "deepseek-open-api-keys", + title: "Open API Keys", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.deepseek.com/api_keys") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureDeepSeekAPITokenLoaded() }), + ] + } +} diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift new file mode 100644 index 000000000..ea624be22 --- /dev/null +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var deepSeekAPIToken: String { + get { self.configSnapshot.providerConfig(for: .deepseek)?.sanitizedAPIKey ?? "" } + set { + self.updateProviderConfig(provider: .deepseek) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .deepseek, field: "apiKey", value: newValue) + } + } + + func ensureDeepSeekAPITokenLoaded() {} +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 1b4950ec3..199a62f2d 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -40,6 +40,7 @@ enum ProviderImplementationRegistry { case .perplexity: PerplexityProviderImplementation() case .abacus: AbacusProviderImplementation() case .mistral: MistralProviderImplementation() + case .deepseek: DeepSeekProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg new file mode 100644 index 000000000..5a2f55bfe --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 73786c47f..106f2a57d 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -70,6 +70,7 @@ extension SettingsStore { _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken + _ = self.deepSeekAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBar/UsageStore+Accessors.swift b/Sources/CodexBar/UsageStore+Accessors.swift index 9c3c759e9..5ce176c04 100644 --- a/Sources/CodexBar/UsageStore+Accessors.swift +++ b/Sources/CodexBar/UsageStore+Accessors.swift @@ -56,6 +56,8 @@ extension UsageStore { return ZaiSettingsError.missingToken.errorDescription case .openrouter: return OpenRouterSettingsError.missingToken.errorDescription + case .deepseek: + return DeepSeekUsageError.missingCredentials.errorDescription case .perplexity: return PerplexityAPIError.missingToken.errorDescription case .minimax: diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 3704feeda..9c9ce3241 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -785,13 +785,17 @@ extension UsageStore { let ollamaCookieHeader = self.settings.ollamaCookieHeader let processEnvironment = self.environmentBase let openRouterConfigToken = self.settings.providerConfig(for: .openrouter)?.sanitizedAPIKey - let openRouterHasConfigToken = !(openRouterConfigToken?.trimmingCharacters(in: .whitespacesAndNewlines) - .isEmpty ?? true) let openRouterHasEnvToken = OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil let openRouterEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) + let deepSeekConfigToken = self.settings.providerConfig(for: .deepseek)?.sanitizedAPIKey + let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil + let deepSeekEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .deepseek, + config: self.settings.providerConfig(for: .deepseek)) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() @@ -864,23 +868,22 @@ extension UsageStore { ollamaCookieSource: ollamaCookieSource, ollamaCookieHeader: ollamaCookieHeader) case .openrouter: - let resolution = ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment) - let hasAny = resolution != nil - let source: String = if resolution == nil { - "none" - } else if openRouterHasConfigToken, openRouterHasEnvToken { - "settings-config (overrides env)" - } else if openRouterHasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "OPENROUTER_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + return Self.apiKeyDebugLine( + label: "OPENROUTER_API_KEY", + resolution: ProviderTokenResolver.openRouterResolution(environment: openRouterEnvironment), + configToken: openRouterConfigToken, + hasEnvToken: openRouterHasEnvToken) case .warp: let resolution = ProviderTokenResolver.warpResolution() let hasAny = resolution != nil let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .deepseek: + return Self.apiKeyDebugLine( + label: "DEEPSEEK_API_KEY", + resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), + configToken: deepSeekConfigToken, + hasEnvToken: deepSeekHasEnvToken) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" @@ -1003,6 +1006,26 @@ extension UsageStore { #endif } + nonisolated private static func apiKeyDebugLine( + label: String, + resolution: ProviderTokenResolution?, + configToken: String?, + hasEnvToken: Bool) -> String + { + let hasAny = resolution != nil + let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let source: String = if resolution == nil { + "none" + } else if hasConfigToken, hasEnvToken { + "settings-config (overrides env)" + } else if hasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" + } + private static func debugCursorLog( browserDetection: BrowserDetection, cursorCookieSource: ProviderCookieSource, diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 5d4f9cba2..0e1b5b2c8 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -200,7 +200,7 @@ struct TokenAccountCLIContext { mistral: ProviderSettingsSnapshot.MistralProviderSettings( cookieSource: cookieSource, manualCookieHeader: cookieHeader)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp, .deepseek: return nil } } diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 6620ae879..71e438e56 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -31,6 +31,8 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey + case .deepseek: + env[DeepSeekSettingsReader.apiKeyEnvironmentKey] = apiKey default: break } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index fba33c0ce..ae1d10a34 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -20,6 +20,8 @@ public enum LogCategories { public static let copilotTokenStore = "copilot-token-store" public static let creditsPurchase = "creditsPurchase" public static let cursorLogin = "cursor-login" + public static let deepSeekSettings = "deepseek-settings" + public static let deepSeekUsage = "deepseek-usage" public static let geminiProbe = "gemini-probe" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift new file mode 100644 index 000000000..56db22889 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum DeepSeekProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .deepseek, + metadata: ProviderMetadata( + id: .deepseek, + displayName: "DeepSeek", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show DeepSeek usage", + cliName: "deepseek", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.deepseek.com/usage", + statusPageURL: nil, + statusLinkURL: "https://status.deepseek.com"), + branding: ProviderBranding( + iconStyle: .deepseek, + iconResourceName: "ProviderIcon-deepseek", + color: ProviderColor(red: 0.32, green: 0.49, blue: 0.94)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "DeepSeek per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [DeepSeekAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "deepseek", + aliases: ["deep-seek", "ds"], + versionDetector: nil)) + } +} + +struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "deepseek.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw DeepSeekUsageError.missingCredentials + } + let usage = try await DeepSeekUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.deepseekToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift new file mode 100644 index 000000000..6f622d332 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct DeepSeekSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "DEEPSEEK_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "DEEPSEEK_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.removeFirst() + value.removeLast() + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift new file mode 100644 index 000000000..46b272790 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -0,0 +1,190 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct DeepSeekBalanceResponse: Decodable, Sendable { + public let isAvailable: Bool + public let balanceInfos: [DeepSeekBalanceInfo] + + enum CodingKeys: String, CodingKey { + case isAvailable = "is_available" + case balanceInfos = "balance_infos" + } +} + +public struct DeepSeekBalanceInfo: Decodable, Sendable { + public let currency: String + public let totalBalance: String + public let grantedBalance: String + public let toppedUpBalance: String + + enum CodingKeys: String, CodingKey { + case currency + case totalBalance = "total_balance" + case grantedBalance = "granted_balance" + case toppedUpBalance = "topped_up_balance" + } +} + +// MARK: - Domain snapshot + +public struct DeepSeekUsageSnapshot: Sendable { + public let isAvailable: Bool + public let currency: String + public let totalBalance: Double + public let grantedBalance: Double + public let toppedUpBalance: Double + public let updatedAt: Date + + public init( + isAvailable: Bool, + currency: String, + totalBalance: Double, + grantedBalance: Double, + toppedUpBalance: Double, + updatedAt: Date) + { + self.isAvailable = isAvailable + self.currency = currency + self.totalBalance = totalBalance + self.grantedBalance = grantedBalance + self.toppedUpBalance = toppedUpBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let symbol = self.currency == "CNY" ? "¥" : "$" + + let loginMethod: String + if !self.isAvailable { + loginMethod = "Account unavailable" + } else if self.totalBalance <= 0 { + loginMethod = "\(symbol)0.00 — add credits at platform.deepseek.com" + } else { + let total = String(format: "\(symbol)%.2f", self.totalBalance) + let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) + let granted = String(format: "\(symbol)%.2f", self.grantedBalance) + loginMethod = "\(total) (Paid: \(paid) / Granted: \(granted))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .deepseek, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum DeepSeekUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing DeepSeek API key." + case let .networkError(message): + "DeepSeek network error: \(message)" + case let .apiError(message): + "DeepSeek API error: \(message)" + case let .parseFailed(message): + "Failed to parse DeepSeek response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct DeepSeekUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) + private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> DeepSeekUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw DeepSeekUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw DeepSeekUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("DeepSeek API returned \(httpResponse.statusCode): \(body)") + throw DeepSeekUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("DeepSeek API response: \(jsonString)") + } + + return try Self.parseSnapshot(data: data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { + let decoded: DeepSeekBalanceResponse + do { + decoded = try JSONDecoder().decode(DeepSeekBalanceResponse.self, from: data) + } catch { + throw DeepSeekUsageError.parseFailed(error.localizedDescription) + } + + // Prefer USD; fall back to first available entry. + let info = decoded.balanceInfos.first { $0.currency == "USD" } + ?? decoded.balanceInfos.first + + guard let info else { + return DeepSeekUsageSnapshot( + isAvailable: false, + currency: "USD", + totalBalance: 0, + grantedBalance: 0, + toppedUpBalance: 0, + updatedAt: Date()) + } + + guard + let total = Double(info.totalBalance), + let granted = Double(info.grantedBalance), + let toppedUp = Double(info.toppedUpBalance) + else { + throw DeepSeekUsageError.parseFailed("Non-numeric balance value in response.") + } + + return DeepSeekUsageSnapshot( + isAvailable: decoded.isAvailable, + currency: info.currency, + totalBalance: total, + grantedBalance: granted, + toppedUpBalance: toppedUp, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d98204b8e..fa1e5c137 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -80,6 +80,7 @@ public enum ProviderDescriptorRegistry { .perplexity: PerplexityProviderDescriptor.descriptor, .abacus: AbacusProviderDescriptor.descriptor, .mistral: MistralProviderDescriptor.descriptor, + .deepseek: DeepSeekProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 85113cc26..fcd53f90b 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -71,6 +71,18 @@ public enum ProviderTokenResolver { self.perplexityResolution(environment: environment)?.token } + public static func deepseekToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.deepseekResolution(environment: environment)?.token + } + + public static func deepseekResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 22493fa0f..923be6669 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -30,6 +30,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case perplexity case abacus case mistral + case deepseek } // swiftformat:enable sortDeclarations @@ -62,6 +63,7 @@ public enum IconStyle: Sendable, CaseIterable { case perplexity case abacus case mistral + case deepseek case combined } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 893be0d5a..a4ce33fb9 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -9,6 +9,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), + .deepseek: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepSeek API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 69cc02db6..0450d3092 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -236,7 +236,7 @@ enum CostUsageScanner { case .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity, .abacus, - .mistral: + .mistral, .deepseek: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index ce60108de..3d94e9d30 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -78,6 +78,7 @@ enum ProviderChoice: String, AppEnum { case .perplexity: return nil // Perplexity not yet supported in widgets case .abacus: return nil // Abacus AI not yet supported in widgets case .mistral: return nil // Mistral not yet supported in widgets + case .deepseek: return nil // DeepSeek not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 00c791aa8..8c1dc6905 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -284,6 +284,7 @@ private struct ProviderSwitchChip: View { case .perplexity: "Pplx" case .abacus: "Abacus" case .mistral: "Mistral" + case .deepseek: "DeepSeek" } } } @@ -647,6 +648,8 @@ enum WidgetColors { Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) case .mistral: Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange + case .deepseek: + Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) } } } diff --git a/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift new file mode 100644 index 000000000..0aba8b4a8 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct DeepSeekSettingsReaderTests { + @Test + func `reads DEEPSEEK_API_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-abc123"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-abc123") + } + + @Test + func `falls back to DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_KEY": "sk-fallback"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-fallback") + } + + @Test + func `DEEPSEEK_API_KEY takes priority over DEEPSEEK_KEY`() { + let env = ["DEEPSEEK_API_KEY": "sk-primary", "DEEPSEEK_KEY": "sk-secondary"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-primary") + } + + @Test + func `trims whitespace`() { + let env = ["DEEPSEEK_API_KEY": " sk-trimmed "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["DEEPSEEK_API_KEY": "\"sk-quoted\""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["DEEPSEEK_KEY": "'sk-single'"] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == "sk-single") + } + + @Test + func `returns nil when no key present`() { + #expect(DeepSeekSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["DEEPSEEK_API_KEY": ""] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["DEEPSEEK_API_KEY": " "] + #expect(DeepSeekSettingsReader.apiKey(environment: env) == nil) + } +} + +struct DeepSeekProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["DEEPSEEK_API_KEY": "sk-resolve-test"] + let resolution = ProviderTokenResolver.deepseekResolution(environment: env) + #expect(resolution?.token == "sk-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.deepseekResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift new file mode 100644 index 000000000..320777d16 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekUsageFetcherTests { + @Test + func `parses USD balance response`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == true) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.grantedBalance == 10.0) + #expect(snapshot.toppedUpBalance == 40.0) + } + + @Test + func `parses CNY balance response`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "110.00", + "granted_balance": "10.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "CNY") + #expect(snapshot.totalBalance == 110.0) + #expect(snapshot.toppedUpBalance == 100.0) + } + + @Test + func `prefers USD when both currencies present`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + }, + { + "currency": "USD", + "total_balance": "20.00", + "granted_balance": "5.00", + "topped_up_balance": "15.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currency == "USD") + #expect(snapshot.totalBalance == 20.0) + } + + @Test + func `depleted icon when is_available false`() throws { + let json = """ + { + "is_available": false, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "0.00", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "Account unavailable") + } + + @Test + func `full bar when balance available`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "5.00", + "granted_balance": "0.00", + "topped_up_balance": "5.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod?.contains("$5.00") == true) + } + + @Test + func `throws on malformed balance string`() { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "not-a-number", + "granted_balance": "0.00", + "topped_up_balance": "0.00" + } + ] + } + """ + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `empty balance_infos returns unavailable snapshot`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.isAvailable == false) + #expect(snapshot.totalBalance == 0.0) + } + + @Test + func `throws on invalid JSON root`() { + let json = "[{ \"is_available\": true }]" + #expect { + _ = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `balance description includes paid and granted breakdown`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + let loginMethod = usage.identity?.loginMethod ?? "" + #expect(loginMethod.contains("$50.00")) + #expect(loginMethod.contains("$40.00")) + #expect(loginMethod.contains("$10.00")) + } + + @Test + func `CNY balance uses yen symbol`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "CNY", + "total_balance": "100.00", + "granted_balance": "0.00", + "topped_up_balance": "100.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + let loginMethod = usage.identity?.loginMethod ?? "" + #expect(loginMethod.contains("¥")) + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 665bf0c76..0b16b997a 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -39,6 +39,22 @@ struct ProviderConfigEnvironmentTests { #expect(env[OpenRouterSettingsReader.envKey] == "or-token") } + @Test + func `applies API key override for deepseek`() { + let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepseek, + config: config) + + let key = DeepSeekSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "ds-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "ds-token") + } + @Test func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") @@ -63,6 +79,19 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.openRouterToken(environment: env) == "config-token") } + @Test + func `deepseek config override wins over environment token`() { + let config = ProviderConfig(id: .deepseek, apiKey: "config-token") + let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [envKey: "env-token"], + provider: .deepseek, + config: config) + + #expect(env[envKey] == "config-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "config-token") + } + @Test func `leaves environment when API key missing`() { let config = ProviderConfig(id: .zai, apiKey: nil) diff --git a/docs/deepseek.md b/docs/deepseek.md new file mode 100644 index 000000000..b116a9bb8 --- /dev/null +++ b/docs/deepseek.md @@ -0,0 +1,40 @@ +--- +summary: "DeepSeek provider data sources: API key + balance endpoint." +read_when: + - Adding or tweaking DeepSeek balance parsing + - Updating API key handling + - Documenting new provider behavior +--- + +# DeepSeek provider + +DeepSeek is API-only. Balance is reported by `GET https://api.deepseek.com/user/balance`, +so CodexBar only needs a valid API key to show your remaining credit balance. + +## Data sources + +1. **API key** stored in `~/.codexbar/config.json` or supplied via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`. + CodexBar stores the key in config after you paste it in Settings → Providers → DeepSeek. +2. **Balance endpoint** + - `GET https://api.deepseek.com/user/balance` + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Response contains `is_available`, and a `balance_infos` array with per-currency entries + (`total_balance`, `granted_balance`, `topped_up_balance`). + +## Usage details + +- The menu card shows total balance with the paid vs. granted breakdown: + e.g. `$50.00 (Paid: $40.00 / Granted: $10.00)`. +- Granted credits are promotional and may expire; topped-up credits are user-paid and do not expire. +- When multiple currencies are present, USD is shown preferentially. +- `is_available: false` from the API dims the icon and shows "Account unavailable". +- There is no session or weekly window — DeepSeek does not expose per-window quota via API. +- Settings config takes precedence over environment variables when both are present. + +## Key files + +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift` (descriptor + fetch strategy) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift` (settings field + activation logic) +- `Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift` (SettingsStore extension) diff --git a/docs/providers.md b/docs/providers.md index 0fe26b2cf..5db9fe874 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral)." +summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, OpenCode, Alibaba Coding Plan, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter, Abacus AI, Mistral, DeepSeek)." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -41,6 +41,7 @@ until the session is invalid, to avoid repeated Keychain prompts. | OpenRouter | API token (config, overrides env) → credits API (`api`). | | Abacus AI | Browser cookies → compute points + billing API (`web`). | | Mistral | Console billing API via Ory Kratos session cookies (`web`). | +| DeepSeek | API key (config, overrides env) → balance endpoint (`api`). | ## Codex - Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. @@ -204,4 +205,11 @@ until the session is invalid, to avoid repeated Keychain prompts. - Resets at end of calendar month. - Status: `https://status.mistral.ai` (link only, no auto-polling). +## DeepSeek +- API key via Settings (`~/.codexbar/config.json`) or `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY` env var. +- `GET https://api.deepseek.com/user/balance`. +- Shows total balance with paid vs. granted breakdown; USD preferred when multiple currencies present. +- Status: `https://status.deepseek.com` (link only, no auto-polling). +- Details: `docs/deepseek.md`. + See also: `docs/provider.md` for architecture notes. From baf5a2bd9c3b8c3c1ca7bd7f634a481420a5f940 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 14:46:33 +0530 Subject: [PATCH 008/314] Fix DeepSeek balance display --- Sources/CodexBar/MenuCardView.swift | 4 +-- Sources/CodexBar/MenuDescriptor.swift | 8 +++--- Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/CLIRenderer.swift | 2 +- .../DeepSeek/DeepSeekUsageFetcher.swift | 25 +++++++++++++------ .../DeepSeekUsageFetcherTests.swift | 24 ++++++++++-------- 6 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 297dd71bd..02ab642c6 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1071,7 +1071,7 @@ extension UsageMenuCardView.Model { { primaryResetText = openRouterQuotaDetail } - if input.provider == .warp || input.provider == .kilo, + if input.provider == .warp || input.provider == .kilo || input.provider == .deepseek, let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -1083,7 +1083,7 @@ extension UsageMenuCardView.Model { { primaryDetailText = detail } - if input.provider == .warp || input.provider == .kilo, primary.resetsAt == nil { + if input.provider == .warp || input.provider == .kilo || input.provider == .deepseek, primary.resetsAt == nil { primaryResetText = nil } // Abacus: show credits as detail, compute pace on the primary monthly window diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 46e0a2c6a..cc757055b 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -146,8 +146,10 @@ struct MenuDescriptor { if let snap = store.snapshot(for: provider) { let resetStyle = settings.resetTimeDisplayStyle if let primary = snap.primary { - let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus { - // Warp/Kilo/Abacus primary uses resetDescription for non-reset detail + let primaryWindow = if provider == .warp || provider == .kilo || provider == .abacus || + provider == .deepseek + { + // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. RateWindow( usedPercent: primary.usedPercent, @@ -163,7 +165,7 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if provider == .warp || provider == .kilo || provider == .abacus, + if provider == .warp || provider == .kilo || provider == .abacus || provider == .deepseek, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 9c9ce3241..b59823a02 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1006,7 +1006,7 @@ extension UsageStore { #endif } - nonisolated private static func apiKeyDebugLine( + private nonisolated static func apiKeyDebugLine( label: String, resolution: ProviderTokenResolution?, configToken: String?, diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index e22260c8d..7ae00397e 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -199,7 +199,7 @@ enum CLIRenderer { now: Date, lines: inout [String]) { - if provider == .warp || provider == .kilo || provider == .mistral { + if provider == .warp || provider == .kilo || provider == .mistral || provider == .deepseek { if let reset = self.resetLineForDetailBackedWindow(window: window, style: context.resetStyle, now: now) { lines.append(self.subtleLine(reset, useColor: context.useColor)) } diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index 46b272790..2aafbb567 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -58,26 +58,35 @@ public struct DeepSeekUsageSnapshot: Sendable { public func toUsageSnapshot() -> UsageSnapshot { let symbol = self.currency == "CNY" ? "¥" : "$" - let loginMethod: String - if !self.isAvailable { - loginMethod = "Account unavailable" - } else if self.totalBalance <= 0 { - loginMethod = "\(symbol)0.00 — add credits at platform.deepseek.com" + let balanceDetail: String + let usedPercent: Double + if self.totalBalance <= 0 { + balanceDetail = "\(symbol)0.00 — add credits at platform.deepseek.com" + usedPercent = 100 + } else if !self.isAvailable { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 } else { let total = String(format: "\(symbol)%.2f", self.totalBalance) let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance) let granted = String(format: "\(symbol)%.2f", self.grantedBalance) - loginMethod = "\(total) (Paid: \(paid) / Granted: \(granted))" + balanceDetail = "\(total) (Paid: \(paid) / Granted: \(granted))" + usedPercent = 0 } let identity = ProviderIdentitySnapshot( providerID: .deepseek, accountEmail: nil, accountOrganization: nil, - loginMethod: loginMethod) + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) return UsageSnapshot( - primary: nil, + primary: balanceWindow, secondary: nil, tertiary: nil, providerCost: nil, diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 320777d16..59f384ced 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -74,7 +74,7 @@ struct DeepSeekUsageFetcherTests { } @Test - func `depleted icon when is_available false`() throws { + func `zero balance prompts top up even when unavailable`() throws { let json = """ { "is_available": false, @@ -91,8 +91,9 @@ struct DeepSeekUsageFetcherTests { let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) #expect(snapshot.isAvailable == false) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.identity?.loginMethod == "Account unavailable") + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "$0.00 — add credits at platform.deepseek.com") + #expect(usage.identity?.loginMethod == nil) } @Test @@ -112,8 +113,9 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - #expect(usage.primary == nil) - #expect(usage.identity?.loginMethod?.contains("$5.00") == true) + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetDescription?.contains("$5.00") == true) + #expect(usage.identity?.loginMethod == nil) } @Test @@ -180,10 +182,10 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - let loginMethod = usage.identity?.loginMethod ?? "" - #expect(loginMethod.contains("$50.00")) - #expect(loginMethod.contains("$40.00")) - #expect(loginMethod.contains("$10.00")) + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("$50.00")) + #expect(detail.contains("$40.00")) + #expect(detail.contains("$10.00")) } @Test @@ -203,7 +205,7 @@ struct DeepSeekUsageFetcherTests { """ let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) let usage = snapshot.toUsageSnapshot() - let loginMethod = usage.identity?.loginMethod ?? "" - #expect(loginMethod.contains("¥")) + let detail = usage.primary?.resetDescription ?? "" + #expect(detail.contains("¥")) } } From d4dcfee268d6a7ee7ddd5a2060f514192df60f85 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 14:55:51 +0530 Subject: [PATCH 009/314] Use token accounts for DeepSeek keys --- .../DeepSeekProviderImplementation.swift | 36 +++---------------- .../DeepSeek/DeepSeekSettingsStore.swift | 16 --------- .../SettingsStore+MenuObservation.swift | 1 - .../Config/ProviderConfigEnvironment.swift | 2 -- .../ProviderConfigEnvironmentTests.swift | 12 +++---- ...kenAccountEnvironmentPrecedenceTests.swift | 32 +++++++++++++++++ 6 files changed, 42 insertions(+), 57 deletions(-) delete mode 100644 Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift index 13d7d83f3..e72ec76f0 100644 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift +++ b/Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift @@ -1,8 +1,6 @@ -import AppKit import CodexBarCore import CodexBarMacroSupport import Foundation -import SwiftUI @ProviderImplementationRegistration struct DeepSeekProviderImplementation: ProviderImplementation { @@ -14,44 +12,18 @@ struct DeepSeekProviderImplementation: ProviderImplementation { } @MainActor - func observeSettings(_ settings: SettingsStore) { - _ = settings.deepSeekAPIToken - } + func observeSettings(_: SettingsStore) {} @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { if DeepSeekSettingsReader.apiKey(environment: context.environment) != nil { return true } - context.settings.ensureDeepSeekAPITokenLoaded() - return !context.settings.deepSeekAPIToken - .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return !context.settings.tokenAccounts(for: .deepseek).isEmpty } @MainActor - func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ - ProviderSettingsFieldDescriptor( - id: "deepseek-api-key", - title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Generate one at platform.deepseek.com/api_keys.", - kind: .secure, - placeholder: "sk-...", - binding: context.stringBinding(\.deepSeekAPIToken), - actions: [ - ProviderSettingsActionDescriptor( - id: "deepseek-open-api-keys", - title: "Open API Keys", - style: .link, - isVisible: nil, - perform: { - if let url = URL(string: "https://platform.deepseek.com/api_keys") { - NSWorkspace.shared.open(url) - } - }), - ], - isVisible: nil, - onActivate: { context.settings.ensureDeepSeekAPITokenLoaded() }), - ] + func settingsFields(context _: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [] } } diff --git a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift b/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift deleted file mode 100644 index ea624be22..000000000 --- a/Sources/CodexBar/Providers/DeepSeek/DeepSeekSettingsStore.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CodexBarCore -import Foundation - -extension SettingsStore { - var deepSeekAPIToken: String { - get { self.configSnapshot.providerConfig(for: .deepseek)?.sanitizedAPIKey ?? "" } - set { - self.updateProviderConfig(provider: .deepseek) { entry in - entry.apiKey = self.normalizedConfigValue(newValue) - } - self.logSecretUpdate(provider: .deepseek, field: "apiKey", value: newValue) - } - } - - func ensureDeepSeekAPITokenLoaded() {} -} diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 106f2a57d..73786c47f 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -70,7 +70,6 @@ extension SettingsStore { _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken - _ = self.deepSeekAPIToken _ = self.tokenAccountsByProvider _ = self.debugLoadingPattern _ = self.selectedMenuProvider diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 71e438e56..6620ae879 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -31,8 +31,6 @@ public enum ProviderConfigEnvironment { } case .openrouter: env[OpenRouterSettingsReader.envKey] = apiKey - case .deepseek: - env[DeepSeekSettingsReader.apiKeyEnvironmentKey] = apiKey default: break } diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 0b16b997a..1ff6b0c84 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -40,7 +40,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func `applies API key override for deepseek`() { + func `ignores legacy API key override for deepseek`() { let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -51,8 +51,8 @@ struct ProviderConfigEnvironmentTests { #expect(key != nil) guard let key else { return } - #expect(env[key] == "ds-token") - #expect(ProviderTokenResolver.deepseekToken(environment: env) == "ds-token") + #expect(env[key] == nil) + #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) } @Test @@ -80,7 +80,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func `deepseek config override wins over environment token`() { + func `deepseek config override leaves environment token alone`() { let config = ProviderConfig(id: .deepseek, apiKey: "config-token") let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] let env = ProviderConfigEnvironment.applyAPIKeyOverride( @@ -88,8 +88,8 @@ struct ProviderConfigEnvironmentTests { provider: .deepseek, config: config) - #expect(env[envKey] == "config-token") - #expect(ProviderTokenResolver.deepseekToken(environment: env) == "config-token") + #expect(env[envKey] == "env-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "env-token") } @Test diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index 414352959..2390110bc 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -24,6 +24,21 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-deepseek-app") + settings.addTokenAccount(provider: .deepseek, label: "Account 1", token: "account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .deepseek, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `token account environment overrides config API key in CLI environment builder`() throws { let config = CodexBarConfig( @@ -45,6 +60,23 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(env[ZaiSettingsReader.apiTokenKey] != "config-token") } + @Test + func `deepseek token account injects environment in CLI environment builder`() throws { + let config = CodexBarConfig(providers: []) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + let env = tokenContext.environment(base: [:], provider: .deepseek, account: account) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + @Test func `ollama token account selection forces manual cookie source in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( From 4081454fe1b6e09b0c365e91c1274d44934af45d Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 16:03:59 +0530 Subject: [PATCH 010/314] Add DeepSeek to provider order fixture --- Tests/CodexBarTests/SettingsStoreTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 9493cdb45..3a3866ec4 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -939,6 +939,7 @@ struct SettingsStoreTests { .perplexity, .abacus, .mistral, + .deepseek, ]) // Move one provider; ensure it's persisted across instances. From af375de65c67f7c0183011e63513248e8e11aacc Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 16:23:06 +0530 Subject: [PATCH 011/314] Include DeepSeek token accounts in debug output --- Sources/CodexBar/UsageStore.swift | 19 ++++++++++----- .../UsageStorePathDebugTests.swift | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b59823a02..120c75a2e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -790,12 +790,13 @@ extension UsageStore { base: processEnvironment, provider: .openrouter, config: self.settings.providerConfig(for: .openrouter)) - let deepSeekConfigToken = self.settings.providerConfig(for: .deepseek)?.sanitizedAPIKey let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil - let deepSeekEnvironment = ProviderConfigEnvironment.applyAPIKeyOverride( + let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil + let deepSeekEnvironment = ProviderRegistry.makeEnvironment( base: processEnvironment, provider: .deepseek, - config: self.settings.providerConfig(for: .deepseek)) + settings: self.settings, + tokenOverride: nil) let codexFetcher = self.codexFetcher let browserDetection = self.browserDetection let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() @@ -882,8 +883,9 @@ extension UsageStore { return Self.apiKeyDebugLine( label: "DEEPSEEK_API_KEY", resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), - configToken: deepSeekConfigToken, - hasEnvToken: deepSeekHasEnvToken) + configToken: nil, + hasEnvToken: deepSeekHasEnvToken, + hasTokenAccount: deepSeekHasTokenAccount) case .gemini, .antigravity, .opencode, .opencodego, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, .kimik2, .jetbrains, .perplexity, .abacus, .mistral: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" @@ -1010,12 +1012,17 @@ extension UsageStore { label: String, resolution: ProviderTokenResolution?, configToken: String?, - hasEnvToken: Bool) -> String + hasEnvToken: Bool, + hasTokenAccount: Bool = false) -> String { let hasAny = resolution != nil let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) let source: String = if resolution == nil { "none" + } else if hasTokenAccount, hasEnvToken { + "settings-token-account (overrides env)" + } else if hasTokenAccount { + "settings-token-account" } else if hasConfigToken, hasEnvToken { "settings-config (overrides env)" } else if hasConfigToken { diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index 11aabaf55..856fc178c 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -29,4 +29,27 @@ struct UsageStorePathDebugTests { #expect(store.pathDebugInfo != .empty) #expect(store.pathDebugInfo.effectivePATH.isEmpty == false) } + + @Test + func `deepseek debug log includes selected token account`() async throws { + let suite = "UsageStorePathDebugTests-deepseek-debug-token-account" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.addTokenAccount(provider: .deepseek, label: "Primary", token: "sk-deepseek-test") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.deepseek) + + #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") + } } From 6a442eb962bbc053f1221838b2870d24139074dd Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Wed, 29 Apr 2026 18:55:02 +0530 Subject: [PATCH 012/314] Update DeepSeek changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3a19d03..299b68f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage -- DeepSeek: add provider support with API key balance tracking, paid vs. granted credit breakdown, and CLI support (#795). +- DeepSeek: add provider support with token-account balance tracking, paid vs. granted credit breakdown, and CLI support (#811). Thanks @willytop8! - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! From c5908a2faf3fdd950410bbf7a45a45a8759dfe0c Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:01:44 +0530 Subject: [PATCH 013/314] Add Swift concurrency crash review guidance --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index a4c8e630a..8db4d4681 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ - Per user request: after every edit (code or docs), rebuild and restart using `./Scripts/compile_and_run.sh` so the running app reflects the latest changes. - Release script: keep it in the foreground; do not background it—wait until it finishes. - Release keys: find in `~/.profile` if missing (Sparkle + App Store Connect). +- Swift concurrency: treat sibling `async let` tasks as a review red flag when one child is required and another is optional/best-effort. Prefer sequential awaits or a drained `withThrowingTaskGroup` that surfaces required failures and explicitly contains optional failures; crash stacks mentioning `swift_task_dealloc` or `asyncLet_finish_after_task_completion` should trigger an audit of nearby `async let` usage. - Prefer modern SwiftUI/Observation macros: use `@Observable` models with `@State` ownership and `@Bindable` in views; avoid `ObservableObject`, `@ObservedObject`, and `@StateObject`. - Favor modern macOS 15+ APIs over legacy/deprecated counterparts when refactoring (Observation, new display link APIs, updated menu item styling, etc.). - Keep provider data siloed: when rendering usage or account info for a provider (Claude vs Codex), never display identity/plan fields sourced from a different provider.*** From 33fb652ce6cd4bcb4bc46f5a363992a284b23a9b Mon Sep 17 00:00:00 2001 From: Felipe Camus Date: Wed, 29 Apr 2026 18:20:41 -0400 Subject: [PATCH 014/314] fix(cursor): parse enterprise overall/pooled usage Decoder ignored individualUsage.overall and teamUsage.pooled, so Enterprise/Team accounts fell through to planPercentUsed=0 and the menu showed "100% remaining". Personal cap (overall) now wins; shared pool (pooled) is the last-resort fallback. Existing plan, on-demand, and legacy /api/usage paths unchanged. --- .../Providers/Cursor/CursorStatusProbe.swift | 102 ++++++++++- .../CursorStatusProbeTests.swift | 167 ++++++++++++++++++ 2 files changed, 264 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 4dee402a2..e9fe1c7b6 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -201,6 +201,38 @@ public struct CursorUsageSummary: Codable, Sendable { public struct CursorIndividualUsage: Codable, Sendable { public let plan: CursorPlanUsage? public let onDemand: CursorOnDemandUsage? + /// Enterprise / team-member personal cap. Reported by Cursor when the account is part of a team or + /// enterprise plan with an individual quota. Values follow the same cents-based units as `plan`. + public let overall: CursorOverallUsage? + + public init( + plan: CursorPlanUsage? = nil, + onDemand: CursorOnDemandUsage? = nil, + overall: CursorOverallUsage? = nil) + { + self.plan = plan + self.onDemand = onDemand + self.overall = overall + } +} + +/// Personal cap reported under `individualUsage.overall` for Enterprise/Team members. +/// Mirrors the shape of `CursorOnDemandUsage`; values are in cents. +public struct CursorOverallUsage: Codable, Sendable { + public let enabled: Bool? + /// Usage in cents (e.g., 7384 = $73.84) + public let used: Int? + /// Limit in cents (e.g., 10000 = $100.00). `nil` indicates the API omitted a numeric cap. + public let limit: Int? + /// Remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } public struct CursorPlanUsage: Codable, Sendable { @@ -235,6 +267,31 @@ public struct CursorOnDemandUsage: Codable, Sendable { public struct CursorTeamUsage: Codable, Sendable { public let onDemand: CursorOnDemandUsage? + /// Shared team/enterprise pool counted across all members. Same cents-based units as the other usage blocks. + public let pooled: CursorPooledUsage? + + public init(onDemand: CursorOnDemandUsage? = nil, pooled: CursorPooledUsage? = nil) { + self.onDemand = onDemand + self.pooled = pooled + } +} + +/// Shared team/enterprise pool reported under `teamUsage.pooled`. Values are in cents. +public struct CursorPooledUsage: Codable, Sendable { + public let enabled: Bool? + /// Pool usage in cents. + public let used: Int? + /// Pool limit in cents. `nil` indicates an unlimited or unreported pool. + public let limit: Int? + /// Pool remaining in cents. + public let remaining: Int? + + public init(enabled: Bool? = nil, used: Int? = nil, limit: Int? = nil, remaining: Int? = nil) { + self.enabled = enabled + self.used = used + self.limit = limit + self.remaining = remaining + } } // MARK: - Cursor Usage API Models (Legacy Request-Based Plans) @@ -966,8 +1023,6 @@ public struct CursorStatusProbe: Sendable { // Use plan.limit directly - breakdown.total represents total *used* credits, not the limit. let planUsedRaw = Double(summary.individualUsage?.plan?.used ?? 0) let planLimitRaw = Double(summary.individualUsage?.plan?.limit ?? 0) - let planUsed = planUsedRaw / 100.0 - let planLimit = planLimitRaw / 100.0 func normPct(_ value: Double?) -> Double? { guard let v = value else { return nil } if v < 0 { return 0 } @@ -984,9 +1039,23 @@ public struct CursorStatusProbe: Sendable { let autoPercent = normPct(summary.individualUsage?.plan?.autoPercentUsed) let apiPercent = normPct(summary.individualUsage?.plan?.apiPercentUsed) - // Headline "Total" should prefer Cursor's provided totalPercentUsed when available. plan.limit is often - // the subscription price in cents, so used/limit can diverge from the dashboard usage bars. - // If totalPercentUsed is absent, fall back to averaging the Auto/API lane percents. + // Enterprise / team-member personal cap (cents). Reported under `individualUsage.overall` for accounts + // that don't get a `plan` block. Falls through to existing logic when absent so non-enterprise paths + // are untouched. + let overallUsedRaw = (summary.individualUsage?.overall?.used).map(Double.init) + let overallLimitRaw = (summary.individualUsage?.overall?.limit).map(Double.init) + + // Shared team/enterprise pool (cents). Last-resort fallback when no individual data is available. + let pooledUsedRaw = (summary.teamUsage?.pooled?.used).map(Double.init) + let pooledLimitRaw = (summary.teamUsage?.pooled?.limit).map(Double.init) + + // Headline "Total" precedence: + // 1. `individualUsage.plan.totalPercentUsed` (existing behavior for Pro/Hobby/etc.) + // 2. averaged `auto` + `api` lane percents (existing behavior) + // 3. either lane alone (existing behavior) + // 4. `individualUsage.plan` ratio (existing behavior) + // 5. NEW: `individualUsage.overall` ratio (Enterprise/Team personal cap) + // 6. NEW: `teamUsage.pooled` ratio (last resort when no individual data is reported) let planPercentUsed: Double = if let totalPercentUsed = summary.individualUsage?.plan?.totalPercentUsed { normalizeTotalPercent(totalPercentUsed) } else if let autoUsed = autoPercent, let apiUsed = apiPercent { @@ -997,10 +1066,33 @@ public struct CursorStatusProbe: Sendable { max(0, min(100, autoUsed)) } else if planLimitRaw > 0 { (planUsedRaw / planLimitRaw) * 100 + } else if let used = overallUsedRaw, let limit = overallLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) + } else if let used = pooledUsedRaw, let limit = pooledLimitRaw, limit > 0 { + normalizeTotalPercent((used / limit) * 100) } else { 0 } + // USD figures: prefer the source the headline ultimately came from. When `plan` is missing but + // `overall` or `pooled` carry the cents, surface those so the on-demand display and downstream + // consumers see real dollar amounts instead of zeros. + let planUsed: Double + let planLimit: Double + if planLimitRaw > 0 || planUsedRaw > 0 { + planUsed = planUsedRaw / 100.0 + planLimit = planLimitRaw / 100.0 + } else if let usedCents = overallUsedRaw, let limitCents = overallLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else if let usedCents = pooledUsedRaw, let limitCents = pooledLimitRaw { + planUsed = usedCents / 100.0 + planLimit = limitCents / 100.0 + } else { + planUsed = 0 + planLimit = 0 + } + let onDemandUsed = Double(summary.individualUsage?.onDemand?.used ?? 0) / 100.0 let onDemandLimit: Double? = summary.individualUsage?.onDemand?.limit.map { Double($0) / 100.0 } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index e34277f9f..defb67661 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -96,6 +96,55 @@ struct CursorStatusProbeTests { #expect(summary.individualUsage?.plan?.totalPercentUsed == 50.0) } + @Test + func `parses enterprise overall and pooled usage summary`() throws { + // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; + // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` + // (shared team pool). Both blocks use cents like the existing `plan` block. + let json = """ + { + "billingCycleStart": "2026-04-01T00:00:00.000Z", + "billingCycleEnd": "2026-05-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "isUnlimited": false, + "individualUsage": { + "overall": { + "enabled": true, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 0, + "limit": null, + "remaining": null + }, + "pooled": { + "enabled": true, + "used": 12725135, + "limit": 28122000, + "remaining": 15396865 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.limitType == "team") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 7384) + #expect(summary.individualUsage?.overall?.limit == 10000) + #expect(summary.individualUsage?.overall?.remaining == 2616) + #expect(summary.teamUsage?.pooled?.used == 12_725_135) + #expect(summary.teamUsage?.pooled?.limit == 28_122_000) + } + // MARK: - User Info Parsing @Test @@ -317,6 +366,124 @@ struct CursorStatusProbeTests { #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) } + @Test + func `enterprise overall drives headline percent and dollars`() throws { + // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of + // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% + // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over + // any team pool, and USD figures must reflect the same source. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-04-01T00:00:00.000Z", + billingCycleEnd: "2026-05-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // Headline: $73.84 / $100 → 73.84% (matches Cursor's own dashboard). + // Allow a tiny tolerance for floating-point division (7384/10000 * 100 ≈ 73.83999…). + #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) + #expect(snapshot.planUsedUSD == 73.84) + #expect(snapshot.planLimitUSD == 100.0) + // Lane percents stay nil because Cursor doesn't ship Auto/API breakdown for Enterprise overall. + #expect(snapshot.autoPercentUsed == nil) + #expect(snapshot.apiPercentUsed == nil) + // Headline must NOT pick up the team pool's 45.25% — personal cap wins. + let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) + #expect(abs(primaryPercent - 73.84) < 0.0001) + } + + @Test + func `enterprise pooled fallback used when no individual data`() { + // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface + // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: nil, + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // 12_725_135 / 28_122_000 ≈ 45.2497...% — accept anything in (45.0, 45.5) to keep the + // assertion robust to floating-point precision. + #expect(snapshot.planPercentUsed > 45.0) + #expect(snapshot.planPercentUsed < 45.5) + #expect(snapshot.planUsedUSD == 127_251.35) + #expect(snapshot.planLimitUSD == 281_220.0) + } + + @Test + func `existing plan block still wins over overall and pooled`() { + // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` + // blocks, the existing percent precedence must remain intact. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 1500, + limit: 5000, + remaining: 3500, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: 30.0), + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // `plan.totalPercentUsed` wins; `overall` and `pooled` are ignored when `plan` is present. + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.planUsedUSD == 15.0) + #expect(snapshot.planLimitUSD == 50.0) + } + @Test func `converts snapshot to usage snapshot`() { let snapshot = CursorStatusSnapshot( From 7219c21ac7846adac5cd7965a109af674ca95bfb Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:28:02 +0530 Subject: [PATCH 015/314] Split Cursor enterprise tests --- .../CursorEnterpriseUsageTests.swift | 169 ++++++++++++++++++ .../CursorStatusProbeTests.swift | 167 ----------------- 2 files changed, 169 insertions(+), 167 deletions(-) create mode 100644 Tests/CodexBarTests/CursorEnterpriseUsageTests.swift diff --git a/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift new file mode 100644 index 000000000..c6b5dd19b --- /dev/null +++ b/Tests/CodexBarTests/CursorEnterpriseUsageTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CursorEnterpriseUsageTests { + @Test + func `parses enterprise overall and pooled usage summary`() throws { + // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; + // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` + // (shared team pool). Both blocks use cents like the existing `plan` block. + let json = """ + { + "billingCycleStart": "2026-04-01T00:00:00.000Z", + "billingCycleEnd": "2026-05-01T00:00:00.000Z", + "membershipType": "enterprise", + "limitType": "team", + "isUnlimited": false, + "individualUsage": { + "overall": { + "enabled": true, + "used": 7384, + "limit": 10000, + "remaining": 2616 + } + }, + "teamUsage": { + "onDemand": { + "enabled": true, + "used": 0, + "limit": null, + "remaining": null + }, + "pooled": { + "enabled": true, + "used": 12725135, + "limit": 28122000, + "remaining": 15396865 + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) + + #expect(summary.membershipType == "enterprise") + #expect(summary.limitType == "team") + #expect(summary.individualUsage?.plan == nil) + #expect(summary.individualUsage?.overall?.used == 7384) + #expect(summary.individualUsage?.overall?.limit == 10000) + #expect(summary.individualUsage?.overall?.remaining == 2616) + #expect(summary.teamUsage?.pooled?.used == 12_725_135) + #expect(summary.teamUsage?.pooled?.limit == 28_122_000) + } + + @Test + func `enterprise overall drives headline percent and dollars`() throws { + // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of + // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% + // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over + // any team pool, and USD figures must reflect the same source. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: "2026-04-01T00:00:00.000Z", + billingCycleEnd: "2026-05-01T00:00:00.000Z", + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: nil, + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + // Headline: $73.84 / $100 -> 73.84% (matches Cursor's own dashboard). + // Allow a tiny tolerance for floating-point division (7384/10000 * 100). + #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) + #expect(snapshot.planUsedUSD == 73.84) + #expect(snapshot.planLimitUSD == 100.0) + #expect(snapshot.autoPercentUsed == nil) + #expect(snapshot.apiPercentUsed == nil) + + let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) + #expect(abs(primaryPercent - 73.84) < 0.0001) + } + + @Test + func `enterprise pooled fallback used when no individual data`() { + // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface + // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "enterprise", + limitType: "team", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: nil, + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed > 45.0) + #expect(snapshot.planPercentUsed < 45.5) + #expect(snapshot.planUsedUSD == 127_251.35) + #expect(snapshot.planLimitUSD == 281_220.0) + } + + @Test + func `existing plan block still wins over overall and pooled`() { + // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` + // blocks, the existing percent precedence must remain intact. + let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) + .parseUsageSummary( + CursorUsageSummary( + billingCycleStart: nil, + billingCycleEnd: nil, + membershipType: "pro", + limitType: "user", + isUnlimited: false, + autoModelSelectedDisplayMessage: nil, + namedModelSelectedDisplayMessage: nil, + individualUsage: CursorIndividualUsage( + plan: CursorPlanUsage( + enabled: true, + used: 1500, + limit: 5000, + remaining: 3500, + breakdown: nil, + autoPercentUsed: nil, + apiPercentUsed: nil, + totalPercentUsed: 30.0), + onDemand: nil, + overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), + teamUsage: CursorTeamUsage( + onDemand: nil, + pooled: CursorPooledUsage( + enabled: true, + used: 12_725_135, + limit: 28_122_000, + remaining: 15_396_865))), + userInfo: nil, + rawJSON: nil) + + #expect(snapshot.planPercentUsed == 30.0) + #expect(snapshot.planUsedUSD == 15.0) + #expect(snapshot.planLimitUSD == 50.0) + } +} diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index defb67661..e34277f9f 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -96,55 +96,6 @@ struct CursorStatusProbeTests { #expect(summary.individualUsage?.plan?.totalPercentUsed == 50.0) } - @Test - func `parses enterprise overall and pooled usage summary`() throws { - // Live Cursor Enterprise payload (sanitized). The Pro/Hobby `plan` block is absent; - // instead Cursor reports `individualUsage.overall` (personal cap) and `teamUsage.pooled` - // (shared team pool). Both blocks use cents like the existing `plan` block. - let json = """ - { - "billingCycleStart": "2026-04-01T00:00:00.000Z", - "billingCycleEnd": "2026-05-01T00:00:00.000Z", - "membershipType": "enterprise", - "limitType": "team", - "isUnlimited": false, - "individualUsage": { - "overall": { - "enabled": true, - "used": 7384, - "limit": 10000, - "remaining": 2616 - } - }, - "teamUsage": { - "onDemand": { - "enabled": true, - "used": 0, - "limit": null, - "remaining": null - }, - "pooled": { - "enabled": true, - "used": 12725135, - "limit": 28122000, - "remaining": 15396865 - } - } - } - """ - let data = try #require(json.data(using: .utf8)) - let summary = try JSONDecoder().decode(CursorUsageSummary.self, from: data) - - #expect(summary.membershipType == "enterprise") - #expect(summary.limitType == "team") - #expect(summary.individualUsage?.plan == nil) - #expect(summary.individualUsage?.overall?.used == 7384) - #expect(summary.individualUsage?.overall?.limit == 10000) - #expect(summary.individualUsage?.overall?.remaining == 2616) - #expect(summary.teamUsage?.pooled?.used == 12_725_135) - #expect(summary.teamUsage?.pooled?.limit == 28_122_000) - } - // MARK: - User Info Parsing @Test @@ -366,124 +317,6 @@ struct CursorStatusProbeTests { #expect(snapshot.toUsageSnapshot().primary?.remainingPercent == 99.55897435897436) } - @Test - func `enterprise overall drives headline percent and dollars`() throws { - // Regression: Cursor Enterprise/Team accounts ship `individualUsage.overall` instead of - // `individualUsage.plan`. Without a model for `overall`, the parser used to report 0% - // (i.e. the menu showed "100% remaining"). The personal cap must take precedence over - // any team pool, and USD figures must reflect the same source. - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: "2026-04-01T00:00:00.000Z", - billingCycleEnd: "2026-05-01T00:00:00.000Z", - membershipType: "enterprise", - limitType: "team", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: CursorIndividualUsage( - plan: nil, - onDemand: nil, - overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), - teamUsage: CursorTeamUsage( - onDemand: CursorOnDemandUsage(enabled: true, used: 0, limit: nil, remaining: nil), - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // Headline: $73.84 / $100 → 73.84% (matches Cursor's own dashboard). - // Allow a tiny tolerance for floating-point division (7384/10000 * 100 ≈ 73.83999…). - #expect(abs(snapshot.planPercentUsed - 73.84) < 0.0001) - #expect(snapshot.planUsedUSD == 73.84) - #expect(snapshot.planLimitUSD == 100.0) - // Lane percents stay nil because Cursor doesn't ship Auto/API breakdown for Enterprise overall. - #expect(snapshot.autoPercentUsed == nil) - #expect(snapshot.apiPercentUsed == nil) - // Headline must NOT pick up the team pool's 45.25% — personal cap wins. - let primaryPercent = try #require(snapshot.toUsageSnapshot().primary?.usedPercent) - #expect(abs(primaryPercent - 73.84) < 0.0001) - } - - @Test - func `enterprise pooled fallback used when no individual data`() { - // When Cursor only reports a shared team pool (no `plan`, no `overall`) we should still surface - // a non-zero headline so the menu reflects pool consumption rather than appearing "all clear". - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: nil, - billingCycleEnd: nil, - membershipType: "enterprise", - limitType: "team", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: nil, - teamUsage: CursorTeamUsage( - onDemand: nil, - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // 12_725_135 / 28_122_000 ≈ 45.2497...% — accept anything in (45.0, 45.5) to keep the - // assertion robust to floating-point precision. - #expect(snapshot.planPercentUsed > 45.0) - #expect(snapshot.planPercentUsed < 45.5) - #expect(snapshot.planUsedUSD == 127_251.35) - #expect(snapshot.planLimitUSD == 281_220.0) - } - - @Test - func `existing plan block still wins over overall and pooled`() { - // Guard against future drift: when Cursor sends both legacy `plan` and the newer `overall` - // blocks, the existing percent precedence must remain intact. - let snapshot = CursorStatusProbe(browserDetection: BrowserDetection(cacheTTL: 0)) - .parseUsageSummary( - CursorUsageSummary( - billingCycleStart: nil, - billingCycleEnd: nil, - membershipType: "pro", - limitType: "user", - isUnlimited: false, - autoModelSelectedDisplayMessage: nil, - namedModelSelectedDisplayMessage: nil, - individualUsage: CursorIndividualUsage( - plan: CursorPlanUsage( - enabled: true, - used: 1500, - limit: 5000, - remaining: 3500, - breakdown: nil, - autoPercentUsed: nil, - apiPercentUsed: nil, - totalPercentUsed: 30.0), - onDemand: nil, - overall: CursorOverallUsage(enabled: true, used: 7384, limit: 10000, remaining: 2616)), - teamUsage: CursorTeamUsage( - onDemand: nil, - pooled: CursorPooledUsage( - enabled: true, - used: 12_725_135, - limit: 28_122_000, - remaining: 15_396_865))), - userInfo: nil, - rawJSON: nil) - - // `plan.totalPercentUsed` wins; `overall` and `pooled` are ignored when `plan` is present. - #expect(snapshot.planPercentUsed == 30.0) - #expect(snapshot.planUsedUSD == 15.0) - #expect(snapshot.planLimitUSD == 50.0) - } - @Test func `converts snapshot to usage snapshot`() { let snapshot = CursorStatusSnapshot( From 3ce6e2ac96b8f4abdddbfbf35acba317fc4f8780 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 30 Apr 2026 13:39:28 +0530 Subject: [PATCH 016/314] Update Cursor Enterprise changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299b68f27..33cc13935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! ### Fixes +- Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00! - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! ## 0.23 — 2026-04-26 From cbd59562addcbcce55e4bb086ad2b3577470b48c Mon Sep 17 00:00:00 2001 From: xiaoqianWX Date: Fri, 1 May 2026 05:15:06 +0800 Subject: [PATCH 017/314] Improve OpenAI dashboard refresh --- .../Codex/CodexConsumerProjection.swift | 26 +- .../StatusItemController+HostedSubmenus.swift | 3 +- .../CodexBar/StatusItemController+Menu.swift | 4 +- .../UsageBreakdownChartMenuView.swift | 2 +- .../CodexBar/UsageStore+HistoricalPace.swift | 5 +- Sources/CodexBar/UsageStore+OpenAIWeb.swift | 72 ++++- .../CodexBarCore/OpenAIDashboardModels.swift | 33 +- .../OpenAIWeb/OpenAIDashboardFetcher.swift | 296 ++++++++++++++++-- .../OpenAIWeb/OpenAIDashboardParser.swift | 50 ++- .../OpenAIDashboardScrapeScript.swift | 232 ++++++++++---- .../OpenAIDashboardWebViewCache.swift | 24 +- .../CodexManagedOpenAIWebRefreshTests.swift | 64 ++++ .../CodexUserFacingErrorTests.swift | 22 ++ ...enAIDashboardFetcherCreditsWaitTests.swift | 77 +++++ .../OpenAIDashboardModelsTests.swift | 94 ++++++ .../OpenAIDashboardParserTests.swift | 31 ++ 16 files changed, 924 insertions(+), 111 deletions(-) create mode 100644 Tests/CodexBarTests/OpenAIDashboardModelsTests.swift diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 15d2a63b9..9e49fb8b9 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -24,6 +24,15 @@ struct CodexUIErrorMapper { return "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again." } + if self.looksOpenAIWebTimeout(lower: lower) { + return "OpenAI web refresh timed out. Refresh OpenAI cookies and try again." + } + + if self.looksOpenAIWebNetworkError(lower: lower) { + return "OpenAI web refresh hit a network error. " + + "Check your connection, then refresh OpenAI cookies and try again." + } + if self.looksInternalTransport(lower: lower) { return "Codex usage is temporarily unavailable. Try refreshing." } @@ -60,6 +69,10 @@ struct CodexUIErrorMapper { || lower.contains("codex credits are still loading") || lower.contains("codex account changed; importing browser cookies") || lower.contains("codex session expired. sign in again.") + || lower.contains("openai web refresh timed out. refresh openai cookies and try again.") + || lower.contains( + "openai web refresh hit a network error. " + + "check your connection, then refresh openai cookies and try again.") || lower.contains("codex usage is temporarily unavailable. try refreshing.") } @@ -84,6 +97,15 @@ struct CodexUIErrorMapper { || lower.contains("get http://") || lower.contains("returned invalid data") } + + private static func looksOpenAIWebTimeout(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + && (lower.contains("timed out") || lower.contains("error -1001")) + } + + private static func looksOpenAIWebNetworkError(lower: String) -> Bool { + lower.contains("nsurlerrordomain") + } } struct CodexConsumerProjection { @@ -194,10 +216,12 @@ struct CodexConsumerProjection { [] } + let displayableUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) let canShowBuyCredits = surface == .liveCard let hasUsageBreakdown = surface == .liveCard && dashboardVisibility == .attached - && !(dashboard?.usageBreakdown ?? []).isEmpty + && !displayableUsageBreakdown.isEmpty let hasCreditsHistory = surface == .liveCard && dashboardVisibility == .attached && !(dashboard?.dailyBreakdown ?? []).isEmpty diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index c716636b2..4334b9b32 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -62,7 +62,8 @@ extension StatusItemController { @discardableResult func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) guard !breakdown.isEmpty else { return false } if !Self.menuCardRenderingEnabled { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f3c9247c3..4537ec451 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1295,7 +1295,9 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - guard !(self.store.openAIDashboard?.usageBreakdown ?? []).isEmpty else { return nil } + let breakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: self.store.openAIDashboard?.usageBreakdown ?? []) + guard !breakdown.isEmpty else { return nil } return self.makeHostedSubviewPlaceholderMenu(chartID: Self.usageBreakdownChartID) } diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ee0ccaa65..5c490cab0 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -146,7 +146,7 @@ struct UsageBreakdownChartMenuView: View { private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { - let sorted = breakdown + let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index cc26cd0bf..0d6666c02 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -98,7 +98,9 @@ extension UsageStore { { guard self.settings.historicalTrackingEnabled else { return } guard authorityDecision.allowedEffects.contains(.historicalBackfill) else { return } - guard !dashboard.usageBreakdown.isEmpty else { return } + let usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard.usageBreakdown) + guard !usageBreakdown.isEmpty else { return } let codexSnapshot = self.snapshots[.codex] let ownership = self.codexOwnershipContext(preferredEmail: attachedAccountEmail) @@ -128,7 +130,6 @@ extension UsageStore { } let historyStore = self.historicalUsageHistoryStore - let usageBreakdown = dashboard.usageBreakdown Task.detached(priority: .utility) { [weak self] in _ = await historyStore.backfillCodexWeeklyFromUsageBreakdown( usageBreakdown, diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index 53b6e2016..32a4de7a7 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -29,7 +29,7 @@ extension UsageStore { } private static let openAIWebRefreshMultiplier: TimeInterval = 5 - private static let openAIWebPrimaryFetchTimeout: TimeInterval = 15 + private static let openAIWebPrimaryFetchTimeout: TimeInterval = 25 private static let openAIWebRetryFetchTimeout: TimeInterval = 8 private static let openAIWebPostImportFetchTimeout: TimeInterval = 25 @@ -494,6 +494,13 @@ extension UsageStore { latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch { + if Self.isOpenAIDashboardTimeout(error) { + await self.retryOpenAIDashboardAfterTimeout( + context: context, + latestCookieImportStatus: &latestCookieImportStatus, + logger: log) + return + } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: context.targetEmail, @@ -506,6 +513,56 @@ extension UsageStore { } } + private func retryOpenAIDashboardAfterTimeout( + context: OpenAIDashboardRefreshContext, + latestCookieImportStatus: inout String?, + logger: @escaping (String) -> Void) async + { + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, + allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) + var effectiveEmail = targetEmail + let imported = await self.importOpenAIDashboardCookiesIfNeeded( + targetEmail: targetEmail, + force: true, + preferCachedCookieHeader: true) + latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() + if await self.abortOpenAIDashboardRetryAfterImportFailure( + importedEmail: imported, + targetEmail: targetEmail, + expectedGuard: context.expectedGuard, + cookieImportStatus: latestCookieImportStatus, + refreshTaskToken: context.refreshTaskToken) + { + return + } + if let imported { + effectiveEmail = imported + } + do { + let dash = try await self.loadLatestOpenAIDashboard( + accountEmail: effectiveEmail, + logger: logger, + timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + await self.applyOpenAIDashboard( + dash, + targetEmail: effectiveEmail, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + allowCodexUsageBackfill: context.allowCodexUsageBackfill) + } catch { + let message = self.preferredOpenAIDashboardFailureMessage( + error: error, + targetEmail: targetEmail, + cookieImportStatus: latestCookieImportStatus) + await self.applyOpenAIDashboardFailure( + message: message, + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: targetEmail) + } + } + private func retryOpenAIDashboardAfterNoData( body: String, context: OpenAIDashboardRefreshContext, @@ -752,6 +809,11 @@ extension UsageStore { return error.localizedDescription } + private static func isOpenAIDashboardTimeout(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorTimedOut + } + private func abortOpenAIDashboardRetryAfterImportFailure( importedEmail: String?, targetEmail: String?, @@ -855,7 +917,11 @@ extension UsageStore { return false } - func importOpenAIDashboardCookiesIfNeeded(targetEmail: String?, force: Bool) async -> String? { + func importOpenAIDashboardCookiesIfNeeded( + targetEmail: String?, + force: Bool, + preferCachedCookieHeader: Bool? = nil) async -> String? + { if await self.openAIWebCookieImportShouldFailClosed() { return nil } @@ -921,7 +987,7 @@ extension UsageStore { result = try await importer.importBestCookies( intoAccountEmail: normalizedTarget, allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: !force, + preferCachedCookieHeader: preferCachedCookieHeader ?? !force, cacheScope: cacheScope, logger: log) case .off: diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 702f1582a..7d776a240 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -36,7 +36,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.codeReviewLimit = codeReviewLimit self.creditEvents = creditEvents self.dailyBreakdown = dailyBreakdown - self.usageBreakdown = usageBreakdown + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: usageBreakdown) self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit @@ -72,9 +72,11 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { [OpenAIDashboardDailyBreakdown].self, forKey: .dailyBreakdown) ?? Self.makeDailyBreakdown(from: self.creditEvents, maxDays: 30) - self.usageBreakdown = try container.decodeIfPresent( + let decodedUsageBreakdown = try container.decodeIfPresent( [OpenAIDashboardDailyBreakdown].self, forKey: .usageBreakdown) ?? [] + self.usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: decodedUsageBreakdown) self.creditsPurchaseURL = try container.decodeIfPresent(String.self, forKey: .creditsPurchaseURL) self.primaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .primaryLimit) self.secondaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .secondaryLimit) @@ -145,6 +147,33 @@ public struct OpenAIDashboardDailyBreakdown: Codable, Equatable, Sendable { self.services = services self.totalCreditsUsed = totalCreditsUsed } + + public static func isSkillUsageService(_ service: String) -> Bool { + service + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .hasPrefix("skillusage:") + } + + public static func removingSkillUsageServices( + from breakdown: [OpenAIDashboardDailyBreakdown]) + -> [OpenAIDashboardDailyBreakdown] + { + breakdown.compactMap { day in + guard !day.services.isEmpty else { + return day.totalCreditsUsed > 0 ? day : nil + } + + let services = day.services.filter { !self.isSkillUsageService($0.service) } + guard !services.isEmpty else { return nil } + + let total = services.reduce(0) { $0 + $1.creditsUsed } + return OpenAIDashboardDailyBreakdown( + day: day.day, + services: services, + totalCreditsUsed: total) + } + } } public struct OpenAIDashboardServiceUsage: Codable, Equatable, Sendable { diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 71ab76628..9877948fb 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -20,6 +20,8 @@ public struct OpenAIDashboardFetcher { } private let usageURL = URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")! + private nonisolated static let dashboardAcceptLanguage = "en-US,en;q=0.9" + private nonisolated static let dashboardUsageAPIURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! public init() {} @@ -43,6 +45,7 @@ public struct OpenAIDashboardFetcher { } private struct DashboardSnapshotComponents { + let signedInEmail: String? let scrape: ScrapeResult let codeReview: Double? let codeReviewLimit: RateWindow? @@ -58,7 +61,7 @@ public struct OpenAIDashboardFetcher { -> OpenAIDashboardSnapshot { OpenAIDashboardSnapshot( - signedInEmail: components.scrape.signedInEmail, + signedInEmail: components.signedInEmail, codeReviewRemainingPercent: components.codeReview, codeReviewLimit: components.codeReviewLimit, creditEvents: components.events, @@ -72,6 +75,17 @@ public struct OpenAIDashboardFetcher { updatedAt: Date()) } + struct DashboardAPIData: Sendable { + let primaryLimit: RateWindow? + let secondaryLimit: RateWindow? + let creditsRemaining: Double? + let accountPlan: String? + + var hasUsageData: Bool { + self.primaryLimit != nil || self.secondaryLimit != nil || self.creditsRemaining != nil + } + } + public struct ProbeResult: Sendable { public let href: String? public let loginRequired: Bool @@ -134,6 +148,12 @@ public struct OpenAIDashboardFetcher { timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) + let preflight = await Self.fetchDashboardAPIPreflight( + websiteDataStore: websiteDataStore, + logger: { logger?($0) }) + let apiData = preflight.apiData + let verifiedSignedInEmail = preflight.verifiedSignedInEmail + let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, @@ -176,41 +196,49 @@ public struct OpenAIDashboardFetcher { // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { - _ = webView.load(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } - if scrape.loginRequired { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.loginRequired - } - - if scrape.cloudflareInterstitial { - if debugDumpHTML, let html = scrape.bodyHTML { - Self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: log) - } - throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") - } + try Self.throwIfBlockingScrapeState(scrape, debugDumpHTML: debugDumpHTML, logger: log) let bodyText = scrape.bodyText ?? "" let codeReview = OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: bodyText) let events = OpenAIDashboardParser.parseCreditEvents(rows: scrape.rows) let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: events, maxDays: 30) let usageBreakdown = scrape.usageBreakdown - let rateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let parsedRateLimits = OpenAIDashboardParser.parseRateLimits(bodyText: bodyText) + let rateLimits = ( + primary: apiData?.primaryLimit ?? parsedRateLimits.primary, + secondary: apiData?.secondaryLimit ?? parsedRateLimits.secondary) let codeReviewLimit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: bodyText) - let creditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) - let accountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) + let parsedCreditsRemaining = OpenAIDashboardParser.parseCreditsRemaining(bodyText: bodyText) + let creditsRemaining = apiData?.creditsRemaining ?? parsedCreditsRemaining + let parsedAccountPlan = scrape.bodyHTML.flatMap(OpenAIDashboardParser.parsePlanFromHTML) + let accountPlan = parsedAccountPlan + ?? apiData?.accountPlan + let hasParsedUsageLimits = parsedRateLimits.primary != nil || parsedRateLimits.secondary != nil let hasUsageLimits = rateLimits.primary != nil || rateLimits.secondary != nil + let signedInEmail = Self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail) + let hasDashboardPageData = Self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasParsedUsageLimits, + creditsRemaining: parsedCreditsRemaining) + let hasDashboardPageSignal = Self.hasAnyDashboardSignal( + hasReturnableData: hasDashboardPageData, + creditsHeaderPresent: scrape.creditsHeaderPresent) + let hasReturnableData = Self.hasReturnableDashboardData( + codeReview: codeReview, + events: events, + usageBreakdown: usageBreakdown, + hasUsageLimits: hasUsageLimits, + creditsRemaining: creditsRemaining) if codeReview != nil, codeReviewFirstSeenAt == nil { codeReviewFirstSeenAt = Date() } - if anyDashboardSignalAt == nil, - codeReview != nil || !usageBreakdown.isEmpty || scrape.creditsHeaderPresent || - hasUsageLimits || creditsRemaining != nil - { + if anyDashboardSignalAt == nil, hasDashboardPageSignal { anyDashboardSignalAt = Date() } if codeReview != nil, usageBreakdown.isEmpty, @@ -225,7 +253,8 @@ public struct OpenAIDashboardFetcher { log("credits purchase url: \(purchaseURL)") } if events.isEmpty, - codeReview != nil || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil + hasReturnableData, + hasDashboardPageSignal { log( "credits header present=\(scrape.creditsHeaderPresent) " + @@ -255,9 +284,7 @@ public struct OpenAIDashboardFetcher { } } - if codeReview != nil || !events.isEmpty || !usageBreakdown - .isEmpty || hasUsageLimits || creditsRemaining != nil - { + if hasReturnableData, hasDashboardPageSignal { // The usage breakdown chart is hydrated asynchronously. When code review is already present, // give it a moment to populate so the menu can show it. if codeReview != nil, usageBreakdown.isEmpty { @@ -268,6 +295,7 @@ public struct OpenAIDashboardFetcher { } } return Self.makeDashboardSnapshot(.init( + signedInEmail: signedInEmail, scrape: scrape, codeReview: codeReview, codeReviewLimit: codeReviewLimit, @@ -345,6 +373,23 @@ public struct OpenAIDashboardFetcher { return false } + nonisolated static func hasReturnableDashboardData( + codeReview: Double?, + events: [CreditEvent], + usageBreakdown: [OpenAIDashboardDailyBreakdown], + hasUsageLimits: Bool, + creditsRemaining: Double?) -> Bool + { + codeReview != nil || !events.isEmpty || !usageBreakdown.isEmpty || hasUsageLimits || creditsRemaining != nil + } + + nonisolated static func hasAnyDashboardSignal( + hasReturnableData: Bool, + creditsHeaderPresent: Bool) -> Bool + { + hasReturnableData || creditsHeaderPresent + } + public func clearSessionData(accountEmail: String?) async { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) OpenAIDashboardWebViewCache.shared.evict(websiteDataStore: store) @@ -389,7 +434,7 @@ public struct OpenAIDashboardFetcher { if let href = scrape.href, !Self.isUsageRoute(href) { usageRouteSeenAt = nil dashboardSignalSeenAt = nil - _ = webView.load(URLRequest(url: self.usageURL)) + _ = webView.load(Self.usageURLRequest(url: self.usageURL)) try? await Task.sleep(for: .milliseconds(500)) continue } @@ -508,7 +553,8 @@ public struct OpenAIDashboardFetcher { if let raw = dict["usageBreakdownJSON"] as? String, !raw.isEmpty { do { let decoder = JSONDecoder() - usageBreakdown = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + let decoded = try decoder.decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + usageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: decoded) } catch { // Best-effort parse; ignore errors to avoid blocking other dashboard data. usageBreakdown = [] @@ -550,6 +596,26 @@ public struct OpenAIDashboardFetcher { didScrollToCredits: (dict["didScrollToCredits"] as? Bool) ?? false) } + private static func throwIfBlockingScrapeState( + _ scrape: ScrapeResult, + debugDumpHTML: Bool, + logger: (String) -> Void) throws + { + if scrape.loginRequired { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.loginRequired + } + + if scrape.cloudflareInterstitial { + if debugDumpHTML, let html = scrape.bodyHTML { + self.writeDebugArtifacts(html: html, bodyText: scrape.bodyText, logger: logger) + } + throw FetchError.noDashboardData(body: "Cloudflare challenge detected in WebView.") + } + } + private func makeWebView( websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, @@ -587,6 +653,180 @@ public struct OpenAIDashboardFetcher { || path.hasSuffix("codex/cloud/settings/analytics") } + nonisolated static func usageURLRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + return request + } + + nonisolated static func dashboardUsageAPIRequest(cookieHeader: String) -> URLRequest { + var request = URLRequest(url: Self.dashboardUsageAPIURL) + request.httpMethod = "GET" + request.timeoutInterval = 4 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardIdentityAPIRequest(url: URL, cookieHeader: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 2 + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(Self.dashboardAcceptLanguage, forHTTPHeaderField: "Accept-Language") + request.setValue("CodexBar", forHTTPHeaderField: "User-Agent") + return request + } + + nonisolated static func dashboardAPIData(from response: CodexUsageResponse) -> DashboardAPIData { + DashboardAPIData( + primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow), + secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow), + creditsRemaining: response.credits?.balance, + accountPlan: response.planType?.rawValue) + } + + private static func fetchDashboardAPIPreflight( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) + async -> (apiData: DashboardAPIData?, verifiedSignedInEmail: String?) + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + let apiData = await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + let verifiedEmail: String? = if apiData?.hasUsageData == true { + await self.fetchSignedInEmailFromAPI(cookieHeader: cookieHeader, logger: logger) + } else { + nil + } + + if apiData?.hasUsageData == true, verifiedEmail != nil { + logger("usage api supplied verified dashboard data; continuing WebView scrape") + } + return (apiData, verifiedEmail) + } + + private static func fetchDashboardUsageAPI( + websiteDataStore: WKWebsiteDataStore, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + let cookieHeader = await self.chatGPTCookieHeader(in: websiteDataStore) + return await self.fetchDashboardUsageAPI(cookieHeader: cookieHeader, logger: logger) + } + + private static func fetchDashboardUsageAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> DashboardAPIData? + { + guard !cookieHeader.isEmpty else { return nil } + + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardUsageAPIRequest(cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("usage api status=\(status)") + guard status >= 200, status < 300 else { return nil } + let decoded = try JSONDecoder().decode(CodexUsageResponse.self, from: data) + let result = self.dashboardAPIData(from: decoded) + if result.hasUsageData { + logger("usage api supplied language-independent rate/credit data") + } + return result + } catch { + logger("usage api unavailable: \(error.localizedDescription)") + return nil + } + } + + private static func fetchSignedInEmailFromAPI( + cookieHeader: String, + logger: @escaping (String) -> Void) async -> String? + { + guard !cookieHeader.isEmpty else { return nil } + + let endpoints = [ + URL(string: "https://chatgpt.com/backend-api/me"), + URL(string: "https://chatgpt.com/api/auth/session"), + ].compactMap(\.self) + + for url in endpoints { + do { + let (data, response) = try await URLSession.shared.data( + for: self.dashboardIdentityAPIRequest(url: url, cookieHeader: cookieHeader)) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger("identity api \(url.path) status=\(status)") + guard status >= 200, status < 300 else { continue } + if let email = self.findFirstEmail(inJSONData: data) { + return email.trimmingCharacters(in: .whitespacesAndNewlines) + } + } catch { + logger("identity api \(url.path) unavailable: \(error.localizedDescription)") + } + } + + return nil + } + + private static func chatGPTCookieHeader(in store: WKWebsiteDataStore) async -> String { + let cookies = await withCheckedContinuation { continuation in + store.httpCookieStore.getAllCookies { cookies in + continuation.resume(returning: cookies) + } + } + + return cookies + .filter { $0.domain.lowercased().contains("chatgpt.com") } + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + } + + nonisolated static func findFirstEmail(inJSONData data: Data) -> String? { + guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil } + var queue: [Any] = [json] + var seen = 0 + while !queue.isEmpty, seen < 2000 { + let current = queue.removeFirst() + seen += 1 + if let string = current as? String, string.contains("@") { + return string + } + if let dictionary = current as? [String: Any] { + for (key, value) in dictionary { + if key.lowercased() == "email", + let string = value as? String, + string.contains("@") + { + return string + } + queue.append(value) + } + } else if let array = current as? [Any] { + queue.append(contentsOf: array) + } + } + return nil + } + + private nonisolated static func rateWindow(from window: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let window else { return nil } + let resetDate = Date(timeIntervalSince1970: TimeInterval(window.resetAt)) + return RateWindow( + usedPercent: Double(window.usedPercent), + windowMinutes: window.limitWindowSeconds / 60, + resetsAt: resetDate, + resetDescription: UsageFormatter.resetDescription(from: resetDate)) + } + + private nonisolated static func firstNonEmpty(_ candidates: String?...) -> String? { + for candidate in candidates { + let trimmed = candidate?.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed?.isEmpty == false { return trimmed } + } + return nil + } + private static func writeDebugArtifacts(html: String, bodyText: String?, logger: (String) -> Void) { let stamp = Int(Date().timeIntervalSince1970) let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift index d9be82bbd..bb1872286 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardParser.swift @@ -153,11 +153,49 @@ public enum OpenAIDashboardParser { } private static func parseCreditsUsed(_ text: String) -> Double { - let cleaned = text - .replacingOccurrences(of: ",", with: "") - .replacingOccurrences(of: "credits", with: "", options: .caseInsensitive) - .trimmingCharacters(in: .whitespacesAndNewlines) - return Double(cleaned) ?? 0 + guard let raw = self.firstNumberToken(in: text) else { return 0 } + let token = raw + .replacingOccurrences(of: "\u{00A0}", with: "") + .replacingOccurrences(of: "\u{202F}", with: "") + .replacingOccurrences(of: " ", with: "") + let hasComma = token.contains(",") + let hasDot = token.contains(".") + if hasComma, hasDot { + return TextParsing.firstNumber(pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, text: token) ?? 0 + } + if hasComma { + if self.usesLocalizedDecimalCommaCreditLabel(text) { + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + if token.range(of: #"^\d{1,3}(,\d{3})+$"#, options: .regularExpression) != nil { + return Double(token.replacingOccurrences(of: ",", with: "")) ?? 0 + } + return Double(token.replacingOccurrences(of: ",", with: ".")) ?? 0 + } + return Double(token) ?? 0 + } + + private static func usesLocalizedDecimalCommaCreditLabel(_ text: String) -> Bool { + text + .lowercased() + .contains("crédit") + } + + private static func firstNumberToken(in text: String) -> String? { + guard let regex = try? NSRegularExpression( + pattern: #"([0-9][0-9.,\s\p{Zs}]*)"#, + options: []) + else { + return nil + } + let range = NSRange(text.startIndex..= 2, + let tokenRange = Range(match.range(at: 1), in: text) + else { + return nil + } + return String(text[tokenRange]) } // MARK: - Private @@ -294,6 +332,7 @@ public enum OpenAIDashboardParser { private static func isFiveHourLimitLine(_ line: String) -> Bool { let lower = line.lowercased() if lower.contains("5h") { return true } + if lower.range(of: #"\b5\s*h\b"#, options: .regularExpression) != nil { return true } if lower.contains("5-hour") { return true } if lower.contains("5 hour") { return true } return false @@ -305,6 +344,7 @@ public enum OpenAIDashboardParser { if lower.contains("7-day") { return true } if lower.contains("7 day") { return true } if lower.contains("7d") { return true } + if lower.range(of: #"\b7\s*d\b"#, options: .regularExpression) != nil { return true } return false } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift index 06d7dea61..54129eba5 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardScrapeScript.swift @@ -227,9 +227,14 @@ let openAIDashboardScrapeScript = """ } return null; }; + const isSkillUsageServiceKey = (raw) => { + const key = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return key.startsWith('skillusage:'); + }; const displayNameForUsageServiceKey = (raw) => { const key = raw === null || raw === undefined ? '' : String(raw).trim(); if (!key) return key; + if (isSkillUsageServiceKey(key)) return null; if (key.toUpperCase() === key && key.length <= 6) return key; const lower = key.toLowerCase(); if (lower === 'cli') return 'CLI'; @@ -237,20 +242,46 @@ let openAIDashboardScrapeScript = """ const words = lower.replace(/[_-]+/g, ' ').split(' ').filter(Boolean); return words.map(w => w.length <= 2 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(' '); }; - const usageBreakdownJSON = (() => { - try { - if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; - - const sections = Array.from(document.querySelectorAll('section')); - const usageSection = sections.find(s => { - const h2 = s.querySelector('h2'); - return h2 && textOf(h2).toLowerCase().startsWith('usage breakdown'); - }); - if (!usageSection) return null; - - const legendMap = {}; + const isLikelyCodexUsageService = (raw) => { + const service = raw === null || raw === undefined ? '' : String(raw).trim().toLowerCase(); + return ( + service === 'cli' || + service === 'desktop' || + service === 'desktop app' || + service === 'vscode' || + service === 'vs code' || + service === 'unknown' || + (service.includes('github') && service.includes('review')) + ); + }; + const usageChartRootForPath = (path) => { + if (!path || !path.closest) return null; + return ( + path.closest('.recharts-wrapper') || + path.closest('svg.recharts-surface') || + path.closest('section') || + path.parentElement || + null + ); + }; + const uniqueUsageChartRoots = (paths) => { + const roots = []; + for (const path of paths) { + const root = usageChartRootForPath(path); + if (root && !roots.includes(root)) roots.push(root); + } + return roots; + }; + const legendMapForUsageChartRoot = (root) => { + const legendMap = {}; + const scopes = [ + root, + root && root.parentElement, + root && root.closest ? root.closest('section') : null + ].filter(Boolean); + for (const scope of scopes) { try { - const legendItems = Array.from(usageSection.querySelectorAll('div[title]')); + const legendItems = Array.from(scope.querySelectorAll('div[title]')); for (const item of legendItems) { const title = item.getAttribute('title') ? String(item.getAttribute('title')).trim() : ''; const square = item.querySelector('div[style*=\"background-color\"]'); @@ -261,11 +292,90 @@ let openAIDashboardScrapeScript = """ if (title && hex) legendMap[hex] = title; } } catch {} + if (Object.keys(legendMap).length > 0) break; + } + return legendMap; + }; + const parseUsageBreakdownFromChartPaths = (paths, legendMap) => { + const totalsByDay = {}; // day -> service -> value + const addValue = (day, service, value) => { + if (!day || !service || isSkillUsageServiceKey(service)) return false; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return false; + if (!totalsByDay[day]) totalsByDay[day] = {}; + totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; + return true; + }; + let pointCount = 0; + for (const path of paths) { + const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; + if (!meta) continue; + + const payload = meta.payload || null; + const day = dayKeyFromPayload(payload); + if (!day) continue; + + const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; + if (valuesObj) { + for (const [k, v] of Object.entries(valuesObj)) { + const service = displayNameForUsageServiceKey(k); + if (addValue(day, service, v)) pointCount++; + } + continue; + } + + let value = null; + if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; + if (value === null && typeof meta.value === 'string') { + const v = parseFloat(meta.value.replace(/,/g, '')); + if (Number.isFinite(v)) value = v; + } + if (value === null) continue; - const totalsByDay = {}; // day -> service -> value - const paths = Array.from(usageSection.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); + const fill = parseHexColor(meta.fill || path.getAttribute('fill')); + const service = + (fill && legendMap[fill]) || + (typeof meta.name === 'string' && meta.name) || + null; + if (addValue(day, service, value)) pointCount++; + } + + const dayKeys = Object.keys(totalsByDay) + .filter(day => Object.keys(totalsByDay[day] || {}).length > 0) + .sort((a, b) => b.localeCompare(a)) + .slice(0, 30); + const breakdown = dayKeys.map(day => { + const servicesMap = totalsByDay[day] || {}; + const services = Object.keys(servicesMap).map(service => ({ + service, + creditsUsed: servicesMap[service] + })).sort((a, b) => { + if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); + return b.creditsUsed - a.creditsUsed; + }); + const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); + return { day, services, totalCreditsUsed }; + }); + const services = Array.from(new Set(breakdown.flatMap(day => day.services.map(service => service.service)))); + const totalCreditsUsed = breakdown.reduce((sum, day) => sum + (Number(day.totalCreditsUsed) || 0), 0); + const likelyCodexServiceCount = services.filter(isLikelyCodexUsageService).length; + return { + breakdown, + pointCount, + services, + totalCreditsUsed, + likelyCodexServiceCount, + score: likelyCodexServiceCount * 1000 + services.length * 100 + pointCount + totalCreditsUsed / 1000 + }; + }; + const usageBreakdownJSON = (() => { + try { + if (window.__codexbarUsageBreakdownJSON) return window.__codexbarUsageBreakdownJSON; + + const paths = Array.from(document.querySelectorAll('g.recharts-bar-rectangle path.recharts-rectangle')); let debug = { pathCount: paths.length, + chartCount: 0, + candidateSummaries: [], sampleReactKeys: null, sampleMetaKeys: null, samplePayloadKeys: null, @@ -292,59 +402,29 @@ let openAIDashboardScrapeScript = """ } } } catch {} - for (const path of paths) { - const meta = barMetaFromElement(path) || barMetaFromElement(path.parentElement) || null; - if (!meta) continue; - - const payload = meta.payload || null; - const day = dayKeyFromPayload(payload); - if (!day) continue; - - const valuesObj = (payload && payload.values && typeof payload.values === 'object') ? payload.values : null; - if (valuesObj) { - if (!totalsByDay[day]) totalsByDay[day] = {}; - for (const [k, v] of Object.entries(valuesObj)) { - if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) continue; - const service = displayNameForUsageServiceKey(k); - if (!service) continue; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + v; - } - continue; - } - - let value = null; - if (typeof meta.value === 'number' && Number.isFinite(meta.value)) value = meta.value; - if (value === null && typeof meta.value === 'string') { - const v = parseFloat(meta.value.replace(/,/g, '')); - if (Number.isFinite(v)) value = v; - } - if (value === null) continue; - - const fill = parseHexColor(meta.fill || path.getAttribute('fill')); - const service = - (fill && legendMap[fill]) || - (typeof meta.name === 'string' && meta.name) || - null; - if (!service) continue; - - if (!totalsByDay[day]) totalsByDay[day] = {}; - totalsByDay[day][service] = (totalsByDay[day][service] || 0) + value; - } - const dayKeys = Object.keys(totalsByDay).sort((a, b) => b.localeCompare(a)).slice(0, 30); - const breakdown = dayKeys.map(day => { - const servicesMap = totalsByDay[day] || {}; - const services = Object.keys(servicesMap).map(service => ({ - service, - creditsUsed: servicesMap[service] - })).sort((a, b) => { - if (a.creditsUsed === b.creditsUsed) return a.service.localeCompare(b.service); - return b.creditsUsed - a.creditsUsed; - }); - const totalCreditsUsed = services.reduce((sum, s) => sum + (Number(s.creditsUsed) || 0), 0); - return { day, services, totalCreditsUsed }; - }); + const roots = uniqueUsageChartRoots(paths); + debug.chartCount = roots.length; + const candidates = roots.map(root => { + const chartPaths = paths.filter(path => usageChartRootForPath(path) === root); + const parsed = parseUsageBreakdownFromChartPaths(chartPaths, legendMapForUsageChartRoot(root)); + return { + root, + pathCount: chartPaths.length, + ...parsed + }; + }).filter(candidate => candidate.breakdown.length > 0); + candidates.sort((a, b) => b.score - a.score); + debug.candidateSummaries = candidates.slice(0, 6).map(candidate => ({ + pathCount: candidate.pathCount, + dayCount: candidate.breakdown.length, + pointCount: candidate.pointCount, + serviceCount: candidate.services.length, + likelyCodexServiceCount: candidate.likelyCodexServiceCount, + services: candidate.services.slice(0, 8) + })); + const breakdown = candidates[0] ? candidates[0].breakdown : []; const json = (breakdown.length > 0) ? JSON.stringify(breakdown) : null; window.__codexbarUsageBreakdownJSON = json; window.__codexbarUsageBreakdownDebug = json ? null : JSON.stringify(debug); @@ -395,6 +475,16 @@ let openAIDashboardScrapeScript = """ let didScrollToCredits = false; let rows = []; try { + const looksLikeCreditsEventRow = (cells) => { + if (!cells || cells.length < 3) return false; + const first = String(cells[0] || ''); + const amount = String(cells[2] || ''); + return /\\d{4}|\\d{1,2}[\\/.\\-]\\d{1,2}/.test(first) && /\\d/.test(amount); + }; + const allTableRows = () => Array.from(document.querySelectorAll('tbody tr')).map(tr => { + const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); + return cells; + }).filter(looksLikeCreditsEventRow); const headings = Array.from(document.querySelectorAll('h1,h2,h3')); const header = headings.find(h => textOf(h).toLowerCase() === 'credits usage history'); if (header) { @@ -411,6 +501,9 @@ let openAIDashboardScrapeScript = """ const cells = Array.from(tr.querySelectorAll('td')).map(td => textOf(td)); return cells; }).filter(r => r.length >= 3); + if (rows.length === 0) { + rows = allTableRows(); + } if (rows.length === 0 && !window.__codexbarDidScrollToCredits) { window.__codexbarDidScrollToCredits = true; // If the table is virtualized/lazy-loaded, we need to scroll to trigger rendering even if the @@ -422,6 +515,13 @@ let openAIDashboardScrapeScript = """ didScrollToCredits = true; } } else if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { + rows = allTableRows(); + if (rows.length > 0) { + creditsHeaderPresent = true; + creditsHeaderInViewport = true; + } + } + if (rows.length === 0 && !window.__codexbarDidScrollToCredits && scrollHeight > viewportHeight * 1.5) { // The credits history section often isn't part of the DOM until you scroll down. Nudge the page // once so subsequent scrapes can find the header and rows. window.__codexbarDidScrollToCredits = true; diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1a90d2816..3706bc312 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -98,6 +98,22 @@ final class OpenAIDashboardWebViewCache { } })(); """ + private let preferredLanguageScript = """ + (() => { + const define = (target, name, value) => { + try { + Object.defineProperty(target, name, { + get: () => value, + configurable: true + }); + } catch {} + }; + define(Navigator.prototype, 'language', 'en-US'); + define(Navigator.prototype, 'languages', ['en-US', 'en']); + define(navigator, 'language', 'en-US'); + define(navigator, 'languages', ['en-US', 'en']); + })(); + """ private func releaseCachedEntry(_ entry: Entry, preserveLoadedPage: Bool) { entry.isBusy = false @@ -431,6 +447,12 @@ final class OpenAIDashboardWebViewCache { private func makeWebView(websiteDataStore: WKWebsiteDataStore) -> (WKWebView, OffscreenWebViewHost) { let config = WKWebViewConfiguration() config.websiteDataStore = websiteDataStore + let userContentController = WKUserContentController() + userContentController.addUserScript(WKUserScript( + source: self.preferredLanguageScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: false)) + config.userContentController = userContentController if #available(macOS 14.0, *) { config.preferences.inactiveSchedulingPolicy = .suspend } @@ -471,7 +493,7 @@ final class OpenAIDashboardWebViewCache { webView.navigationDelegate = delegate webView.codexNavigationDelegate = delegate delegate.armTimeout(seconds: timeout) - _ = webView.load(URLRequest(url: usageURL)) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index cb8ccb90d..36f3fe902 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -111,6 +111,68 @@ struct CodexManagedOpenAIWebRefreshTests { #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } + @Test + func `navigation timeout imports cookies and retries dashboard refresh`() async { + let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-timeout-import-retry") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let importTracker = OpenAIDashboardImportCallTracker() + store._test_openAIDashboardLoaderOverride = { _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + _ = await importTracker.recordCall() + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + let refreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await importTracker.waitUntilCalls(count: 1) + await blocker.waitUntilStarted(count: 2) + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 90, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await refreshTask.value + + #expect(await blocker.startedCount() == 2) + #expect(store.openAIDashboard?.creditsRemaining == 25) + #expect(store.lastOpenAIDashboardError == nil) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async { let settings = self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -224,6 +286,8 @@ struct CodexManagedOpenAIWebRefreshTests { @Test func `post import retry timeout exceeds normal retry timeout`() { + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: false) == 25) + #expect(UsageStore.openAIWebDashboardFetchTimeout(didImportCookies: true) == 25) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: false) == 8) #expect(UsageStore.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true) == 25) } diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index be6f954e2..d265dedf4 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -74,6 +74,28 @@ struct CodexUserFacingErrorTests { "OpenAI web refresh was interrupted. Refresh OpenAI cookies and try again.") } + @Test + func `open A I web timeout becomes retry guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-timeout") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1001.)" + + #expect( + store.userFacingLastOpenAIDashboardError == + "OpenAI web refresh timed out. Refresh OpenAI cookies and try again.") + } + + @Test + func `open A I web network error becomes connection guidance`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-openai-web-network") + store.lastOpenAIDashboardError = "The operation couldn’t be completed. (NSURLErrorDomain error -1004.)" + let expected = [ + "OpenAI web refresh hit a network error.", + "Check your connection, then refresh OpenAI cookies and try again.", + ].joined(separator: " ") + + #expect(store.userFacingLastOpenAIDashboardError == expected) + } + @Test func `non codex providers keep raw errors`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-non-codex") diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index b234a2c32..d778d7807 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -216,4 +216,81 @@ struct OpenAIDashboardFetcherCreditsWaitTests { #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) } + + @Test + func `dashboard requests prefer English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) + let request = OpenAIDashboardFetcher.usageURLRequest(url: url) + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api request carries cookies and English localization`() { + let request = OpenAIDashboardFetcher.dashboardUsageAPIRequest(cookieHeader: "a=b") + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `identity api request carries cookies and English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(url: url, cookieHeader: "a=b") + + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/me") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api data maps language independent rate limits and credits`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 34, + "reset_at": 1700604800, + "limit_window_seconds": 604800 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 42.5 + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + let data = OpenAIDashboardFetcher.dashboardAPIData(from: response) + + #expect(data.primaryLimit?.usedPercent == 12) + #expect(data.primaryLimit?.windowMinutes == 300) + #expect(data.secondaryLimit?.usedPercent == 34) + #expect(data.secondaryLimit?.windowMinutes == 10080) + #expect(data.creditsRemaining == 42.5) + #expect(data.accountPlan == "pro") + #expect(data.hasUsageData) + } + + @Test + func `find first email searches nested api payloads`() { + let json = """ + { + "accounts": [ + { "profile": { "name": "Test" } }, + { "profile": { "email": "nested@example.com" } } + ] + } + """ + + #expect(OpenAIDashboardFetcher.findFirstEmail(inJSONData: Data(json.utf8)) == "nested@example.com") + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift new file mode 100644 index 000000000..b6547e14f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIDashboardModelsTests { + @Test + func `removes skill usage services from usage breakdown`() { + let breakdown = [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 7), + OpenAIDashboardServiceUsage(service: " skillusage:github:github ", creditsUsed: 2), + ], + totalCreditsUsed: 19), + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [ + OpenAIDashboardServiceUsage(service: "Skillusage:deep Research", creditsUsed: 3), + ], + totalCreditsUsed: 3), + ] + + let filtered = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) + + #expect(filtered == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + ], + totalCreditsUsed: 10), + ]) + } + + @Test + func `snapshot initializer sanitizes usage breakdown`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + OpenAIDashboardServiceUsage(service: "Skillusage:pdf Renderer", creditsUsed: 6), + ], + totalCreditsUsed: 10), + ], + creditsPurchaseURL: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + ], + totalCreditsUsed: 4), + ]) + } + + @Test + func `snapshot decoder drops empty zero usage buckets`() throws { + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [ + { "day": "2026-04-30", "services": [], "totalCreditsUsed": 0 }, + { "day": "2026-04-29", "services": [], "totalCreditsUsed": 4 } + ], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [], + totalCreditsUsed: 4), + ]) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index 3fbe44675..03c73d2f3 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -84,6 +84,17 @@ struct OpenAIDashboardParserTests { #expect(limits.secondary?.windowMinutes == 10080) } + @Test + func `parses spaced five hour limit label`() { + let body = """ + Limite 5 h + 72 % restant + """ + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + #expect(abs((limits.primary?.usedPercent ?? 0) - 28) < 0.001) + #expect(limits.primary?.windowMinutes == 300) + } + @Test func `parses plan from client bootstrap`() { let html = """ @@ -126,6 +137,26 @@ struct OpenAIDashboardParserTests { #expect(abs((events.last?.creditsUsed ?? 0) - 506.235) < 0.0001) } + @Test + func `parses credit event amount with localized credit label`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "397,205 crédits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(abs((events.first?.creditsUsed ?? 0) - 397.205) < 0.0001) + } + + @Test + func `parses credit event amount with english comma thousands`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "1,234 credits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(events.first?.creditsUsed == 1234) + } + @Test func `builds daily breakdown from events`() throws { let calendar = Calendar(identifier: .gregorian) From 737c6ca2bbfb7f971fcbd59b3cbbb167a4ab6143 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 2 May 2026 01:34:14 +0530 Subject: [PATCH 018/314] Improve Codex dashboard refresh resilience --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33cc13935..41a03c6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixes - Cursor: show Enterprise/Team usage from personal caps and shared pools instead of reporting 100% remaining (#813). Thanks @fcamus00! - Codex: keep same-workspace managed accounts distinct by matching workspace identity with email, so different OpenAI users in one workspace no longer overwrite each other (#796). Thanks @leezhuuuuu! +- Codex: make OpenAI dashboard refreshes handle non-English pages, lazy-loaded credits history, timeout retries, and unrelated Skillusage rows (#825). Thanks @xiaoqianWX! ## 0.23 — 2026-04-26 From ec2005bd04d4cc2ba0432df85a9a277f1a6a8986 Mon Sep 17 00:00:00 2001 From: Alasdair McCall Date: Fri, 1 May 2026 22:53:07 +0100 Subject: [PATCH 019/314] Add Copilot multi-account support (#637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Copilot to token account support catalog Register .copilot in TokenAccountSupportCatalog with environment-based injection (COPILOT_API_TOKEN), matching the existing env key used by ProviderConfigEnvironment and ProviderTokenResolver. Uses requiresManualCookieSource: false since Copilot authenticates via OAuth, not cookies. Co-Authored-By: Claude Opus 4.6 * feat: add GitHub username fetch to CopilotUsageFetcher Add fetchGitHubUsername(token:) static method that calls GET /user with the OAuth token to retrieve the GitHub login. Used for auto-labeling token accounts during the multi-account add flow and migration. Co-Authored-By: Claude Opus 4.6 * feat: store Copilot OAuth tokens in multi-account system Replace single-token storage (settings.copilotAPIToken) with multi-account storage via addTokenAccount(). After OAuth, fetches GitHub username via /user and plan name via copilot_internal/user to build a "username (Plan)" label. Detects duplicate accounts by username prefix and refreshes the token instead of creating duplicates. Co-Authored-By: Claude Opus 4.6 * feat: update Copilot settings UI for multi-account Replace single-token secure field and login buttons with an "Add Account" button that triggers OAuth device flow. The generic token accounts section (account list, picker, remove, paste fallback) appears automatically now that Copilot is in the TokenAccountSupportCatalog. Migration from config apiKey is triggered via observeSettings(). Co-Authored-By: Claude Opus 4.6 * feat: auto-migrate Copilot config token to multi-account store When copilotAPIToken exists in config but no token accounts exist, migrate the token to a ProviderTokenAccount with a fallback label and clear the config key. An async Task enriches the label with the GitHub username via /user. Migration is idempotent — guarded by checking that token accounts are empty. Co-Authored-By: Claude Opus 4.6 * test: add Copilot multi-account catalog, migration, and env precedence tests Cover the token account catalog entry (injection type, env override), config-to-account migration (happy path, idempotent, no-op cases), and environment precedence (token account overrides config API key). Uses Swift Testing framework with InMemory stores matching existing patterns. Co-Authored-By: Claude Opus 4.6 * docs: add spec, review, and implementation plan for Copilot multi-account Co-Authored-By: Claude Opus 4.6 * chore: update plan status to Shipped Co-Authored-By: Claude Opus 4.6 * Delete .plans/2026-04-01-copilot-multi-account.md * Delete specs/2026-04-01-copilot-multi-account.md * Delete reviews/2026-04-01-copilot-multi-account.md * fix: stack multi-account usage cards and refresh data on account switch Replace the tab-style account switcher with stacked usage cards so each account's quota is visible at once. Fix a race where switching accounts rebuilt the menu before the async refresh completed, showing stale data. Co-Authored-By: Claude Sonnet 4.6 * fix: removed migration code * fix: token account update preserves identity and selection * Refine Copilot multi-account UI * Preserve active token account on removal * Fix Copilot multi-account merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot account review feedback * Fix Copilot token account fetch fallback * Use stable GitHub identity for Copilot re-auth dedupe Address Codex review feedback on PR #637: - Add ProviderTokenAccount.externalIdentifier (optional, backwards- compatible Codable). Persists the GitHub login alongside Copilot token accounts. - CopilotLoginFlow now matches existing accounts by externalIdentifier first, only falling back to label-prefix matching for legacy accounts that pre-date the field. Update writes the identifier back so future re-auths use the stable identity path. This prevents duplicate accounts when usernames previously stored as 'Account N' or hand- edited labels. - Preserve CancellationError as a non-empty per-account error marker via tokenAccountSnapshotErrorMessage so the menu does not silently fall back to the live selected-account snapshot when an individual account refresh is cancelled. Global error path keeps suppressing cancellations as before. - Tests: 7 new tests covering identifier persistence, in-place update preservation, legacy backfill, decoding compatibility, and snapshot vs global error message behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix per-account card duplication on refresh cancellation When the user switches menu tabs mid-flight, the in-flight per-account refresh task is cancelled. Two issues then surfaced: 1. menuCardModel fell back to the provider-level live snapshot whenever snapshotOverride was nil — even on override-context cards. That caused every cancelled per-account card to render with the *selected* account's data, producing N identical 'cancelled' cards. 2. refreshTokenAccounts overwrote accountSnapshots wholesale, so a single cancellation wiped the previous good state for every account. Fixes: - menuCardModel: when surface == .overrideCard, never fall back to the live snapshot. Override cards belong to a specific account context; borrowing live data leaks the wrong identity into the card. - refreshTokenAccounts: capture the prior accountSnapshots and pass each account's prior snapshot into resolveAccountOutcome. On CancellationError with a valid prior snapshot, preserve the prior state instead of replacing it with an error chip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hide cancelled per-account rows when no prior data exists The previous fix preserved prior per-account snapshots when a refresh was cancelled, but only helped when there was prior data to preserve. On a fresh refresh that gets cancelled (e.g. the user closed/reopened the menu quickly), every row would still render as a 'Refresh cancelled' chip with no usable data — three identical 'Copilot / cancelled' cards. Cancellation is not a user-facing error. Treat it as 'no result yet': - resolveAccountOutcome now returns a nil snapshot for CancellationError with no usable prior state. The row is dropped instead of becoming a placeholder. - refreshTokenAccounts skips overwriting accountSnapshots wholesale when every fetch was cancelled and produced no usable data, leaving any prior state intact. - When all accounts are dropped, the existing menu fallback already shows a single live card for the selected account instead of a row per account — so the menu stays usable while a real refresh re-runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Copilot legacy account reauth dedupe * Use stable GitHub IDs for Copilot accounts * Clear Copilot legacy token fallback --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Verification Agent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ratul Sarna --- Sources/CodexBar/MenuContent.swift | 2 + Sources/CodexBar/MenuDescriptor.swift | 3 +- .../PreferencesProviderSettingsRows.swift | 114 +++-- .../PreferencesProvidersPane+Testing.swift | 2 + .../CodexBar/PreferencesProvidersPane.swift | 7 + .../Providers/Copilot/CopilotLoginFlow.swift | 127 +++++- .../CopilotProviderImplementation.swift | 37 +- .../Copilot/CopilotSettingsStore.swift | 11 +- .../Shared/ProviderSettingsDescriptors.swift | 2 + .../SettingsStore+TokenAccounts.swift | 81 +++- .../CodexBar/StatusItemController+Menu.swift | 27 +- ...atusItemController+MenuActionMapping.swift | 1 + .../CodexBar/UsageStore+TokenAccounts.swift | 92 +++- .../Copilot/CopilotProviderDescriptor.swift | 11 +- .../Copilot/CopilotUsageFetcher.swift | 36 ++ .../Providers/ProviderSettingsSnapshot.swift | 6 +- .../TokenAccountSupportCatalog+Data.swift | 7 + Sources/CodexBarCore/TokenAccounts.swift | 13 +- .../CopilotMultiAccountTests.swift | 405 ++++++++++++++++++ .../SettingsStoreCoverageTests.swift | 60 +++ .../UsageStoreCoverageTests.swift | 9 + 21 files changed, 965 insertions(+), 88 deletions(-) create mode 100644 Tests/CodexBarTests/CopilotMultiAccountTests.swift diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index 1f27ef5c1..3c40cbed2 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -112,6 +112,8 @@ struct MenuContent: View { self.actions.addCodexAccount() case .requestCodexSystemPromotion: return + case let .addProviderAccount(provider): + self.actions.switchAccount(provider) case let .switchAccount(provider): self.actions.switchAccount(provider) case let .openTerminal(command): diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index cc757055b..4bd442a98 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -57,6 +57,7 @@ struct MenuDescriptor { case statusPage case addCodexAccount case requestCodexSystemPromotion(UUID) + case addProviderAccount(UsageProvider) case switchAccount(UsageProvider) case openTerminal(command: String) case loginToProvider(url: String) @@ -503,7 +504,7 @@ extension MenuDescriptor.MenuAction { case .refreshAugmentSession: MenuDescriptor.MenuActionSystemImage.refresh.rawValue case .dashboard: MenuDescriptor.MenuActionSystemImage.dashboard.rawValue case .statusPage: MenuDescriptor.MenuActionSystemImage.statusPage.rawValue - case .addCodexAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue + case .addCodexAccount, .addProviderAccount: MenuDescriptor.MenuActionSystemImage.addAccount.rawValue case .requestCodexSystemPromotion: nil case .switchAccount: MenuDescriptor.MenuActionSystemImage.switchAccount.rawValue diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index 514c7e3ce..a5086c4dc 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -214,9 +214,23 @@ struct ProviderSettingsTokenAccountsRowView: View { @State private var newToken: String = "" var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) - .font(.subheadline.weight(.semibold)) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + Text(self.descriptor.title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + if let title = self.descriptor.primaryAddActionTitle, + let action = self.descriptor.primaryAddAction + { + Button(title) { + Task { @MainActor in + await action() + } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text(self.descriptor.subtitle) @@ -231,46 +245,64 @@ struct ProviderSettingsTokenAccountsRowView: View { .font(.footnote) .foregroundStyle(.secondary) } else { - let selectedIndex = min(self.descriptor.activeIndex(), max(0, accounts.count - 1)) - Picker("", selection: Binding( - get: { selectedIndex }, - set: { index in self.descriptor.setActiveIndex(index) })) - { - ForEach(Array(accounts.enumerated()), id: \.offset) { index, account in - Text(account.displayName).tag(index) - } - } - .labelsHidden() - .pickerStyle(.menu) - .controlSize(.small) + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in + HStack(alignment: .center, spacing: 10) { + Button { + self.descriptor.setActiveIndex(index) + } label: { + HStack(alignment: .center, spacing: 8) { + Image(systemName: self.isActive(index: index, accountCount: accounts.count) ? + "checkmark.circle.fill" : "circle") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.isActive(index: index, accountCount: accounts.count) ? + Color.accentColor : Color.secondary) + Text(account.displayName) + .font( + .footnote.weight( + self.isActive(index: index, accountCount: accounts.count) ? + .semibold : .regular)) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - Button("Remove selected account") { - let account = accounts[selectedIndex] - self.descriptor.removeAccount(account.id) + Button("Remove") { + self.descriptor.removeAccount(account.id) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + if index < accounts.count - 1 { + Divider() + } + } } - .buttonStyle(.bordered) - .controlSize(.small) } - HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) - .textFieldStyle(.roundedBorder) - .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) - .textFieldStyle(.roundedBorder) - .font(.footnote) - Button("Add") { - let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) - let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) - guard !label.isEmpty, !token.isEmpty else { return } - self.descriptor.addAccount(label, token) - self.newLabel = "" - self.newToken = "" + if self.descriptor.primaryAddAction == nil { + HStack(spacing: 8) { + TextField("Label", text: self.$newLabel) + .textFieldStyle(.roundedBorder) + .font(.footnote) + SecureField(self.descriptor.placeholder, text: self.$newToken) + .textFieldStyle(.roundedBorder) + .font(.footnote) + Button("Add") { + let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) + guard !label.isEmpty, !token.isEmpty else { return } + self.descriptor.addAccount(label, token) + self.newLabel = "" + self.newToken = "" + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } HStack(spacing: 10) { @@ -287,6 +319,12 @@ struct ProviderSettingsTokenAccountsRowView: View { } } } + + private func isActive(index: Int, accountCount: Int) -> Bool { + guard accountCount > 0 else { return false } + let selectedIndex = min(self.descriptor.activeIndex(), max(0, accountCount - 1)) + return selectedIndex == index + } } extension View { diff --git a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift index 1fcbcffa9..11ab98181 100644 --- a/Sources/CodexBar/PreferencesProvidersPane+Testing.swift +++ b/Sources/CodexBar/PreferencesProvidersPane+Testing.swift @@ -248,6 +248,8 @@ enum ProvidersPaneTestHarness { setActiveIndex: { _ in }, addAccount: { _, _ in }, removeAccount: { _ in }, + primaryAddActionTitle: nil, + primaryAddAction: nil, openConfigFile: {}, reloadFromDisk: {}) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 8cf0ff608..9d916465f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -390,6 +390,13 @@ struct ProvidersPane: View { } } }, + primaryAddActionTitle: provider == .copilot ? "Add Account" : nil, + primaryAddAction: provider == .copilot ? { + await CopilotLoginFlow.run(settings: self.settings) + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await self.store.refreshProvider(provider, allowDisabled: true) + } + } : nil, openConfigFile: { self.settings.openTokenAccountsFile() }, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift index 1170f2236..b314afb75 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift @@ -80,14 +80,70 @@ struct CopilotLoginFlow { switch tokenResult { case let .success(token): - settings.copilotAPIToken = token + // Fetch username for account label. + // If accounts already exist, fail closed when identity lookup fails so re-auth cannot create + // an anonymous duplicate with stale credentials left on the original account. + let existingAccounts = settings.tokenAccounts(for: .copilot) + let label: String + let identity: CopilotUsageFetcher.GitHubUserIdentity? + do { + let resolvedIdentity = try await CopilotUsageFetcher.fetchGitHubIdentity(token: token) + let resolvedUsername = resolvedIdentity.login + let planSuffix: String + do { + let fetcher = CopilotUsageFetcher(token: token) + let usage = try await fetcher.fetch() + let plan = usage.identity(for: .copilot)?.loginMethod ?? "" + planSuffix = plan.isEmpty ? "" : " (\(plan))" + } catch { + planSuffix = "" + } + identity = resolvedIdentity + label = "\(resolvedUsername)\(planSuffix)" + } catch { + guard existingAccounts.isEmpty else { + let err = NSAlert() + err.messageText = "Could Not Identify GitHub Account" + err.informativeText = "GitHub login succeeded, but CodexBar could not verify which " + + "account it belongs to. Please try again." + err.runModal() + return + } + identity = nil + label = "Account 1" + } + + // Match existing account by stable GitHub user ID. For legacy accounts that pre-date stable + // identifiers, also accept login-based externalIdentifier values and resolve stored token identity + // before falling back to labels. + let matchedExisting = await Self.matchExistingAccount( + existingAccounts: existingAccounts, + identity: identity, + label: label) + let externalIdentifier = identity.map(Self.externalIdentifier) + let wasRefresh = matchedExisting != nil + if let existing = matchedExisting { + settings.updateTokenAccount( + provider: .copilot, + accountID: existing.id, + label: label, + token: token, + externalIdentifier: .some(externalIdentifier)) + } else { + settings.addTokenAccount( + provider: .copilot, + label: label, + token: token, + externalIdentifier: externalIdentifier) + } settings.setProviderEnabled( provider: .copilot, metadata: ProviderRegistry.shared.metadata[.copilot]!, enabled: true) let success = NSAlert() - success.messageText = "Login Successful" + success.messageText = wasRefresh ? "Token Refreshed" : "Account Added" + success.informativeText = label success.runModal() case let .failure(error): guard !(error is CancellationError) else { return } @@ -105,6 +161,73 @@ struct CopilotLoginFlow { } } + static func matchExistingAccount( + existingAccounts: [ProviderTokenAccount], + identity: CopilotUsageFetcher.GitHubUserIdentity?, + label: String, + legacyIdentityResolver: @escaping @Sendable (ProviderTokenAccount) async + -> CopilotUsageFetcher.GitHubUserIdentity? = { account in + try? await CopilotUsageFetcher.fetchGitHubIdentity(token: account.token) + }) async -> ProviderTokenAccount? + { + guard let identity, !existingAccounts.isEmpty else { return nil } + let stableIdentifier = self.externalIdentifier(for: identity) + let login = self.normalizedGitHubLogin(identity.login) + + if let byID = existingAccounts.first(where: { account in + self.normalizedExternalIdentifier(account.externalIdentifier) == stableIdentifier + }) { + return byID + } + + // Previous PR revisions stored GitHub login in externalIdentifier. Keep matching those + // accounts case-insensitively, then write back the stable ID on update. + if let byLegacyLogin = existingAccounts.first(where: { account in + self.normalizedGitHubLogin(account.externalIdentifier) == login + }) { + return byLegacyLogin + } + + let legacyAccounts = existingAccounts.filter { $0.externalIdentifier == nil } + for account in legacyAccounts { + guard let resolvedIdentity = await legacyIdentityResolver(account) else { continue } + if resolvedIdentity.id == identity.id || + self.normalizedGitHubLogin(resolvedIdentity.login) == login + { + return account + } + } + + let usernamePrefix = self.displayLabelPrefix(label) + return legacyAccounts.first { account in + self.displayLabelPrefix(account.label) == usernamePrefix + } + } + + static func externalIdentifier(for identity: CopilotUsageFetcher.GitHubUserIdentity) -> String { + "github:user:\(identity.id)" + } + + private static func normalizedExternalIdentifier(_ identifier: String?) -> String? { + let trimmed = identifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed.lowercased() + } + + private static func normalizedGitHubLogin(_ login: String?) -> String? { + let trimmed = login?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + // Stable IDs are not valid GitHub logins; do not let a numeric-looking login fallback + // match the "github:user:" identifier path accidentally. + guard !trimmed.lowercased().hasPrefix("github:user:") else { return nil } + return trimmed.lowercased() + } + + private static func displayLabelPrefix(_ label: String) -> String { + (label.components(separatedBy: " (").first ?? label) + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } + @MainActor private static func presentWaitingAlert( _ alert: NSAlert, diff --git a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift index 6d400417c..4caf55e8a 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotProviderImplementation.swift @@ -20,41 +20,38 @@ struct CopilotProviderImplementation: ProviderImplementation { @MainActor func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { - _ = context - return .copilot(context.settings.copilotSettingsSnapshot()) + .copilot(context.settings.copilotSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func loginMenuAction(context _: ProviderMenuLoginContext) + -> (label: String, action: MenuDescriptor.MenuAction)? + { + ("Add Account...", .addProviderAccount(.copilot)) } @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { [ ProviderSettingsFieldDescriptor( - id: "copilot-api-token", + id: "copilot-add-account", title: "GitHub Login", - subtitle: "Requires authentication via GitHub Device Flow.", - footerText: "The device code is copied to your clipboard. Paste it into the GitHub page with ⌘V.", - kind: .secure, - placeholder: "Sign in via button below", - binding: context.stringBinding(\.copilotAPIToken), + subtitle: "Add accounts via GitHub OAuth Device Flow.", + kind: .plain, + placeholder: nil, + binding: .constant(""), actions: [ ProviderSettingsActionDescriptor( - id: "copilot-login", - title: "Sign in with GitHub", + id: "copilot-add-account-action", + title: "Add Account", style: .bordered, - isVisible: { context.settings.copilotAPIToken.isEmpty }, - perform: { - await CopilotLoginFlow.run(settings: context.settings) - }), - ProviderSettingsActionDescriptor( - id: "copilot-relogin", - title: "Sign in again", - style: .link, - isVisible: { !context.settings.copilotAPIToken.isEmpty }, + isVisible: { true }, perform: { await CopilotLoginFlow.run(settings: context.settings) }), ], isVisible: nil, - onActivate: { context.settings.ensureCopilotAPITokenLoaded() }), + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift index c40a51f3d..fde70a5ce 100644 --- a/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift +++ b/Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift @@ -16,7 +16,14 @@ extension SettingsStore { } extension SettingsStore { - func copilotSettingsSnapshot() -> ProviderSettingsSnapshot.CopilotProviderSettings { - ProviderSettingsSnapshot.CopilotProviderSettings() + func copilotSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.CopilotProviderSettings + { + let account = ProviderTokenAccountSelection.selectedAccount( + provider: .copilot, + settings: self, + override: tokenOverride) + let token = account?.token ?? self.copilotAPIToken + return ProviderSettingsSnapshot.CopilotProviderSettings(apiToken: self.normalizedConfigValue(token)) } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift index 7b408d8da..e6a587d90 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderSettingsDescriptors.swift @@ -99,6 +99,8 @@ struct ProviderSettingsTokenAccountsDescriptor: Identifiable { let setActiveIndex: (Int) -> Void let addAccount: (_ label: String, _ token: String) -> Void let removeAccount: (_ accountID: UUID) -> Void + let primaryAddActionTitle: String? + let primaryAddAction: (() async -> Void)? let openConfigFile: () -> Void let reloadFromDisk: () -> Void } diff --git a/Sources/CodexBar/SettingsStore+TokenAccounts.swift b/Sources/CodexBar/SettingsStore+TokenAccounts.swift index 1f8a0277b..0fceb00c2 100644 --- a/Sources/CodexBar/SettingsStore+TokenAccounts.swift +++ b/Sources/CodexBar/SettingsStore+TokenAccounts.swift @@ -36,11 +36,18 @@ extension SettingsStore { ]) } - func addTokenAccount(provider: UsageProvider, label: String, token: String) { + func addTokenAccount( + provider: UsageProvider, + label: String, + token: String, + externalIdentifier: String? = nil) + { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return } let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedToken.isEmpty else { return } let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedIdentifier = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalisedIdentifier = (trimmedIdentifier?.isEmpty ?? true) ? nil : trimmedIdentifier let existing = self.tokenAccountsData(for: provider) let accounts = existing?.accounts ?? [] let fallbackLabel = trimmedLabel.isEmpty ? "Account \(accounts.count + 1)" : trimmedLabel @@ -49,13 +56,17 @@ extension SettingsStore { label: fallbackLabel, token: trimmedToken, addedAt: Date().timeIntervalSince1970, - lastUsed: nil) + lastUsed: nil, + externalIdentifier: normalisedIdentifier) let updated = ProviderTokenAccountData( version: existing?.version ?? 1, accounts: accounts + [account], activeIndex: accounts.count) self.updateProviderConfig(provider: provider) { entry in entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } } self.applyTokenAccountCookieSourceIfNeeded(provider: provider) CodexBarLog.logger(LogCategories.tokenAccounts).info( @@ -66,18 +77,80 @@ extension SettingsStore { ]) } + func updateTokenAccount( + provider: UsageProvider, + accountID: UUID, + label: String? = nil, + token: String? = nil, + externalIdentifier: String?? = nil) + { + guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + guard let index = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } + + let trimmedLabel = label?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedToken, trimmedToken.isEmpty { return } + + let existing = data.accounts[index] + let resolvedIdentifier: String? + if let externalIdentifier { + let trimmed = externalIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) + resolvedIdentifier = (trimmed?.isEmpty ?? true) ? nil : trimmed + } else { + resolvedIdentifier = existing.externalIdentifier + } + let updatedAccount = ProviderTokenAccount( + id: existing.id, + label: (trimmedLabel?.isEmpty == false) ? trimmedLabel! : existing.label, + token: trimmedToken ?? existing.token, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed, + externalIdentifier: resolvedIdentifier) + + var accounts = data.accounts + accounts[index] = updatedAccount + let updated = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + self.updateProviderConfig(provider: provider) { entry in + entry.tokenAccounts = updated + if provider == .copilot { + entry.apiKey = nil + } + } + self.applyTokenAccountCookieSourceIfNeeded(provider: provider) + CodexBarLog.logger(LogCategories.tokenAccounts).info( + "Token account updated", + metadata: [ + "provider": provider.rawValue, + "count": "\(updated.accounts.count)", + ]) + } + func removeTokenAccount(provider: UsageProvider, accountID: UUID) { guard let data = self.tokenAccountsData(for: provider), !data.accounts.isEmpty else { return } + let activeAccountID = data.accounts[data.clampedActiveIndex()].id + guard let removedIndex = data.accounts.firstIndex(where: { $0.id == accountID }) else { return } let filtered = data.accounts.filter { $0.id != accountID } self.updateProviderConfig(provider: provider) { entry in if filtered.isEmpty { entry.tokenAccounts = nil } else { - let clamped = min(max(data.activeIndex, 0), filtered.count - 1) + let nextActiveIndex = if activeAccountID != accountID, + let preservedIndex = filtered.firstIndex(where: { $0.id == activeAccountID }) + { + preservedIndex + } else { + min(removedIndex, filtered.count - 1) + } entry.tokenAccounts = ProviderTokenAccountData( version: data.version, accounts: filtered, - activeIndex: clamped) + activeIndex: nextActiveIndex) + } + if provider == .copilot { + entry.apiKey = nil } } CodexBarLog.logger(LogCategories.tokenAccounts).info( diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 4537ec451..9d03f140b 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -728,14 +728,19 @@ extension StatusItemController { onSelect: { [weak self, weak menu] index in guard let self, let menu else { return } self.settings.setActiveTokenAccountIndex(index, for: display.provider) - Task { @MainActor in + // Immediately rebuild to show the new selection, then refresh data + // and rebuild again once fresh data arrives. + self.populateMenu(menu, provider: display.provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + Task { @MainActor [weak self, weak menu] in + guard let self else { return } await ProviderInteractionContext.$current.withValue(.userInitiated) { await self.store.refresh() } + guard let menu else { return } + self.rebuildOpenMenuIfStillVisible(menu, provider: display.provider) } - self.populateMenu(menu, provider: display.provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) }) let item = NSMenuItem() item.view = view @@ -817,7 +822,9 @@ extension StatusItemController { let accounts = self.settings.tokenAccounts(for: provider) guard accounts.count > 1 else { return nil } let activeIndex = self.settings.tokenAccountsData(for: provider)?.clampedActiveIndex() ?? 0 - let showAll = self.settings.showAllTokenAccountsInMenu + let canShowAllCopilotAccounts = provider == .copilot && + accounts.count <= UsageStore.tokenAccountMenuSnapshotLimit + let showAll = canShowAllCopilotAccounts || self.settings.showAllTokenAccountsInMenu let snapshots = showAll ? (self.store.accountSnapshots[provider] ?? []) : [] return TokenAccountMenuDisplay( provider: provider, @@ -1356,12 +1363,20 @@ extension StatusItemController { let target = provider ?? self.store.enabledProvidersForDisplay().first ?? .codex let metadata = self.store.metadata(for: target) - let snapshot = snapshotOverride ?? self.store.snapshot(for: target) let surface: CodexConsumerProjection.Surface = if snapshotOverride != nil || errorOverride != nil { .overrideCard } else { .liveCard } + // Override cards belong to a specific account/context (e.g. a per-account + // refresh result). Never fall back to the provider-level live snapshot here — + // that data belongs to a *different* account and would render misleading + // duplicate cards when an account refresh failed or was cancelled. + let snapshot: UsageSnapshot? = if surface == .overrideCard { + snapshotOverride + } else { + snapshotOverride ?? self.store.snapshot(for: target) + } let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: target, diff --git a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift index 44d2c35c1..eb68b018e 100644 --- a/Sources/CodexBar/StatusItemController+MenuActionMapping.swift +++ b/Sources/CodexBar/StatusItemController+MenuActionMapping.swift @@ -9,6 +9,7 @@ extension StatusItemController { case .dashboard: (#selector(self.openDashboard), nil) case .statusPage: (#selector(self.openStatusPage), nil) case .addCodexAccount: (#selector(self.addManagedCodexAccountFromMenu(_:)), nil) + case let .addProviderAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) case let .requestCodexSystemPromotion(managedAccountID): (#selector(self.requestCodexSystemPromotionFromMenu(_:)), managedAccountID.uuidString) case let .switchAccount(provider): (#selector(self.runSwitchAccount(_:)), provider.rawValue) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 7a00c1236..44f1ed5bb 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -18,6 +18,8 @@ struct TokenAccountUsageSnapshot: Identifiable { } extension UsageStore { + static let tokenAccountMenuSnapshotLimit = 6 + func tokenAccounts(for provider: UsageProvider) -> [ProviderTokenAccount] { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return [] } return self.settings.tokenAccounts(for: provider) @@ -25,6 +27,9 @@ extension UsageStore { func shouldFetchAllTokenAccounts(provider: UsageProvider, accounts: [ProviderTokenAccount]) -> Bool { guard TokenAccountSupportCatalog.support(for: provider) != nil else { return false } + if provider == .copilot { + return accounts.count > 1 + } return self.settings.showAllTokenAccountsInMenu && accounts.count > 1 } @@ -32,16 +37,35 @@ extension UsageStore { let selectedAccount = self.settings.selectedTokenAccount(for: provider) let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first + + // Capture the prior per-account snapshot state so we can preserve last-good + // data when an in-flight refresh is cancelled (e.g. menu tab switches). Without + // this, cancellation produces empty/error snapshots and the menu briefly shows + // misleading cards for accounts that previously had valid data. + let priorSnapshots = await MainActor.run { self.accountSnapshots[provider] ?? [] } + let priorByAccountID = Dictionary(uniqueKeysWithValues: priorSnapshots.map { ($0.account.id, $0) }) + var snapshots: [TokenAccountUsageSnapshot] = [] var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? + var sawAnyNonCancellationOutcome = false for account in limitedAccounts { let override = TokenAccountOverride(provider: provider, account: account) let outcome = await self.fetchOutcome(provider: provider, override: override) - let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) - snapshots.append(resolved.snapshot) + let isCancellation = Self.outcomeIsCancellation(outcome) + if !isCancellation { + sawAnyNonCancellationOutcome = true + } + let resolved = self.resolveAccountOutcome( + outcome, + provider: provider, + account: account, + priorSnapshot: priorByAccountID[account.id]) + if let snapshot = resolved.snapshot { + snapshots.append(snapshot) + } if let usage = resolved.usage { historySamples.append((account: account, snapshot: usage)) } @@ -51,8 +75,15 @@ extension UsageStore { } } - await MainActor.run { - self.accountSnapshots[provider] = snapshots + // If every fetch was cancelled (e.g. the user closed/reopened the menu mid-flight) + // and we have no usable snapshots, leave the prior per-account state alone. + // Wiping it would produce a menu of useless "cancelled" placeholders. + let shouldPreservePriorState = !sawAnyNonCancellationOutcome && + snapshots.allSatisfy { $0.snapshot == nil } + if !shouldPreservePriorState { + await MainActor.run { + self.accountSnapshots[provider] = snapshots + } } if let selectedOutcome { @@ -69,11 +100,18 @@ extension UsageStore { selectedAccount: effectiveSelected) } + private static func outcomeIsCancellation(_ outcome: ProviderFetchOutcome) -> Bool { + if case let .failure(error) = outcome.result, error is CancellationError { + return true + } + return false + } + func limitedTokenAccounts( _ accounts: [ProviderTokenAccount], selected: ProviderTokenAccount?) -> [ProviderTokenAccount] { - let limit = 6 + let limit = Self.tokenAccountMenuSnapshotLimit if accounts.count <= limit { return accounts } var limited = Array(accounts.prefix(limit)) if let selected, !limited.contains(where: { $0.id == selected.id }) { @@ -126,10 +164,28 @@ extension UsageStore { } private struct ResolvedAccountOutcome { - let snapshot: TokenAccountUsageSnapshot + let snapshot: TokenAccountUsageSnapshot? let usage: UsageSnapshot? } + func tokenAccountErrorMessage(_ error: any Error) -> String? { + guard !(error is CancellationError) else { return nil } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } + + /// Per-account snapshot error text. Unlike ``tokenAccountErrorMessage``, + /// cancellations are preserved as a non-empty marker so the menu does not + /// silently fall back to the live (selected-account) snapshot when an + /// individual account refresh is cancelled. + func tokenAccountSnapshotErrorMessage(_ error: any Error) -> String { + if error is CancellationError { + return "Refresh cancelled" + } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? "Refresh failed" : message + } + func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], @@ -148,7 +204,8 @@ extension UsageStore { private func resolveAccountOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, - account: ProviderTokenAccount) -> ResolvedAccountOutcome + account: ProviderTokenAccount, + priorSnapshot: TokenAccountUsageSnapshot? = nil) -> ResolvedAccountOutcome { switch outcome.result { case let .success(result): @@ -161,10 +218,23 @@ extension UsageStore { sourceLabel: result.sourceLabel) return ResolvedAccountOutcome(snapshot: snapshot, usage: labeled) case let .failure(error): + // Preserve the last-good snapshot when the refresh was cancelled (e.g. the + // user switched menu tabs mid-flight). Without this the per-account list + // would briefly render error chips for accounts that already had data. + if error is CancellationError { + if let priorSnapshot, priorSnapshot.snapshot != nil { + return ResolvedAccountOutcome(snapshot: priorSnapshot, usage: priorSnapshot.snapshot) + } + // No usable prior data: skip this row entirely. The caller will + // either preserve the existing per-account state or fall back to + // the single live card. Rendering a "cancelled" placeholder here + // produces visually duplicate cards with no useful data. + return ResolvedAccountOutcome(snapshot: nil, usage: nil) + } let snapshot = TokenAccountUsageSnapshot( account: account, snapshot: nil, - error: error.localizedDescription, + error: self.tokenAccountSnapshotErrorMessage(error), sourceLabel: nil) return ResolvedAccountOutcome(snapshot: snapshot, usage: nil) } @@ -200,11 +270,15 @@ extension UsageStore { account: account) case let .failure(error): await MainActor.run { + guard let message = self.tokenAccountErrorMessage(error) else { + self.errors[provider] = nil + return + } let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true if shouldSurface { - self.errors[provider] = error.localizedDescription + self.errors[provider] = message self.snapshots.removeValue(forKey: provider) } else { self.errors[provider] = nil diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 9e2b063cc..fb039943c 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -44,11 +44,11 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .apiToken func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil + Self.resolveToken(context: context) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let token = Self.resolveToken(environment: context.env), !token.isEmpty else { + guard let token = Self.resolveToken(context: context), !token.isEmpty else { throw URLError(.userAuthenticationRequired) } let fetcher = CopilotUsageFetcher(token: token) @@ -62,7 +62,10 @@ struct CopilotAPIFetchStrategy: ProviderFetchStrategy { false } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.copilotToken(environment: environment) + private static func resolveToken(context: ProviderFetchContext) -> String? { + ProviderTokenResolver.copilotToken(environment: context.env) + ?? ProviderTokenResolver.copilotResolution(environment: [ + "COPILOT_API_TOKEN": context.settings?.copilot?.apiToken ?? "", + ])?.token } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a..e8f88c0d9 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -4,6 +4,16 @@ import FoundationNetworking #endif public struct CopilotUsageFetcher: Sendable { + public struct GitHubUserIdentity: Decodable, Equatable, Sendable { + public let id: Int64 + public let login: String + + public init(id: Int64, login: String) { + self.id = id + self.login = login + } + } + private let token: String public init(token: String) { @@ -66,6 +76,32 @@ public struct CopilotUsageFetcher: Sendable { identity: identity) } + public static func fetchGitHubUsername(token: String) async throws -> String { + try await self.fetchGitHubIdentity(token: token).login + } + + public static func fetchGitHubIdentity(token: String) async throws -> GitHubUserIdentity { + guard let url = URL(string: "https://api.github.com/user") else { + throw URLError(.badURL) + } + var request = URLRequest(url: url) + request.setValue("token \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } + guard httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + return try JSONDecoder().decode(GitHubUserIdentity.self, from: data) + } + private func addCommonHeaders(to request: inout URLRequest) { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version") diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 2692c4920..819a6d87e 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -165,7 +165,11 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CopilotProviderSettings: Sendable { - public init() {} + public let apiToken: String? + + public init(apiToken: String? = nil) { + self.apiToken = apiToken + } } public struct KiloProviderSettings: Sendable { diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index a4ce33fb9..94e1d3f7e 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -86,5 +86,12 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec..cb8e3f35b 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -6,13 +6,24 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? + /// Stable provider-specific identity (e.g. GitHub `login`) used for + /// re-auth deduplication. Optional so legacy accounts keep working. + public let externalIdentifier: String? - public init(id: UUID, label: String, token: String, addedAt: TimeInterval, lastUsed: TimeInterval?) { + public init( + id: UUID, + label: String, + token: String, + addedAt: TimeInterval, + lastUsed: TimeInterval?, + externalIdentifier: String? = nil) + { self.id = id self.label = label self.token = token self.addedAt = addedAt self.lastUsed = lastUsed + self.externalIdentifier = externalIdentifier } public var displayName: String { diff --git a/Tests/CodexBarTests/CopilotMultiAccountTests.swift b/Tests/CodexBarTests/CopilotMultiAccountTests.swift new file mode 100644 index 000000000..e472a8219 --- /dev/null +++ b/Tests/CodexBarTests/CopilotMultiAccountTests.swift @@ -0,0 +1,405 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// MARK: - Catalog + +@Test +func `copilot catalog entry exists`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + #expect(support != nil) + #expect(support?.requiresManualCookieSource == false) + #expect(support?.cookieName == nil) +} + +@Test +func `copilot catalog entry uses environment injection`() { + let support = TokenAccountSupportCatalog.support(for: .copilot) + guard let support else { + Issue.record("Copilot catalog entry missing") + return + } + if case let .environment(key) = support.injection { + #expect(key == "COPILOT_API_TOKEN") + } else { + Issue.record("Expected .environment injection, got cookieHeader") + } +} + +@Test +func `copilot env override uses correct key`() { + let override = TokenAccountSupportCatalog.envOverride(for: .copilot, token: "gh_abc") + #expect(override == ["COPILOT_API_TOKEN": "gh_abc"]) +} + +// MARK: - Username Fetch (parsing only) + +@Test +func `GitHub user response parses stable id and login`() throws { + let json = #"{"login": "testuser", "id": 123, "name": "Test User"}"# + let user = try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + #expect(user.id == 123) + #expect(user.login == "testuser") +} + +@Test +func `GitHub user response requires stable id`() throws { + let json = #"{"login": "minimaluser"}"# + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(CopilotUsageFetcher.GitHubUserIdentity.self, from: Data(json.utf8)) + } +} + +// MARK: - API Key Fallback + +@MainActor +struct CopilotAPIKeyFallbackTests { + @Test + func `ensure loader preserves config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-loader") + settings.copilotAPIToken = "gh_token_123" + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.copilotAPIToken == "gh_token_123") + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + } + + @Test + func `token accounts clear legacy config token`() { + let settings = Self.makeSettingsStore(suite: "copilot-api-key-with-accounts") + settings.copilotAPIToken = "gh_token_old" + settings.addTokenAccount(provider: .copilot, label: "existing", token: "gh_token_existing") + + settings.ensureCopilotAPITokenLoaded() + + #expect(settings.tokenAccounts(for: .copilot).count == 1) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "gh_token_existing") + #expect(settings.tokenAccounts(for: .copilot).first?.label == "existing") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - Environment Precedence + +@MainActor +struct CopilotEnvironmentPrecedenceTests { + @Test + func `token account overrides config API key`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-env-override") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + let override = TokenAccountOverride(provider: .copilot, account: account) + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: override) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: override) + + #expect(env["COPILOT_API_TOKEN"] == "new_account_token") + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `selected token account is included in copilot settings snapshot`() { + let settings = Self.makeSettingsStore(suite: "copilot-settings-snapshot-account") + settings.copilotAPIToken = "old_config_token" + settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(snapshot.copilot?.apiToken == "new_account_token") + } + + @Test + func `config API key used when no token accounts`() { + let settings = Self.makeSettingsStore(suite: "copilot-env-config-only") + settings.copilotAPIToken = "config_token" + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .copilot, + settings: settings, + tokenOverride: nil) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + + #expect(env["COPILOT_API_TOKEN"] == "config_token") + #expect(snapshot.copilot?.apiToken == "config_token") + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - External Identifier Dedup + +@MainActor +struct CopilotExternalIdentifierTests { + @Test + func `addTokenAccount persists external identifier`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-add") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + + let account = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(account.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount preserves identifier when not provided`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-preserve") + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "gh_token_1", + externalIdentifier: "octocat") + let original = try #require(settings.tokenAccounts(for: .copilot).first) + + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "octocat (Business)", + token: "gh_token_2") + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == original.id) + #expect(updated.token == "gh_token_2") + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `updateTokenAccount writes identifier back for legacy accounts`() throws { + let settings = Self.makeSettingsStore(suite: "copilot-ext-id-backfill") + // Legacy account: no externalIdentifier (pre-feature). + settings.addTokenAccount(provider: .copilot, label: "octocat (Pro)", token: "gh_legacy") + let legacy = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(legacy.externalIdentifier == nil) + + settings.updateTokenAccount( + provider: .copilot, + accountID: legacy.id, + label: "octocat (Pro)", + token: "gh_refreshed", + externalIdentifier: .some("octocat")) + + let updated = try #require(settings.tokenAccounts(for: .copilot).first) + #expect(updated.id == legacy.id) + #expect(updated.externalIdentifier == "octocat") + } + + @Test + func `legacy Account N account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Account 1", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "octocat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `user renamed legacy account matches reauth by stored token identity`() async { + let legacy = Self.makeAccount(label: "Work GitHub", token: "old-token", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { account in + account.token == "old-token" ? Self.identity(id: 123, login: "OctoCat") : nil + }) + + #expect(matched?.id == legacy.id) + } + + @Test + func `stable external identifier match is preferred`() async { + let identified = Self.makeAccount( + label: "Personal", + token: "identified", + externalIdentifier: "github:user:123") + let legacy = Self.makeAccount(label: "octocat", token: "legacy", externalIdentifier: nil) + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [legacy, identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + } + + @Test + func `legacy login external identifier still matches and can be backfilled`() async { + let identified = Self.makeAccount(label: "Personal", token: "identified", externalIdentifier: "OctoCat") + let matched = await CopilotLoginFlow.matchExistingAccount( + existingAccounts: [identified], + identity: Self.identity(id: 123, login: "octocat"), + label: "octocat (Pro)", + legacyIdentityResolver: { _ in + Issue.record("Resolver should not run when legacy externalIdentifier matches") + return nil + }) + + #expect(matched?.id == identified.id) + #expect(CopilotLoginFlow.externalIdentifier(for: Self.identity(id: 123, login: "octocat")) == "github:user:123") + } + + @Test + func `decoding legacy token account JSON yields nil identifier`() throws { + let json = """ + { + "id": "11111111-1111-1111-1111-111111111111", + "label": "octocat", + "token": "gh_legacy", + "addedAt": 1700000000.0 + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + #expect(account.label == "octocat") + #expect(account.externalIdentifier == nil) + #expect(account.lastUsed == nil) + } + + private nonisolated static func identity(id: Int64, login: String) -> CopilotUsageFetcher.GitHubUserIdentity { + CopilotUsageFetcher.GitHubUserIdentity(id: id, login: login) + } + + private static func makeAccount( + label: String, + token: String, + externalIdentifier: String?) -> ProviderTokenAccount + { + ProviderTokenAccount( + id: UUID(), + label: label, + token: token, + addedAt: 1_700_000_000, + lastUsed: nil, + externalIdentifier: externalIdentifier) + } + + private static func makeSettingsStore(suite: String) -> SettingsStore { + SettingsStore( + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} + +// MARK: - Token Account Snapshot Error Messages + +@MainActor +struct TokenAccountSnapshotErrorMessageTests { + @Test + func `cancellation produces non-empty marker for per-account snapshot`() { + let store = Self.makeUsageStore() + let message = store.tokenAccountSnapshotErrorMessage(CancellationError()) + #expect(!message.isEmpty) + #expect(message.lowercased().contains("cancel")) + } + + @Test + func `cancellation is suppressed for global error path`() { + let store = Self.makeUsageStore() + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + } + + @Test + func `non-cancellation error preserves localized message`() { + let store = Self.makeUsageStore() + struct Boom: LocalizedError { + var errorDescription: String? { + "kaboom" + } + } + #expect(store.tokenAccountSnapshotErrorMessage(Boom()) == "kaboom") + #expect(store.tokenAccountErrorMessage(Boom()) == "kaboom") + } + + private static func makeUsageStore() -> UsageStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "copilot-snapshot-error-\(UUID().uuidString)"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 0b18ad89a..cae25e49c 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -81,6 +81,66 @@ struct SettingsStoreCoverageTests { settings.reloadTokenAccounts() } + @Test + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `copilot token accounts clear legacy api key fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotAPIToken = "legacy-token" + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "token-1") + + settings.copilotAPIToken = "legacy-token" + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.removeTokenAccount(provider: .copilot, accountID: account.id) + + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + @Test func `claude snapshot uses OAuth routing for OAuth token accounts`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index b5710878a..b9d50beba 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -389,6 +389,15 @@ struct UsageStoreCoverageTests { #expect(gate.streak == 0) } + @Test + func `token account error message ignores cancellation`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-token-account-cancel") + let store = Self.makeUsageStore(settings: settings) + + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(ProviderFetchError.noAvailableStrategy(.copilot)) != nil) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), From 82bbcde911b3493a99f4c1a12df6cfccf7322519 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sat, 2 May 2026 13:47:34 +0530 Subject: [PATCH 020/314] Update Copilot multi-account changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a03c6b3..a1b9e954e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.24 — Unreleased ### Providers & Usage +- Copilot: add multi-account support with GitHub OAuth sign-in, account switching, and per-account usage cards (#637). Thanks @ajmccall! - DeepSeek: add provider support with token-account balance tracking, paid vs. granted credit breakdown, and CLI support (#811). Thanks @willytop8! - Claude: add a peak-hours menu-card indicator with countdowns and a provider setting to hide it (#611). Thanks @hello-amed! - Cost history: show per-model cost details as a compact vertical list when hovering daily bars (#513). Thanks @iam-brain! From 3deab300b842bb7206e4890da043d23abd2dd311 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 02:08:19 +0100 Subject: [PATCH 021/314] test: isolate Claude fingerprint prompt load --- Package.resolved | 4 +- ...AuthCredentialsStoreSecurityCLITests.swift | 87 ++++++++++--------- 2 files changed, 48 insertions(+), 43 deletions(-) diff --git a/Package.resolved b/Package.resolved index 2dd5f9581..e7250ccc9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/steipete/Commander", "state" : { - "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", - "version" : "0.2.1" + "revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b", + "version" : "0.2.2" } }, { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift index 1e49708b0..c9a167f64 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreSecurityCLITests.swift @@ -763,50 +763,55 @@ struct ClaudeOAuthCredentialsStoreSecurityCLITests { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let securityData = self.makeCredentialsData( - accessToken: "security-load-with-prompt", - expiresAt: Date(timeIntervalSinceNow: 3600)) - let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( - modifiedAt: 321, - createdAt: 320, - persistentRefHash: "sentinel") + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let securityData = self.makeCredentialsData( + accessToken: "security-load-with-prompt", + expiresAt: Date(timeIntervalSinceNow: 3600)) + let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() + let sentinelFingerprint = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprint( + modifiedAt: 321, + createdAt: 320, + persistentRefHash: "sentinel") - let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( - .securityCLIExperimental, - operation: { - try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - try ProviderInteractionContext.$current.withValue(.userInitiated) { - try ClaudeOAuthCredentialsStore - .withClaudeKeychainFingerprintStoreOverrideForTesting( - fingerprintStore) - { - try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( - data: nil, - fingerprint: sentinelFingerprint) - { - try ClaudeOAuthCredentialsStore - .withSecurityCLIReadOverrideForTesting( - .data(securityData)) - { - try ClaudeOAuthCredentialsStore.load( - environment: [:], - allowKeychainPrompt: true, - respectKeychainPromptCooldown: false) - } - } + let creds = try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental, + operation: { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + try ProviderInteractionContext.$current.withValue(.userInitiated) { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainFingerprintStoreOverrideForTesting( + fingerprintStore) + { + try ClaudeOAuthCredentialsStore + .withClaudeKeychainOverridesForTesting( + data: nil, + fingerprint: sentinelFingerprint) + { + try ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting( + .data(securityData)) + { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: true, + respectKeychainPromptCooldown: false) + } + } + } } - } - } - }) + } + }) - #expect(creds.accessToken == "security-load-with-prompt") - #expect(fingerprintStore.fingerprint == nil) + #expect(creds.accessToken == "security-load-with-prompt") + #expect(fingerprintStore.fingerprint == nil) + } + } } } } From 236af15a888c7190ec9649faa6ab5551dcba43ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:03:00 +0100 Subject: [PATCH 022/314] fix: harden Claude OAuth usage handling --- .../Providers/Claude/ClaudeLoginFlow.swift | 9 ++- .../Claude/ClaudeProviderImplementation.swift | 1 - Sources/CodexBar/SettingsStore.swift | 3 + Sources/CodexBar/UsageStore.swift | 7 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 7 +- Tests/CodexBarTests/ClaudeUsageTests.swift | 41 ++++++++++++ .../SettingsStoreCoverageTests.swift | 15 +++++ ...sageStoreSessionQuotaTransitionTests.swift | 64 +++++++++++++++++++ docs/claude.md | 3 +- 9 files changed, 144 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 9550abb05..76002520e 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -2,7 +2,7 @@ import CodexBarCore @MainActor extension StatusItemController { - func runClaudeLoginFlow() async { + func runClaudeLoginFlow() async -> Bool { let phaseHandler: @Sendable (ClaudeLoginRunner.Phase) -> Void = { [weak self] phase in Task { @MainActor in switch phase { @@ -12,14 +12,19 @@ extension StatusItemController { } } let result = await ClaudeLoginRunner.run(timeout: 120, onPhaseChange: phaseHandler) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return false } self.loginPhase = .idle self.presentClaudeLoginResult(result) let outcome = self.describe(result.outcome) let length = result.output.count self.loginLogger.info("Claude login", metadata: ["outcome": outcome, "length": "\(length)"]) if case .success = result.outcome { + let metadata = self.store.metadata(for: .claude) + self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + self.settings.claudeUsageDataSource = .oauth self.postLoginNotification(for: .claude) + return true } + return false } } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index da82c1734..28e3382b4 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -210,7 +210,6 @@ struct ClaudeProviderImplementation: ProviderImplementation { @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runClaudeLoginFlow() - return true } @MainActor diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 6d3e76e4f..d26a48a51 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -379,6 +379,9 @@ extension SettingsStore { self.updateProviderConfig(provider: provider) { entry in entry.enabled = enabled } + if !enabled, self.selectedMenuProvider == provider { + self.selectedMenuProvider = nil + } } func rerunProviderDetection() { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 120c75a2e..62ee3f289 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -610,7 +610,7 @@ final class UsageStore { provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { - if let primary = snapshot.primary { + if let primary = snapshot.primary, Self.isSessionWindow(primary) { return (primary, .primary) } if provider == .copilot, let secondary = snapshot.secondary { @@ -619,6 +619,11 @@ final class UsageStore { return nil } + private static func isSessionWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { return true } + return minutes <= 6 * 60 + } + func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 82d919865..d94205884 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -836,7 +836,12 @@ extension ClaudeUsageFetcher { resetDescription: resetDescription) } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) else { + guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) + ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + else { throw ClaudeUsageError.parseFailed("missing session data") } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 6e400fd6b..6c7ff468f 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -882,6 +882,47 @@ struct ClaudeUsageTests { } } +struct ClaudeOAuthUsageMappingTests { + @Test + func `oauth usage falls back to weekly window when five hour is absent`() throws { + let json = """ + { + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 17, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 42) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + #expect(snapshot.secondary?.usedPercent == 42) + #expect(snapshot.opus?.usedPercent == 17) + } + + @Test + func `oauth usage falls back when five hour has no utilization`() throws { + let json = """ + { + "five_hour": { "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 9, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + + #expect(snapshot.primary.usedPercent == 9) + #expect(snapshot.primary.windowMinutes == 7 * 24 * 60) + } + + @Test + func `oauth usage throws when no usable windows are present`() { + let json = "{}" + + #expect(throws: ClaudeUsageError.self) { + try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + } + } +} + @Suite(.serialized) struct ClaudeAutoFetcherCharacterizationTests { private final class RequestLog: @unchecked Sendable { diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index cae25e49c..63d90b571 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -35,6 +35,21 @@ struct SettingsStoreCoverageTests { #expect(enabled.contains(.codex)) } + @Test + func `disabling selected provider clears menu selection`() throws { + let settings = Self.makeSettingsStore() + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + settings.selectedMenuProvider = .claude + + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + + #expect(settings.selectedMenuProvider == nil) + #expect(settings.enabledProvidersOrdered(metadataByProvider: metadata) == [.codex]) + } + @Test func `menu bar metric preferences and display modes`() { let settings = Self.makeSettingsStore() diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d9..b4cb0b6f8 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -77,4 +77,68 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.claude]) + } } diff --git a/docs/claude.md b/docs/claude.md index dba35bac2..7f22717ee 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -52,9 +52,10 @@ Usage source picker: - `anthropic-beta: oauth-2025-04-20` - Mapping: - `five_hour` → session window. - - `seven_day` → weekly window. + - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. - `extra_usage` → Extra usage cost (monthly spend/limit). +- Successful OAuth login enables Claude and selects OAuth as the usage source. - Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise. ## Web API (cookies) From e296a1f6e8d8732735b5464f9ad8b8acb4204f5d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:03:03 +0100 Subject: [PATCH 023/314] fix: scope CLI and Codex usage attribution --- Sources/CodexBarCLI/CLIHelpers.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CLIProviderSelectionTests.swift | 12 +++- .../CodexBarTests/CostUsageFetcherTests.swift | 64 +++++++++++++++++++ docs/cli.md | 2 + docs/codex.md | 3 +- 6 files changed, 80 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 0a6663b94..fa8754592 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -17,7 +17,6 @@ extension CodexBarCLI { if let rawOverride, let parsed = ProviderSelection(argument: rawOverride) { return parsed } - if enabled.count >= 3 { return .all } if enabled.count == 2 { let enabledSet = Set(enabled) let primary = Set(ProviderDescriptorRegistry.all.filter(\ .metadata.isPrimaryProvider).map(\ .id)) @@ -26,6 +25,7 @@ extension CodexBarCLI { } return .custom(enabled) } + if enabled.count >= 3 { return .custom(enabled) } if let first = enabled.first { return ProviderSelection(provider: first) } return .single(.codex) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0450d3092..4936cb43d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -760,7 +760,7 @@ enum CostUsageScanner { ?? info?["model_name"] as? String ?? payload["model"] as? String ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" + let model = currentModel ?? modelFromInfo ?? "gpt-5" func toInt(_ v: Any?) -> Int { if let n = v as? NSNumber { return n.intValue } diff --git a/Tests/CodexBarTests/CLIProviderSelectionTests.swift b/Tests/CodexBarTests/CLIProviderSelectionTests.swift index 2db996bd6..dd247168d 100644 --- a/Tests/CodexBarTests/CLIProviderSelectionTests.swift +++ b/Tests/CodexBarTests/CLIProviderSelectionTests.swift @@ -70,11 +70,19 @@ struct CLIProviderSelectionTests { } @Test - func `provider selection uses all when enabled`() { + func `provider selection uses enabled providers when three or more are enabled`() { let selection = CodexBarCLI.providerSelection( rawOverride: nil, enabled: [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) - #expect(selection.asList == ProviderSelection.all.asList) + #expect(selection.asList == [.codex, .claude, .zai, .cursor, .gemini, .antigravity, .factory, .copilot]) + } + + @Test + func `provider selection does not expand three enabled providers to all providers`() { + let enabled: [UsageProvider] = [.codex, .claude, .copilot] + let selection = CodexBarCLI.providerSelection(rawOverride: nil, enabled: enabled) + #expect(selection.asList == enabled) + #expect(!selection.asList.contains(.gemini)) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 0cfd90ea3..21884acc0 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -198,4 +198,68 @@ struct CostUsageFetcherTests { totalTokens: 205), ]) } + + @Test + func `fetcher prefers turn context model over token count fallback`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 10) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + + let nativeTurnContext: [String: Any] = [ + "type": "turn_context", + "timestamp": iso0, + "payload": [ + "model": "openai/gpt-5.4", + ], + ] + let nativeTokenCount: [String: Any] = [ + "type": "event_msg", + "timestamp": iso1, + "payload": [ + "type": "token_count", + "info": [ + "model": "gpt-5", + "total_token_usage": [ + "input_tokens": 100, + "cached_input_tokens": 20, + "output_tokens": 10, + ], + ], + ], + ] + _ = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([nativeTurnContext, nativeTokenCount])) + + let nativeOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + let cost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 10) ?? 0 + + #expect(snapshot.daily.first?.modelBreakdowns == [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: cost, + totalTokens: 110), + ]) + } } diff --git a/docs/cli.md b/docs/cli.md index a13c8f830..84ea928a2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -44,6 +44,8 @@ See `docs/configuration.md` for the schema. - `--refresh` ignores cached scans. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). + - With three or more providers enabled, the default stays scoped to enabled providers; use `--provider all` to query + every registered provider. - `--account