Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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 = "66c7fb0c902d7da3"
}
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,49 @@ 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 {
if 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 +512,11 @@ 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 {
if trimmed.hasPrefix(prefix) {
trimmed = String(trimmed.dropFirst(prefix.count))
break
}
}

if let lastDot = trimmed.lastIndex(of: "."),
Expand All @@ -515,7 +539,7 @@ enum CostUsagePricing {
}
}

return trimmed
return self.normalizeModelAlias(trimmed)
}

static func codexCostUSD(
Expand Down Expand Up @@ -733,10 +757,37 @@ 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
}

private static func claudeCostUSD(
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