Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Sources/CodexBar/Providers/Claude/ClaudeLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
100 changes: 86 additions & 14 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -456,28 +456,47 @@ 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.
if trimmed == "gpt-5.6" {
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[..<datedSuffix.lowerBound])
if self.codex[base] != nil {
return base
let normalizedBase = self.normalizeModelAlias(base)
if self.codex[normalizedBase] != nil {
return normalizedBase
}
}
return trimmed
return normalized
}

static func isCodexUnattributedModel(_ raw: String) -> Bool {
Expand All @@ -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: "."),
Expand All @@ -515,7 +535,7 @@ enum CostUsagePricing {
}
}

return trimmed
return self.normalizeModelAlias(trimmed)
}

static func codexCostUSD(
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve cache-write tokens when pricing proxy OpenAI logs

When Claude Code logs a non-Claude model with cache_creation_input_tokens, totalPromptTokens already includes those cache-write tokens, but this call passes cacheWriteInputTokens: 0. codexCostUSD then treats the writes as ordinary uncached input, so proxy OpenAI models with cache-write tariffs (for example GPT-5.6 built-ins or models.dev cache_write rates) get incorrect costs; pass cacheCreationInputTokens as the cache-write subset instead.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down
153 changes: 153 additions & 0 deletions Tests/CodexBarTests/ClaudeIssue2403ReproductionTests.swift
Original file line number Diff line number Diff line change
@@ -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")
})
}
}
3 changes: 2 additions & 1 deletion Tests/CodexBarTests/ClaudeLoginFlowPolicyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Expand Down
Loading
Loading