Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- General settings: turn Low Power Mode into an Off/On/Automatic preference, with Automatic following the system Low Power Mode state (#2995). Thanks @elijahfriedman!

### Fixed
- Claude spend: price bare first-party model IDs from their models.dev vendor catalog, preserve explicit routes, and leave ambiguous cross-vendor matches unpriced (#3002). Thanks @Yuxin-Qiao!
- Menu: prevent the system menu highlight from painting behind provider detail cards on macOS 27 beta (#2998). Thanks @Tan1103!
- Cursor: keep an API-validated usage result when the Keychain cache is temporarily unavailable, instead of treating it as a missing or replaced session (#3000). Thanks @hxy91819!
- Usage & Spend: sum priced subscriptions into a ~$ partial estimate with coverage shown when some subscriptions lack prices, instead of hiding the total (#3001). Thanks @Yuxin-Qiao!
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/CodexLocalDataScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct CodexLocalDataScope: Sendable, Equatable {
func applying(to options: CostUsageScanner.Options) -> CostUsageScanner.Options {
var copy = options
copy.codexSessionsRoot = self.sessionsRoot
copy.codexTraceDatabaseURL = copy.codexTraceDatabaseURL ?? self.stateDatabaseURL
return copy
}

Expand Down
6 changes: 5 additions & 1 deletion Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,11 @@ public struct CostUsageFetcher: Sendable {
targets.insert(ModelsDevPricingTarget(providerID: target.providerID, modelID: target.modelID))
}
} else {
targets.insert(ModelsDevPricingTarget(providerID: "anthropic", modelID: breakdown.modelName))
for target in CostUsagePricing.claudeModelsDevPricingTargets(for: breakdown.modelName) {
targets.insert(ModelsDevPricingTarget(
providerID: target.providerID,
modelID: target.modelID))
}
}
}
}
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 = "3c1ec2b780582978"
static let value = "50cb4b9e11791432"
}
3 changes: 2 additions & 1 deletion Sources/CodexBarCore/PiSessionCostScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ enum PiSessionCostScanner {
modelsDevArtifact: modelsDevArtifact,
formulaVersion: Self.costFormulaVersion,
parserHash: CodexParserHash.value,
modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union(["anthropic"])))
modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union(
Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs))))
}

private static func requestedWindowExpandsCache(
Expand Down
101 changes: 99 additions & 2 deletions Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -763,8 +763,7 @@ enum CostUsagePricing {
: currentPricing,
tokens: tokens)
}
if let lookup = self.modelsDevLookup(
providerID: self.claudeModelsDevProviderID,
if let lookup = self.claudeModelsDevLookup(
model: model,
catalog: modelsDevCatalog,
cacheRoot: modelsDevCacheRoot)
Expand Down Expand Up @@ -850,3 +849,101 @@ enum CostUsagePricing {
cacheRoot: cacheRoot)
}
}

