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 @@ -13,6 +13,7 @@
- z.ai/GLM: parse `CREDIT_LIMIT` quota entries from credit-based Coding Plans (lite/standard/pro) so usage no longer sticks at 100% remaining / 0% used and the 5-hour credit window drives the primary percentage and reset time (#2724, #2712). Thanks @stuible!

### Changed
- Codex: cost history now lives in a single SQLite store — bounded memory at any corpus size, append-linear catch-up, and no more multi-hundred-MB JSON decode on refresh (#2760). Thanks @xx205 for the accumulator design!
- CLI: dashboard snapshot identity now defaults to full; use `--identity redacted` to restore redacted emails.
- Provider plugins: run the same bundled JavaScript providers and local plugin CLI on Linux through a sandboxed QuickJS engine, removing the cut-over providers' Linux-only Swift twins.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ show an incident indicator.
- Provider-specific usage meters with reset countdowns.
- Optional Codex web dashboard enrichments (code review remaining, usage breakdown, credits history).
- Inline spend and usage charts for API-backed providers such as OpenAI, Claude Admin API, OpenRouter, LiteLLM, z.ai, MiniMax, Mistral, and AWS Bedrock.
- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories.
- Configurable cost-usage scans for Codex + Claude, plus reused chart UI for supported provider histories. Codex history uses a WAL-enabled SQLite store capped at 25,000 retained session entries and 256 MiB.
- A persistent Settings → Usage & Spend view for local 7/30-day estimates, grouped by native currency and limited to providers that expose cost history.
- Provider status polling with incident badges in the menu and icon overlay.
- Merge Icons mode to combine providers into one status item + switcher.
Expand Down
10 changes: 4 additions & 6 deletions Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ enum CodexLocalProjectUsageIndexer {
checkCancellation: checkCancellation)
try checkCancellation?()

let cache = CostUsageCacheIO.load(
provider: .codex,
let cache = CostUsageStoreAccess.read(
cacheRoot: scannerOptions.cacheRoot,
calendar: scannerOptions.calendar)
let catalogResult = CodexThreadCatalogReader.loadResult(options: scannerOptions)
Expand Down Expand Up @@ -158,8 +157,7 @@ enum CodexLocalProjectUsageIndexer {
since: since,
until: until,
calendar: options.calendar)
let cache = cacheOverride ?? CostUsageCacheIO.load(
provider: .codex,
let cache = cacheOverride ?? CostUsageStoreAccess.read(
cacheRoot: options.cacheRoot,
calendar: options.calendar)
let catalog = catalogOverride ?? CodexThreadCatalogReader.load(options: options)
Expand Down Expand Up @@ -698,7 +696,7 @@ extension CodexLocalProjectUsageIndexer {
let revisionParts = identity.rootsFingerprint.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }
let indexRevision = ([
identity.scopeSignature,
context.cache.producerKey ?? "",
CostUsageStore.cacheGeneration,
context.cache.codexPricingKey ?? "",
] + revisionParts)
.joined(separator: "|")
Expand Down Expand Up @@ -913,7 +911,7 @@ extension CodexLocalProjectUsageIndexer {
{
let roots = self.rootsFingerprint(CostUsageScanner.codexRootsFingerprint(options: options))
var parts = roots.sorted { $0.key < $1.key }.map { "root:\($0.key)=\($0.value)" }
parts.append("producer=\(cache.producerKey ?? "")")
parts.append("store=\(CostUsageStore.cacheGeneration)")
parts.append("pricing=\(cache.codexPricingKey ?? "")")
parts.append("priorityMetadata=\(cache.codexPriorityMetadataKey ?? "")")
if let catalogFingerprint {
Expand Down
8 changes: 6 additions & 2 deletions Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ struct CodexWorkspaceUsageFingerprintPayload: Encodable {
let priorityCostNanos: [String: [String: Int64]]?
let standardTokens: [String: [String: Int]]?
let priorityTokens: [String: [String: Int]]?
let rows: [CostUsageScanner.CodexUsageRow]?
let rowCount: Int?
let lastRowIndex: Int?
let tokenIndexAnchor: CostUsageCodexTokenIndexAnchor?

init(usage: CostUsageFileUsage) {
self.days = usage.days
Expand All @@ -38,7 +40,9 @@ struct CodexWorkspaceUsageFingerprintPayload: Encodable {
self.priorityCostNanos = usage.codexPriorityCostNanos
self.standardTokens = usage.codexStandardTokens
self.priorityTokens = usage.codexPriorityTokens
self.rows = usage.codexRows
self.rowCount = usage.codexRows?.count
self.lastRowIndex = usage.codexRows?.compactMap(\.eventIndex).max()
self.tokenIndexAnchor = usage.codexTokenIndexAnchor
}
}

Expand Down
36 changes: 28 additions & 8 deletions Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
self.size = usage.size
self.parsedBytes = usage.parsedBytes ?? -1
self.sessionID = usage.codexSession?.sessionId ?? usage.sessionId ?? ""
self.producerKey = cache.producerKey ?? ""
self.producerKey = CostUsageStore.cacheGeneration
self.pricingKey = cache.codexPricingKey ?? ""
self.contentFingerprint = usage.codexWorkspaceUsageFingerprintValue()
}
Expand Down Expand Up @@ -126,7 +126,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
else { return nil }
}
if let cache {
guard Self.columnString(statement, at: 3) == cache.producerKey,
guard Self.columnString(statement, at: 3) == CostUsageStore.cacheGeneration,
Self.columnString(statement, at: 4) == cache.codexPricingKey,
Self.columnString(statement, at: 5) == Self.cacheFingerprint(cache)
else { return nil }
Expand Down Expand Up @@ -531,7 +531,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
else { throw SidecarError.statementFailed }
defer { sqlite3_finalize(statement) }
Self.bind(generation, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed }
guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) }
}

private func importChangedRollouts(
Expand Down Expand Up @@ -625,7 +625,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
let rootsData = try JSONEncoder().encode(rootsFingerprint)
Self.bind(rootsData, to: state, at: 2)
Self.bind(catalog.fingerprint, to: state, at: 3)
Self.bind(cache.producerKey, to: state, at: 4)
Self.bind(CostUsageStore.cacheGeneration, to: state, at: 4)
Self.bind(cache.codexPricingKey, to: state, at: 5)
Self.bind(Self.cacheFingerprint(cache), to: state, at: 6)
sqlite3_bind_int64(state, 7, Int64((snapshot.updatedAt.timeIntervalSince1970 * 1000).rounded()))
Expand Down Expand Up @@ -697,7 +697,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
Self.bind(identity.contentFingerprint, to: statement, at: 18)
sqlite3_bind_int(statement, 19, Self.hasCompleteEventDetail(usage) ? 1 : 0)
Self.bind(generation, to: statement, at: 20)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed }
guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) }
}

private static func insertDaily(path: String, usage: CostUsageFileUsage, db: OpaquePointer?) throws {
Expand Down Expand Up @@ -725,7 +725,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
Self.bind(usage.codexStandardCostNanos?[day]?[model], to: statement, at: 10)
Self.bind(usage.codexPriorityCostNanos?[day]?[model], to: statement, at: 11)
Self.bind(usage.codexPrioritySurchargeNanos?[day]?[model], to: statement, at: 12)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed }
guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) }
}
}
}
Expand All @@ -738,6 +738,20 @@ struct CodexWorkspaceUsageSidecar: Sendable {
input_tokens, cached_input_tokens, output_tokens, known_cost_nanos, unpriced_tokens,
pricing_model, pricing_mode, reasoning_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(rollout_path, event_index) DO UPDATE SET
timestamp_ms = excluded.timestamp_ms,
day = excluded.day,
canonical_model = excluded.canonical_model,
raw_model = excluded.raw_model,
turn_id = excluded.turn_id,
input_tokens = excluded.input_tokens,
cached_input_tokens = excluded.cached_input_tokens,
output_tokens = excluded.output_tokens,
known_cost_nanos = excluded.known_cost_nanos,
unpriced_tokens = excluded.unpriced_tokens,
pricing_model = excluded.pricing_model,
pricing_mode = excluded.pricing_mode,
reasoning_tokens = excluded.reasoning_tokens
"""
guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed }
defer { sqlite3_finalize(statement) }
Expand All @@ -760,7 +774,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
Self.bind(row.pricingModel, to: statement, at: 13)
Self.bind(row.pricingMode, to: statement, at: 14)
Self.bind(row.reasoning.map(Int64.init), to: statement, at: 15)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed }
guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) }
}
}

Expand All @@ -770,7 +784,7 @@ struct CodexWorkspaceUsageSidecar: Sendable {
else { throw SidecarError.statementFailed }
defer { sqlite3_finalize(statement) }
Self.bind(path, to: statement, at: 1)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed }
guard sqlite3_step(statement) == SQLITE_DONE else { throw Self.sqliteFailure(db) }
}
}

Expand Down Expand Up @@ -955,11 +969,17 @@ struct CodexWorkspaceUsageSidecar: Sendable {

private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

private static func sqliteFailure(_ db: OpaquePointer?) -> SidecarError {
guard let db, let message = sqlite3_errmsg(db) else { return .writeFailed }
return .sqlite(String(cString: message))
}

private enum SidecarError: Error {
case openFailed
case incompatibleSchema
case statementFailed
case writeFailed
case sqlite(String)
}
#endif
}
Expand Down
51 changes: 10 additions & 41 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,24 +304,10 @@ public struct CostUsageFetcher: Sendable {
{
let roots = CostUsageScanner.codexSessionsRoots(options: options)
let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options)
let loadedCache = CostUsageCacheIO.loadCodexForMigration(
let cache = CostUsageStoreAccess.read(
cacheRoot: options.cacheRoot,
calendar: options.calendar)
let cache = loadedCache.cache
guard cache.roots == rootsFingerprint else {
if let incompatibleCache = loadedCache.incompatibleCache,
incompatibleCache.roots == rootsFingerprint
{
let staleSnapshotUpdatedAt: Date? = if incompatibleCache.lastScanUnixMs > 0 {
Date(timeIntervalSince1970: TimeInterval(incompatibleCache.lastScanUnixMs) / 1000)
} else {
nil
}
return CodexScanCatchUpStatus(
pending: true,
progressKey: "producer-upgrade",
staleSnapshotUpdatedAt: staleSnapshotUpdatedAt)
}
return CodexScanCatchUpStatus(pending: false, progressKey: "scope-mismatch")
}

Expand Down Expand Up @@ -553,7 +539,9 @@ public struct CostUsageFetcher: Sendable {
if provider == .codex {
let roots = CostUsageScanner.codexSessionsRoots(options: options.scanOptions)
let cache = CostUsageScanner.codexCache(
CostUsageCacheIO.load(provider: .codex, cacheRoot: options.scanOptions.cacheRoot),
CostUsageStoreAccess.read(
cacheRoot: options.scanOptions.cacheRoot,
calendar: options.scanOptions.calendar),
scopedTo: roots)
let range = CostUsageScanner.CostUsageDayRange(
since: since, until: now, calendar: options.scanOptions.calendar)
Expand Down Expand Up @@ -735,9 +723,9 @@ public struct CostUsageFetcher: Sendable {
let roots = CostUsageScanner.codexSessionsRoots(options: options)
let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options)
let cache = CostUsageScanner.codexCache(
CostUsageCacheIO.loadCodexForMigration(
CostUsageStoreAccess.read(
cacheRoot: options.cacheRoot,
calendar: options.calendar).cache,
calendar: options.calendar),
scopedTo: roots)
guard cache.timeZoneIdentifier == options.calendar.timeZone.identifier,
cache.roots == rootsFingerprint,
Expand Down Expand Up @@ -793,8 +781,8 @@ public struct CostUsageFetcher: Sendable {
return nil
}

// Decoding the persisted scan cache parses multi-megabyte JSON; keep it off the
// cooperative pool alongside the scans themselves.
// Snapshot assembly can touch many SQLite rows; keep it off the cooperative pool
// alongside the scans themselves.
let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in
let clampedHistoryDays = max(1, min(365, historyDays))
let options = Self.resolvedScannerOptions(
Expand All @@ -813,11 +801,11 @@ public struct CostUsageFetcher: Sendable {
let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false
let roots = CostUsageScanner.codexSessionsRoots(options: options)
let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options)
let loadedCache = CostUsageCacheIO.loadCodexForMigration(
let loadedCache = CostUsageStoreAccess.read(
cacheRoot: options.cacheRoot,
calendar: options.calendar)
let cache = CostUsageScanner.codexCache(
loadedCache.cache,
loadedCache,
scopedTo: roots)
var reports: [CostUsageDailyReport] = []
var projects: [CostUsageProjectBreakdown] = []
Expand Down Expand Up @@ -869,25 +857,6 @@ public struct CostUsageFetcher: Sendable {
}
}
}
} else if let incompatibleCache = loadedCache.incompatibleCache,
incompatibleCache.timeZoneIdentifier == range.calendar.timeZone.identifier,
!incompatibleCache.days.isEmpty,
incompatibleCache.roots == rootsFingerprint,
!CostUsageScanner.requestedWindowExpandsCache(range: range, cache: incompatibleCache)
{
let daily = CostUsageScanner.buildCodexReportFromCache(
cache: incompatibleCache,
range: range,
modelsDevCacheRoot: options.cacheRoot)
if !daily.data.isEmpty {
reports.append(daily)
if incompatibleCache.lastScanUnixMs > 0 {
let scanAt = Date(
timeIntervalSince1970: TimeInterval(incompatibleCache.lastScanUnixMs) / 1000)
staleSnapshotUpdatedAt = scanAt
scanTimes.append(scanAt)
}
}
}

if includePiSessions,
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 = "89e80f722cad05c8"
static let value = "97cf82ab7b18b255"
}
Loading