Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
c82dab5
Add CLIProxyAPI cost attribution
EClinick Jul 25, 2026
5d61c30
Tighten proxy attribution evidence
EClinick Jul 26, 2026
023c0f1
Fix Linux telemetry networking import
EClinick Jul 26, 2026
c3bedb0
Fix CLIProxyAPI attribution edge cases
EClinick Jul 27, 2026
0f4bda0
Preserve CLIProxyAPI usage across scoped scans
EClinick Jul 27, 2026
e9b111e
Prune stale CLIProxyAPI telemetry
EClinick Jul 27, 2026
02951c4
Preserve complete CLIProxyAPI attribution
EClinick Jul 27, 2026
b9192ab
Retain proxy spend after forced refresh
EClinick Jul 27, 2026
19f7f1b
Harden CLIProxyAPI telemetry maintenance
EClinick Jul 27, 2026
b02b484
Fix proxied model pricing boundaries
EClinick Jul 27, 2026
3ce2fcc
Retry proxy-only unknown pricing
EClinick Jul 27, 2026
eb1f283
Isolate and index proxy attribution
EClinick Jul 27, 2026
0acb769
Cache CLIProxyAPI request logs
EClinick Jul 27, 2026
b28c18d
Hydrate cached CLIProxyAPI usage
EClinick Jul 27, 2026
ee96859
Prefer dated CLIProxyAPI request logs
EClinick Jul 27, 2026
2372a08
Refresh resolved proxy model pricing
EClinick Jul 27, 2026
d20f52a
Refresh proxy pricing by provider
EClinick Jul 27, 2026
c82e0d5
Resolve proxy model vendors after refresh
EClinick Jul 27, 2026
a87a694
Keep proxy auth inventory ambiguous
EClinick Jul 27, 2026
a4795f3
Merge upstream main into CLIProxyAPI attribution
EClinick Jul 28, 2026
3bf86e5
Stabilize attribution breakdown ordering
EClinick Jul 28, 2026
c75c92f
Stabilize dashboard attribution ranks
EClinick Jul 28, 2026
5811859
Merge latest upstream main
EClinick Jul 28, 2026
f933bdb
Retry failed telemetry persistence
EClinick Jul 28, 2026
9cb7f0f
Merge current upstream main
EClinick Jul 28, 2026
bf8a117
Split telemetry retry test
EClinick Jul 28, 2026
30604c7
Harden telemetry collection
EClinick Jul 28, 2026
4a60621
Isolate telemetry staging tests
EClinick Jul 28, 2026
5dcc12d
Keep proxy subscriptions isolated
EClinick Jul 28, 2026
4e845a7
Respect cost tracking opt out
EClinick Jul 28, 2026
282e01b
Recheck telemetry opt out and pricing
EClinick Jul 28, 2026
2f8747b
Fix attribution sort expectation
EClinick Jul 28, 2026
b5d9426
Complete attribution sort tie-breaks
EClinick Jul 28, 2026
895de6b
Split chart attribution sort coverage
EClinick Jul 28, 2026
01ffbe2
Remove feature changelog entry
EClinick Jul 28, 2026
09262e9
Stop proxy collection on removal
EClinick Jul 28, 2026
95028de
Persist proxy telemetry durably
EClinick Jul 28, 2026
f472866
Serialize proxy telemetry migration
EClinick Jul 28, 2026
ab2f183
Stabilize project attribution ordering
EClinick Jul 28, 2026
1d32f31
Skip empty proxy history scans
EClinick Jul 28, 2026
6731ef0
Exclude proxy usage from subscriptions
EClinick Jul 28, 2026
7461d2e
Preserve proxy telemetry timestamps
EClinick Jul 28, 2026
37ddfd8
Avoid shared timestamp formatter
EClinick Jul 28, 2026
b37f346
Preserve valid proxy queue records
EClinick Jul 28, 2026
dccfa4f
Merge upstream main
EClinick Jul 29, 2026
b03ec2b
Protect popped usage batches
EClinick Jul 29, 2026
6988c7f
Quarantine unresolved proxy usage
EClinick Jul 29, 2026
fb9769e
Clear durable cost caches
EClinick Jul 29, 2026
af15749
Merge upstream main
EClinick Jul 29, 2026
b5648f4
Purge proxy telemetry on disconnect
EClinick Jul 29, 2026
9bd15e8
Complete proxy telemetry cleanup
EClinick Jul 29, 2026
164bc73
Harden proxy cleanup lifecycle
EClinick Jul 30, 2026
d093217
Finish proxy disconnect cleanup
EClinick Jul 30, 2026
a579b7d
Merge main and address proxy review feedback
EClinick Jul 30, 2026
1fe0077
Merge concurrent proxy cleanup fixes
EClinick Jul 30, 2026
2908743
Address remaining proxy review feedback
EClinick Jul 30, 2026
e728e2a
Fix proxy collector lock location
EClinick Jul 30, 2026
6517067
Fix remaining proxy review issues
EClinick Jul 30, 2026
3bf52d6
Reject stale proxy cache time zones
EClinick Jul 30, 2026
7bad61f
Reject stale-zone proxy caches
EClinick Jul 30, 2026
32d681d
Merge remote-tracking branch 'fork/task/ec/codex-proxy-attribution' i…
EClinick Jul 30, 2026
ff3287d
Use released model fixtures
EClinick Jul 30, 2026
8fd2dd4
Drop cached proxy attribution on disconnect
EClinick Jul 30, 2026
2907270
Serialize cost cache clearing
EClinick Jul 30, 2026
0b8a0f2
Fix proxy telemetry retention cleanup
EClinick Jul 30, 2026
71592d4
Recheck proxy configuration before collection
EClinick Jul 30, 2026
20ada6b
Serialize proxy telemetry migration
EClinick Jul 30, 2026
fdf7763
Stabilize proxy cache timezone test
EClinick Jul 30, 2026
ff8417f
Tighten proxy attribution boundaries
EClinick Jul 30, 2026
53ff713
Drain in-flight proxy telemetry pops
EClinick Jul 30, 2026
008e9e6
Honor live proxy attribution results
EClinick Jul 30, 2026
202ffee
Exclude proxy attribution from share providers
EClinick Jul 30, 2026
851586b
Harden proxy telemetry cleanup
EClinick Jul 30, 2026
92b6cdd
Recheck proxy disconnect before reporting
EClinick Jul 30, 2026
ae3aaf1
Reconcile unkeyed proxy attribution
EClinick Jul 30, 2026
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
## 0.45.3 — Unreleased