extension CostUsagePricing {
/// Bare Claude-routed IDs may match first-party models.dev vendors. Recognizable model families
/// stay with their vendor, while unknown bare IDs must have one unambiguous catalog match.
/// Provider-specific by design: first-party vendor routing for bare Claude model IDs.
static let claudeFirstPartyModelsDevProviderIDs: [String] = [
Self.claudeModelsDevProviderID,
"openai",
"google",
"moonshot",
"kimi-for-coding",
"minimax",
"deepseek",
]

static func claudeModelsDevPricingTargets(for rawModel: String) -> [(providerID: String, modelID: String)] {
let trimmed = rawModel.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
if let slash = trimmed.firstIndex(of: "/") {
let codexTargets = self.codexModelsDevPricingTargets(for: trimmed)
if !codexTargets.isEmpty {
return codexTargets
}

let routeID = String(trimmed[..<slash]).lowercased()
let modelID = String(trimmed[trimmed.index(after: slash)...])
guard !routeID.isEmpty, !modelID.isEmpty,
self.claudeFirstPartyModelsDevProviderIDs.contains(routeID)
else { return [] }
return self.claudeModelsDevModelIDs(for: modelID).map { (routeID, $0) }
}

let providerIDs = self.claudeFirstPartyModelsDevPreferredProviderIDs(for: trimmed)
?? self.claudeFirstPartyModelsDevProviderIDs
let modelIDs = self.claudeModelsDevModelIDs(for: trimmed)
return providerIDs.flatMap { providerID in
modelIDs.map { (providerID, $0) }
}
}

private static func claudeModelsDevModelIDs(for rawModel: String) -> [String] {
let normalized = self.normalizeClaudeModel(rawModel)
return normalized == rawModel ? [rawModel] : [rawModel, normalized]
Comment on lines +892 to +894

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 Apply OpenAI alias normalization to bare Claude routes

When a Claude transcript contains the recognizable bare OpenAI alias gpt-5.6, this helper applies only normalizeClaudeModel, so it searches the OpenAI catalog for gpt-5.6 but not the canonical gpt-5.6-sol. A catalog containing only the canonical entry is already a supported case in CostUsagePricingTests, and this new Claude routing path consequently leaves that usage unpriced even though the equivalent Codex lookup resolves the alias. Use the owning vendor's normalization when constructing targets for recognized non-Claude families.

Useful? React with 👍 / 👎.

}

private static func claudeFirstPartyModelsDevPreferredProviderIDs(for rawModel: String) -> [String]? {
let model = self.normalizeClaudeModel(rawModel).lowercased()
if model.hasPrefix("claude-") { return [self.claudeModelsDevProviderID] }
let openAIReasoningFamily = ["o1", "o3", "o4"].contains {
model == $0 || model.hasPrefix("\($0)-")
}
if openAIReasoningFamily
|| ["gpt-", "chatgpt-", "text-embedding-"].contains(where: model.hasPrefix)
{
// Provider-specific by design: recognizable model families stay in their owning first-party catalog.
return ["openai"]
}
if ["gemini-", "gemma-", "deep-research-", "veo-", "lyria-"].contains(where: model.hasPrefix) {
return ["google"]
}
if model == "kimi-for-coding" || model == "k3" || model.hasPrefix("k3-") {
return ["kimi-for-coding"]
}
if model.hasPrefix("kimi-") || model.hasPrefix("moonshot-") {
return ["moonshot", "kimi-for-coding"]
}
if model.hasPrefix("minimax-") { return ["minimax"] }
if model.hasPrefix("deepseek-") { return ["deepseek"] }
return nil
}

fileprivate static func claudeModelsDevLookup(
model rawModel: String,
catalog: ModelsDevCatalog?,
cacheRoot: URL?) -> ModelsDevPricingLookup?
{
let trimmed = rawModel.trimmingCharacters(in: .whitespacesAndNewlines)
let hasExplicitRoute = trimmed.contains("/")
let hasPreferredVendor = self.claudeFirstPartyModelsDevPreferredProviderIDs(for: trimmed) != nil
var matches: [ModelsDevPricingLookup] = []
for target in self.claudeModelsDevPricingTargets(for: rawModel) {
if let lookup = self.modelsDevLookup(
providerID: target.providerID,
model: target.modelID,
catalog: catalog,
cacheRoot: cacheRoot)
{
if hasExplicitRoute || hasPreferredVendor {
return lookup
}
matches.append(lookup)
}
}
let matchedProviderIDs = Set(matches.map(\.pricing.providerID))
guard matchedProviderIDs.count == 1 else { return nil }
return matches.first
}
}
7 changes: 5 additions & 2 deletions Tests/CodexBarTests/CodexLocalProjectUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ struct CodexLocalProjectUsageTests {
func `local data scope avoids persisting raw Codex home paths`() {
let home = FileManager.default.temporaryDirectory
.appendingPathComponent("workspaces-private-home", isDirectory: true)
let scope = CodexLocalDataScope.resolve(options: CostUsageScanner.Options(
codexSessionsRoot: home.appendingPathComponent("sessions", isDirectory: true)))
let options = CostUsageScanner.Options(
codexSessionsRoot: home.appendingPathComponent("sessions", isDirectory: true))
let scope = CodexLocalDataScope.resolve(options: options)
let scopedOptions = scope.applying(to: options)

#expect(scope.codexHome == home.standardizedFileURL)
#expect(scope.identifier.hasPrefix("codex-workspaces:"))
#expect(!scope.identifier.contains(home.path))
#expect(scopedOptions.codexTraceDatabaseURL == scope.stateDatabaseURL)
}

@Test
Expand Down
43 changes: 43 additions & 0 deletions Tests/CodexBarTests/CostUsageFetcherUnknownModelPricingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,40 @@ struct CostUsageFetcherUnknownModelPricingTests {
#expect(abs((breakdown.costUSD ?? 0) - 0.0000084) < 0.0000001)
}

@Test
func `fetcher reprices a bare claude vendor model after an on demand catalog refresh`() async throws {
let fixture = try UnknownModelPricingFixture()
defer { fixture.environment.cleanup() }
let assistant: [String: Any] = [
"type": "assistant",
"timestamp": fixture.environment.isoString(for: fixture.day),
"message": [
"model": "deepseek-v4-flash",
"usage": [
"input_tokens": 100,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 10,
],
],
]
_ = try fixture.environment.writeClaudeProjectFile(
relativePath: "project-a/unknown-vendor-model.jsonl",
contents: fixture.environment.jsonl([assistant]))

let snapshot = try await CostUsageFetcher.loadTokenSnapshot(
provider: .claude,
now: fixture.day,
refreshPricingInBackground: false,
scannerOptions: fixture.options,
modelsDevClient: ModelsDevClient(transport: CostUsageFetcherModelsDevTransport(
data: fixture.refreshedCatalog)))

let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first)
#expect(breakdown.modelName == "deepseek-v4-flash")
#expect(abs((breakdown.costUSD ?? 0) - 0.0000168) < 0.0000001)
}

