diff --git a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift index 29241390d0..ed1cccf6a7 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift @@ -33,6 +33,9 @@ extension StatusItemController { if case .success = result.outcome { let metadata = self.store.metadata(for: .claude) self.settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) + if self.settings.claudeUsageDataSource == .web { + self.settings.claudeUsageDataSource = .auto + } self.postLoginNotification(for: .claude) return true } diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index d46bf4b5b2..0a24b644cf 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "48ac20dad61e9a7f" + static let value = "588b3c4be6e47928" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3727bf2ce2..4b8a5ea56b 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -456,10 +456,27 @@ enum CostUsagePricing { private static let codexModelsDevProviderID = "openai" private static let claudeModelsDevProviderID = "anthropic" + private static let proxyPrefixes = [ + "cli-proxy-", "cli-proxy/", "proxy-", "proxy/", + "openai/", "anthropic/", "anthropic.", "google/", "deepseek/", + ] + + private static func normalizeModelAlias(_ input: String) -> String { + switch input { + case "gpt4o": "gpt-4o" + case "gpt4o-mini": "gpt-4o-mini" + case "gpt4": "gpt-4" + case "gpt4-turbo": "gpt-4-turbo" + case "gpt3.5-turbo", "gpt35-turbo": "gpt-3.5-turbo" + default: input + } + } + static func normalizeCodexModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("openai/") { - trimmed = String(trimmed.dropFirst("openai/".count)) + for prefix in self.proxyPrefixes where trimmed.hasPrefix(prefix) { + trimmed = String(trimmed.dropFirst(prefix.count)) + break } // OpenAI routes the unsuffixed gpt-5.6 alias to Sol. @@ -467,17 +484,19 @@ enum CostUsagePricing { return "gpt-5.6-sol" } - if self.codex[trimmed] != nil { - return trimmed + let normalized = self.normalizeModelAlias(trimmed) + if self.codex[normalized] != nil { + return normalized } if let datedSuffix = trimmed.range(of: #"-\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) { let base = String(trimmed[.. Bool { @@ -491,8 +510,9 @@ enum CostUsagePricing { static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasPrefix("anthropic.") { - trimmed = String(trimmed.dropFirst("anthropic.".count)) + for prefix in self.proxyPrefixes where trimmed.hasPrefix(prefix) { + trimmed = String(trimmed.dropFirst(prefix.count)) + break } if let lastDot = trimmed.lastIndex(of: "."), @@ -515,7 +535,7 @@ enum CostUsagePricing { } } - return trimmed + return self.normalizeModelAlias(trimmed) } static func codexCostUSD( @@ -733,12 +753,43 @@ enum CostUsagePricing { tokens: tokens) } - guard let pricing = self.claude[key] else { return nil } - return self.claudeCostUSD( - pricing: pricing, - tokens: tokens) + if let pricing = self.claude[key] { + return self.claudeCostUSD( + pricing: pricing, + tokens: tokens) + } + + // Cross-provider fallback for non-Claude models logged in Claude Code (e.g. via CLIProxyApi) + let totalPromptTokens = inputTokens + cacheReadInputTokens + cacheCreationInputTokens + if let codexCost = self.codexCostUSD( + model: key, + inputTokens: totalPromptTokens, + cachedInputTokens: cacheReadInputTokens, + outputTokens: outputTokens, + cacheWriteInputTokens: 0, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + return codexCost + } + + if let lookup = self.modelsDevLookupAnyProvider( + model: key, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: lookup.pricing, + tokens: tokens) + } + + return nil } +} + +// MARK: - Claude Cost Usage Calculation +extension CostUsagePricing { private static func claudeCostUSD( pricing: ClaudePricing, tokens: ClaudeCostTokens) -> Double @@ -808,4 +859,25 @@ enum CostUsagePricing { modelID: model, cacheRoot: cacheRoot) } + + private static func modelsDevLookupAnyProvider( + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(modelID: model) + } + + for providerID in ["openai", "anthropic", "google", "deepseek"] { + if let lookup = ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + { + return lookup + } + } + return nil + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift index 6915086291..a3fd316af1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -68,6 +68,15 @@ struct ModelsDevCatalog: Codable, Equatable { return self.providers[providerID]?.pricing(modelID: rawModelID) } + func pricing(modelID rawModelID: String) -> ModelsDevPricingLookup? { + for providerID in self.providers.keys.sorted() { + if let lookup = self.pricing(providerID: providerID, modelID: rawModelID) { + return lookup + } + } + return nil + } + func isPlausibleRefresh() -> Bool { // These are the direct pricing sources CodexBar relies on. Requiring both // rejects empty/partial responses without comparing against a fallback- diff --git a/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift new file mode 100644 index 0000000000..20e2207857 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift @@ -0,0 +1,153 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct ClaudeIssue2403ReproductionTests { + private func makeSettings(suiteSuffix: String) -> SettingsStore { + let suite = "ClaudeIssue2403ReproductionTests-\(suiteSuffix)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `web refresh failure disconnects after login`() async throws { + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + + let settings = self.makeSettings(suiteSuffix: "web-unauthorized") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerDetectionCompleted = true + settings.claudeUsageDataSource = .web + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + // 1. Simulate user clicking "Sign in with Claude Code..." which runs login runner and succeeds + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + onPhaseChange(.waitingBrowser) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Successfully logged in", + authLink: nil) + } + + #expect(didLogin) + #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) + } + + // 2. Immediately after login, store refreshes provider. + // If web API is unauthorized (e.g. browser not logged in), web fetch fails with unauthorized + store.errors[.claude] = ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription + + // 3. Verify menu actions immediately revert to re-login / disconnect state + let actions = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + + #expect(actions.contains { + $0.0 == "Re-login at claude.ai" && $0.1 == .loginToProvider(url: "https://claude.ai/") + }) + } + + @Test + func `login flow succeeds but stale token account causes immediate refresh failure and disconnect`() async throws { + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + + let settings = self.makeSettings(suiteSuffix: "token-account-stale") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerDetectionCompleted = true + settings.claudeUsageDataSource = .auto + + // User previously configured a stale/invalid token account + settings.addTokenAccount(provider: .claude, label: "Old Account", token: "invalid-cookie-session-token") + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + // 1. User signs in with Claude Code CLI + await withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let didLogin = await controller.runClaudeLoginFlow { _, onPhaseChange in + onPhaseChange(.requesting) + await Task.yield() + return ClaudeLoginRunner.Result( + outcome: .success, + output: "Logged in successfully", + authLink: nil) + } + + #expect(didLogin) + } + + // 2. Token account override causes fetch to fail on refresh + store.errors[.claude] = "Claude session key invalid" + + withStatusItemControllerForTesting(store: store, settings: settings, fetcher: fetcher) { controller in + let model = controller.menuCardModel(for: .claude) + #expect(model?.subtitleStyle == .error) + #expect(model?.subtitleText == "Claude session key invalid") + } + } + + @Test + func `oauth unauthorized error after login forces terminal re-auth prompt`() { + let settings = self.makeSettings(suiteSuffix: "oauth-unauthorized") + settings.claudeUsageDataSource = .oauth + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + store.errors[.claude] = ClaudeOAuthFetchError.unauthorized.localizedDescription + + let actions = MenuDescriptor.build( + provider: .claude, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + .sections + .flatMap(\.entries) + .compactMap { entry -> (String, MenuDescriptor.MenuAction)? in + guard case let .action(label, action) = entry else { return nil } + return (label, action) + } + + #expect(actions.contains { + $0.0 == "Open Terminal" && $0.1 == .openTerminal(command: "claude") + }) + } +} diff --git a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift index eeba8ccb68..2203aef497 100644 --- a/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift @@ -41,7 +41,8 @@ struct ClaudeLoginFlowTests { #expect(controller.loginPhase == .idle) } - #expect(settings.claudeUsageDataSource == source) + let expectedSource: ClaudeUsageDataSource = (source == .web) ? .auto : source + #expect(settings.claudeUsageDataSource == expectedSource) #expect(settings.isProviderEnabledCached(provider: .claude, metadataByProvider: registry.metadata)) } } diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 5cacd77a4e..10f647904d 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -775,4 +775,119 @@ struct CostUsageScannerClaudeRegressionTests { #expect(datedCost != nil) #expect(baseCost == datedCost) } + + @Test + func `prices cli proxy api gpt model in claude code log issue 2393`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 22) + let iso0 = env.isoString(for: day) + let proxyModel = "cli-proxy-gpt-5.4" + + _ = try env.writeClaudeProjectFile( + relativePath: "project-cli-proxy/session-proxy.jsonl", + contents: env.jsonl([ + [ + "type": "assistant", + "timestamp": iso0, + "sessionId": "cli-proxy-session", + "requestId": "req_proxy_1", + "isSidechain": false, + "message": [ + "id": "msg_proxy_1", + "model": proxyModel, + "usage": [ + "input_tokens": 1500, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 500, + "output_tokens": 300, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(10), + options: options) + + #expect(report.data.count == 1) + let dayData = report.data[0] + #expect(dayData.totalTokens == 2300) + + // Verify Issue 2393 resolution: + // Token counts are attributed to the CLIProxy model, and costUSD is successfully calculated via prefix & alias + // normalization. + let breakdowns = try #require(dayData.modelBreakdowns) + #expect(breakdowns.count == 1) + let breakdown = breakdowns[0] + #expect(breakdown.modelName == "gpt-5.4") + #expect(breakdown.totalTokens == 2300) + #expect(breakdown.costUSD != nil) + } + + @Test + func `prices gpt model in claude code log issue 2393`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 22) + let iso0 = env.isoString(for: day) + let proxyModel = "gpt-5.4" + + _ = try env.writeClaudeProjectFile( + relativePath: "project-cli-proxy/session-gpt.jsonl", + contents: env.jsonl([ + [ + "type": "assistant", + "timestamp": iso0, + "sessionId": "cli-proxy-session", + "requestId": "req_proxy_1", + "isSidechain": false, + "message": [ + "id": "msg_proxy_1", + "model": proxyModel, + "usage": [ + "input_tokens": 1500, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 500, + "output_tokens": 300, + ], + ], + ], + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: nil, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day.addingTimeInterval(10), + options: options) + + #expect(report.data.count == 1) + let dayData = report.data[0] + #expect(dayData.totalTokens == 2300) + + let breakdowns = try #require(dayData.modelBreakdowns) + #expect(breakdowns.count == 1) + let breakdown = breakdowns[0] + #expect(breakdown.modelName == proxyModel) + #expect(breakdown.totalTokens == 2300) + #expect(breakdown.costUSD != nil) + } }