### Fixed
- Usage & Spend: keep CLIProxyAPI-routed Claude Code sessions in the correct upstream total using request logs
and active provider credentials, with sanitized per-request telemetry when available, instead of splitting
unchanged usage into separate proxy and unknown-route rows.

## 0.45.2 — 2026-07-19

Expand Down
27 changes: 26 additions & 1 deletion Sources/CodexBar/CostHistoryChartMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import Charts
import CodexBarCore
import SwiftUI

private func claudeCodeModelProviderText(_ provider: CostUsageAttribution.ModelProvider) -> String {
switch provider {
case .openAI: "OpenAI model via Claude Code"
case .anthropic: "Anthropic model via Claude Code"
case .google: "Google model via Claude Code"
case .unknown: "Unknown model provider via Claude Code"
}
}

@MainActor
struct CostHistoryChartMenuView: View {
typealias DailyEntry = CostUsageDailyReport.Entry
Expand Down Expand Up @@ -573,7 +582,7 @@ struct CostHistoryChartMenuView: View {
}

private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool {
item.standardCostUSD != nil || item.priorityCostUSD != nil
item.attribution != nil || item.standardCostUSD != nil || item.priorityCostUSD != nil
}

private static func detailRowsViewportHeight(rowCount: Int, rowHeight: CGFloat) -> CGFloat {
Expand Down Expand Up @@ -839,6 +848,19 @@ struct CostHistoryChartMenuView: View {

private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? {
var parts: [String] = []
if let attribution = item.attribution {
switch attribution.route {
case .cliProxyAPI:
let route = if let upstream = attribution.upstream {
"\(upstream.displayName) · CLIProxyAPI via Claude Code"
} else {
"CLIProxyAPI via Claude Code"
}
parts.append(route)
case .unknown:
parts.append(claudeCodeModelProviderText(attribution.modelProvider))
}
}
if let standardCost = item.standardCostUSD {
var standardPart = "Std \(self.costString(standardCost))"
if let standardTokens = item.standardTokens {
Expand Down Expand Up @@ -897,6 +919,7 @@ extension CostHistoryChartMenuView {

struct VisibleModelBreakdownFingerprint: Equatable {
let modelName: String
let attribution: CostUsageAttribution?
let costBitPattern: UInt64?
let totalTokens: Int?
let standardCostBitPattern: UInt64?
Expand Down Expand Up @@ -976,6 +999,7 @@ extension CostHistoryChartMenuView {
models: session.modelBreakdowns.map { item in
VisibleModelBreakdownFingerprint(
modelName: item.modelName,
attribution: item.attribution,
costBitPattern: item.costUSD.map(\.bitPattern),
totalTokens: item.totalTokens,
standardCostBitPattern: item.standardCostUSD.map(\.bitPattern),
Expand All @@ -995,6 +1019,7 @@ extension CostHistoryChartMenuView {
modelBreakdowns: self.orderedBreakdownItems(entry.modelBreakdowns ?? []).map { item in
VisibleModelBreakdownFingerprint(
modelName: item.modelName,
attribution: item.attribution,
costBitPattern: item.costUSD.map(\.bitPattern),
totalTokens: item.totalTokens,
standardCostBitPattern: item.standardCostUSD.map(\.bitPattern),
Expand Down
161 changes: 160 additions & 1 deletion Sources/CodexBar/PreferencesSpendDashboardPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ func spendDashboardCoverageText(covered: Int, requested: Int) -> String {
"\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))"
}

func spendDashboardModelSourceText(
providerName: String,
attribution: CostUsageAttribution?) -> String
{
guard let attribution else { return providerName }
switch attribution.route {
case .cliProxyAPI:
if let upstream = attribution.upstream {
return "\(upstream.displayName) · CLIProxyAPI via Claude Code"
}
return "CLIProxyAPI via Claude Code"
case .unknown:
let modelProvider = switch attribution.modelProvider {
case .openAI: "OpenAI model"
case .anthropic: "Anthropic model"
case .google: "Google model"
case .unknown: "Unknown model provider"
}
return "\(providerName) · \(modelProvider) via Claude Code"
}
}

enum SpendDashboardModelHistoryPresentation: Equatable {
case unavailable
case empty
Expand All @@ -48,6 +70,11 @@ struct SpendDashboardPane: View {
@Bindable var settings: SettingsStore
@Bindable var store: UsageStore
@State private var controller: SpendDashboardController
@State private var cliProxyAPIBaseURL = CLIProxyAPIConnectionSettings.defaultBaseURL
@State private var cliProxyAPIManagementKey = ""
@State private var cliProxyAPIHasSavedConfiguration = false
@State private var cliProxyAPIStatus: String?
@State private var cliProxyAPIIsSaving = false

init(settings: SettingsStore, store: UsageStore) {
self.settings = settings
Expand All @@ -62,6 +89,7 @@ struct SpendDashboardPane: View {
VStack(alignment: .leading, spacing: 18) {
self.header
self.content
self.cliProxyAPISetup
self.provenance
self.shareAction
}
Expand All @@ -71,6 +99,7 @@ struct SpendDashboardPane: View {
.onAppear {
self.controller.refreshDateWindow()
self.controller.update(configuration: self.configuration)
self.loadCLIProxyAPIConfiguration()
}
.onChange(of: self.configuration) { _, configuration in
self.controller.update(configuration: configuration)
Expand Down Expand Up @@ -173,6 +202,132 @@ struct SpendDashboardPane: View {
}
}

private var cliProxyAPISetup: some View {
SpendDashboardPanel {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("CLIProxyAPI attribution", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
Spacer()
if self.cliProxyAPIHasSavedConfiguration {
Label("Configured", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.secondary)
}
}

Text(
"Connect CLIProxyAPI’s management usage queue to identify the exact upstream provider " +
"and whether it used OAuth or an API key. Enter the same plaintext " +
"remote-management secret key configured in CLIProxyAPI.")
.font(.caption)
.foregroundStyle(.secondary)

Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
Text("Server URL")
.foregroundStyle(.secondary)
TextField(
CLIProxyAPIConnectionSettings.defaultBaseURL,
text: self.$cliProxyAPIBaseURL)
.textFieldStyle(.roundedBorder)
}
GridRow {
Text("Management key")
.foregroundStyle(.secondary)
SecureField(
self.cliProxyAPIHasSavedConfiguration ? "Saved — enter to replace" : "Required",
text: self.$cliProxyAPIManagementKey)
.textFieldStyle(.roundedBorder)
}
}

HStack(spacing: 8) {
Button(self.cliProxyAPIHasSavedConfiguration ? "Save & test" : "Connect & test") {
Task {
await self.saveAndTestCLIProxyAPIConfiguration()
}
}
.disabled(self.cliProxyAPIIsSaving)

if self.cliProxyAPIHasSavedConfiguration {
Button("Remove", role: .destructive) {
self.removeCLIProxyAPIConfiguration()
}
.disabled(self.cliProxyAPIIsSaving)
}

if self.cliProxyAPIIsSaving {
ProgressView()
.controlSize(.small)
}
if let cliProxyAPIStatus {
Text(cliProxyAPIStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}

Text(
"Reading the queue removes the returned records from CLIProxyAPI. CodexBar stores a " +
"sanitized local copy for cost attribution and does not retain source, account, " +
"API-key, response-header, or failure-body fields.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}

private func loadCLIProxyAPIConfiguration() {
guard let configuration = CLIProxyAPIConnectionSettingsStore.load() else { return }
self.cliProxyAPIBaseURL = configuration.baseURL
self.cliProxyAPIHasSavedConfiguration = true
}

private func saveAndTestCLIProxyAPIConfiguration() async {
self.cliProxyAPIIsSaving = true
defer { self.cliProxyAPIIsSaving = false }

let existingKey = CLIProxyAPIConnectionSettingsStore.load()?.managementKey ?? ""
let enteredKey = self.cliProxyAPIManagementKey.trimmingCharacters(in: .whitespacesAndNewlines)
let configuration = CLIProxyAPIConnectionSettings(
baseURL: self.cliProxyAPIBaseURL,
managementKey: enteredKey.isEmpty ? existingKey : enteredKey)
guard configuration.isConfigured else {
self.cliProxyAPIStatus = "Enter a valid URL and management key."
return
}
guard CLIProxyAPIConnectionSettingsStore.save(configuration) else {
self.cliProxyAPIStatus = "Could not save the management key."
return
Comment thread
EClinick marked this conversation as resolved.
}

self.cliProxyAPIManagementKey = ""
self.cliProxyAPIHasSavedConfiguration = true
switch await self.store.collectCLIProxyAPIUsageNow() {
case .notConfigured:
self.cliProxyAPIStatus = "Configuration was not available."
case let .collected(count):
self.cliProxyAPIStatus = count == 0
? "Connected. No queued records."
: "Connected. Imported \(count) records."
self.controller.refresh()
case let .failed(message):
self.cliProxyAPIStatus = "Saved, but test failed: \(message)"
}
}

private func removeCLIProxyAPIConfiguration() {
guard CLIProxyAPIConnectionSettingsStore.clear() else {
self.cliProxyAPIStatus = "Could not remove the saved configuration."
return
}
self.cliProxyAPIManagementKey = ""
self.cliProxyAPIHasSavedConfiguration = false
self.cliProxyAPIStatus = "Configuration removed."
}

private var shareAction: some View {
HStack {
Spacer()
Expand Down Expand Up @@ -366,7 +521,11 @@ private struct SpendModelPanel: View {
SpendProviderIcon(provider: row.provider)
VStack(alignment: .leading, spacing: 2) {
Text(row.modelName).lineLimit(1)
Text(row.providerName).font(.caption).foregroundStyle(.secondary)
Text(spendDashboardModelSourceText(
providerName: row.providerName,
attribution: row.attribution))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(row.totalCost.map {
Expand Down
10 changes: 10 additions & 0 deletions Sources/CodexBar/SpendDashboardController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,16 @@ enum SpendDashboardSource {
encoder.append(entry.modelBreakdowns?.count)
for breakdown in entry.modelBreakdowns ?? [] {
encoder.append(breakdown.modelName)
encoder.append(breakdown.attribution?.client.rawValue ?? "")
encoder.append(breakdown.attribution?.route.rawValue ?? "")
encoder.append(breakdown.attribution?.modelProvider.rawValue ?? "")
encoder.append(breakdown.attribution?.upstream?.provider ?? "")
encoder.append(breakdown.attribution?.upstream?.authType.rawValue ?? "")
encoder.append(breakdown.attribution?.upstream?.model ?? "")
encoder.append(breakdown.attribution?.upstream?.executorType ?? "")
for evidence in breakdown.attribution?.evidence ?? [] {
encoder.append(evidence.rawValue)
}
encoder.append(breakdown.totalTokens)
encoder.append(breakdown.requestCount)
encoder.append(breakdown.costUSD)
Expand Down
44 changes: 39 additions & 5 deletions Sources/CodexBar/SpendDashboardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,43 @@ struct SpendDashboardModel: Equatable, Sendable {
}

struct ModelRow: Identifiable, Equatable, Sendable {
struct ID: Hashable, Sendable {
let provider: UsageProvider
let modelName: String
let attribution: CostUsageAttribution?
}

let rank: Int
let provider: UsageProvider
let providerName: String
let modelName: String
let totalTokens: Int?
let totalCost: Double?
let attribution: CostUsageAttribution?

var id: String {
"\(self.provider.rawValue):\(self.modelName)"
init(
rank: Int,
provider: UsageProvider,
providerName: String,
modelName: String,
totalTokens: Int?,
totalCost: Double?,
attribution: CostUsageAttribution? = nil)
{
self.rank = rank
self.provider = provider
self.providerName = providerName
self.modelName = modelName
self.totalTokens = totalTokens
self.totalCost = totalCost
self.attribution = attribution
}

var id: ID {
ID(
provider: self.provider,
modelName: self.modelName,
attribution: self.attribution)
}
}

Expand Down Expand Up @@ -128,6 +156,7 @@ struct SpendDashboardModel: Equatable, Sendable {
private struct ModelKey: Hashable {
let provider: UsageProvider
let modelName: String
let attribution: CostUsageAttribution?
}

private struct ModelAccumulator {
Expand Down Expand Up @@ -285,7 +314,10 @@ struct SpendDashboardModel: Equatable, Sendable {
for breakdown in breakdowns {
let name = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
let key = ModelKey(provider: input.provider, modelName: name)
let key = ModelKey(
provider: input.provider,
modelName: name,
attribution: breakdown.attribution)
var aggregate = aggregates[key] ?? ModelAccumulator(
providerName: input.modelProviderName,
tokens: 0,
Expand Down Expand Up @@ -324,7 +356,8 @@ struct SpendDashboardModel: Equatable, Sendable {
providerName: value.providerName,
modelName: key.modelName,
totalTokens: value.sawTokens && !value.invalidTokens && !value.overflowedTokens ? value.tokens : nil,
totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil)
totalCost: value.sawCost && !value.invalidCost && !value.overflowedCost ? value.cost : nil,
attribution: key.attribution)
}
.sorted { lhs, rhs in
switch (lhs.totalCost, rhs.totalCost) {
Expand All @@ -346,7 +379,8 @@ struct SpendDashboardModel: Equatable, Sendable {
providerName: row.providerName,
modelName: row.modelName,
totalTokens: row.totalTokens,
totalCost: row.totalCost)
totalCost: row.totalCost,
attribution: row.attribution)
}
return ModelSummary(rows: rows, completeness: completeness)
}
Expand Down
Loading