@Test
func `pricing retry preserves disabled pi session merging`() async throws {
let fixture = try UnknownModelPricingFixture()
Expand Down Expand Up @@ -273,6 +307,15 @@ private struct UnknownModelPricingFixture {
}
}
},
"deepseek": {
"id": "deepseek",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"cost": { "input": 0.14, "output": 0.28 }
}
}
},
"anthropic": {
"id": "anthropic",
"models": { "claude-new": { "id": "claude-new", "cost": { "input": 3, "output": 15 } } }
Expand Down
158 changes: 158 additions & 0 deletions Tests/CodexBarTests/CostUsagePricingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,164 @@ extension CostUsagePricingTests {
#expect(cost == expected)
}

@Test
func `claude cost prices bare first-party model IDs from vendor catalogs`() throws {
let root = try Self.seedModelsDevCache("""
{
"anthropic": {
"id": "anthropic",
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"cost": { "input": 3, "output": 15 }
},
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"cost": { "input": 99, "output": 199 }
}
}
},
"openai": {
"id": "openai",
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"cost": { "input": 1, "output": 2 }
}
}
},
"deepseek": {
"id": "deepseek",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"cost": { "input": 0.14, "output": 0.28 }
}
}
},
"opencode-go": {
"id": "opencode-go",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"cost": { "input": 0.07, "output": 0.14 }
}
}
}
}
""")

let bare = CostUsagePricing.claudeCostUSD(
model: "deepseek-v4-flash",
inputTokens: 100,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)
let prefixed = CostUsagePricing.claudeCostUSD(
model: "opencode-go/deepseek-v4-flash",
inputTokens: 100,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)
let anthropic = CostUsagePricing.claudeCostUSD(
model: "claude-sonnet-4-6",
inputTokens: 10,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)

#expect(bare == (100.0 * 0.14e-6) + (5.0 * 0.28e-6))
#expect(prefixed == (100.0 * 0.07e-6) + (5.0 * 0.14e-6))
#expect(anthropic == (10.0 * 3e-6) + (5.0 * 15e-6))
#expect(CostUsagePricing.claudeModelsDevPricingTargets(for: "deepseek-v4-flash").first?.providerID
== "deepseek")
#expect(CostUsagePricing.claudeModelsDevPricingTargets(for: "claude-sonnet-4-6").first?.providerID
== "anthropic")
#expect(Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs).isSuperset(of: [
"anthropic",
"openai",
"google",
"moonshot",
"kimi-for-coding",
"minimax",
"deepseek",
]))
}

@Test
func `claude cost rejects an ambiguous bare model catalog collision`() throws {
let root = try Self.seedModelsDevCache("""
{
"openai": {
"id": "openai",
"models": {
"shared-model": {
"id": "shared-model",
"cost": { "input": 1, "output": 2 }
}
}
},
"google": {
"id": "google",
"models": {
"shared-model": {
"id": "shared-model",
"cost": { "input": 3, "output": 4 }
}
}
}
}
""")

let cost = CostUsagePricing.claudeCostUSD(
model: "shared-model",
inputTokens: 100,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)

#expect(cost == nil)
}

@Test
func `claude cost does not cross charge a prefixed route onto first-party rates`() throws {
let root = try Self.seedModelsDevCache("""
{
"deepseek": {
"id": "deepseek",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"cost": { "input": 0.14, "output": 0.28 }
}
}
}
}
""")

let cost = CostUsagePricing.claudeCostUSD(
model: "opencode-go/deepseek-v4-flash",
inputTokens: 100,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)
let unknownRoute = CostUsagePricing.claudeCostUSD(
model: "unknown-route/deepseek-v4-flash",
inputTokens: 100,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
outputTokens: 5,
modelsDevCacheRoot: root)

#expect(cost == nil)
#expect(unknownRoute == nil)
}

private static func seedModelsDevCache(_ json: String) throws -> URL {
let root = try Self.cacheRoot()
let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8))
Expand Down
Loading