diff --git a/README.md b/README.md index 655aeddfa..ee2ab103e 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ the details and methodology in [docs/performance.md](docs/performance.md). - **[Claude](docs/providers/claude.md)** — session, weekly, model-specific limits, extra usage, local daily spend - **[Codex](docs/providers/codex.md)** — session, weekly, credits, local daily spend - **[Copilot](docs/providers/copilot.md)** — AI credits, extra usage, organization billing, chat and completions -- **[Cursor](docs/providers/cursor.md)** — credits, total/auto/API usage, requests, on-demand, per-day spend +- **[Cursor](docs/providers/cursor.md)** — credits, total/cursor/other model usage, requests, on-demand, per-day spend - **[Devin](docs/providers/devin.md)** — weekly and daily quota, extra usage balance - **[Grok](docs/providers/grok.md)** — weekly shared pool, pay-as-you-go, local daily spend - **[Kimi](docs/providers/kimi.md)** — five-hour and weekly Kimi Code quota, Extra Usage balance and monthly spend diff --git a/Sources/Runway/Providers/Claude/ClaudeDesktopAuthStore.swift b/Sources/Runway/Providers/Claude/ClaudeDesktopAuthStore.swift index dab84f85d..8e965d9ad 100644 --- a/Sources/Runway/Providers/Claude/ClaudeDesktopAuthStore.swift +++ b/Sources/Runway/Providers/Claude/ClaudeDesktopAuthStore.swift @@ -234,6 +234,18 @@ struct ClaudeDesktopAuthStore: Sendable { return Self.cookieRelativePaths.contains { files.exists(path($0)) } } + /// Desktop stamps the signed-in user on `lastKnownAccountUuid`. Account-prefixed cache keys + /// (`acct:|`) only belong to that user. + func lastKnownAccountUUID() -> String? { + guard let text = try? files.readTextIfPresent(path(Self.configRelativePath)), + let data = text.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let user = root["lastKnownAccountUuid"] as? String, + UUID(uuidString: user) != nil + else { return nil } + return user.lowercased() + } + func load(allowInteraction: Bool) -> ClaudeDesktopCredentialResult { guard hasCredentialMaterial() else { return ClaudeDesktopCredentialResult(oauth: nil, status: .notFound) @@ -251,6 +263,7 @@ struct ClaudeDesktopAuthStore: Sendable { let selection = Self.selectCredential( activeOrganization: activeOrg, + activeAccountUUID: lastKnownAccountUUID(), v2: caches.v2, v1: caches.v1, now: now() @@ -366,19 +379,21 @@ struct ClaudeDesktopAuthStore: Sendable { static func selectCredential( activeOrganization: String, + activeAccountUUID: String? = nil, v2: [String: Any]?, v1: [String: Any]?, now: Date ) -> Selection { let normalizedOrg = activeOrganization.lowercased() - let v2Candidates = candidates(in: v2, organization: normalizedOrg, now: now) + let v2Entries = normalizedCache(v2, activeAccountUUID: activeAccountUUID) + let v1Entries = normalizedCache(v1, activeAccountUUID: activeAccountUUID) + let v2Candidates = candidates(in: v2Entries, organization: normalizedOrg, now: now) if let best = v2Candidates.available.max(by: { $0.rank < $1.rank }) { return .available(best.oauth) } - let v2Keys = Set(v2?.keys ?? Dictionary().keys) let v1Candidates = candidates( - in: v1?.filter { !v2Keys.contains($0.key) }, + in: v1Entries.filter { v2Entries[$0.key] == nil }, organization: normalizedOrg, now: now ) @@ -420,17 +435,15 @@ struct ClaudeDesktopAuthStore: Sendable { } private static func candidates( - in cache: [String: Any]?, + in cache: [CacheKey: Any], organization: String, now: Date ) -> (available: [Candidate], sawStale: Bool, sawInvalid: Bool) { - guard let cache else { return ([], false, false) } var available: [Candidate] = [] var sawStale = false var sawInvalid = false - for (cacheKey, rawEntry) in cache { - guard let parsedKey = parseCacheKey(cacheKey), - parsedKey.organization == organization, + for (parsedKey, rawEntry) in cache { + guard parsedKey.organization == organization, parsedKey.apiHost == apiHost, parsedKey.scopes.contains(usageScope) else { @@ -468,19 +481,48 @@ struct ClaudeDesktopAuthStore: Sendable { return (available, sawStale, sawInvalid) } - private struct CacheKey { + private struct CacheKey: Hashable { var clientID: String var organization: String var apiHost: String var scopes: [String] } + /// Desktop migrates legacy keys to `acct:|` on use. A scoped entry, + /// including a deletion marker, supersedes its legacy alias. Foreign accounts never + /// participate in selection or suppress the current account's V1 fallback. + private static func normalizedCache( + _ cache: [String: Any]?, + activeAccountUUID: String? + ) -> [CacheKey: Any] { + guard let cache else { return [:] } + var legacy: [CacheKey: Any] = [:] + var scoped: [CacheKey: Any] = [:] + for (rawKey, entry) in cache.sorted(by: { $0.key < $1.key }) { + let isScoped = rawKey.hasPrefix("acct:") + var key = rawKey + if isScoped { + let rest = rawKey.dropFirst(5) + guard let separator = rest.firstIndex(of: "|"), + let owner = UUID(uuidString: String(rest[.. CacheKey? { let marker = ":\(apiHost):" guard let markerRange = value.range(of: marker) else { return nil } let prefix = value[.. ProviderSnapshot { + let error = ClaudeAuthError.notLoggedIn + let snapshot = await localUsageSnapshot( + mapped: ClaudeMappedUsage(plan: nil, lines: []), + warning: error.localizedDescription + ) + let hasLocalUsage = snapshot.usageHistory?.series.daily.contains { + $0.totalTokens > 0 || ($0.costUSD ?? 0) > 0 + } == true + guard hasLocalUsage else { + return ProviderSnapshot.error(provider: provider, error: error) + } + return snapshot + } + /// Terminal failure handling: a lapsed login (`isLoginRenewal`) degrades to the local spend tiles /// under a renewal notice — the data is still trustworthy and the fix belongs to the owning Claude /// app — while every other failure stays a hard error card. `renewalState` only feeds the plan diff --git a/Sources/Runway/Providers/Codex/CodexUsageMapper.swift b/Sources/Runway/Providers/Codex/CodexUsageMapper.swift index f346c5382..44fb526d3 100644 --- a/Sources/Runway/Providers/Codex/CodexUsageMapper.swift +++ b/Sources/Runway/Providers/Codex/CodexUsageMapper.swift @@ -324,6 +324,8 @@ enum CodexUsageMapper { return "Pro 5x" case "pro": return "Pro 20x" + case "self_serve_business_prolite": + return "Business Premium" default: return raw.titleCased(separator: { $0 == "_" }) } diff --git a/Sources/Runway/Providers/Cursor/CursorProvider.swift b/Sources/Runway/Providers/Cursor/CursorProvider.swift index 2a89fe104..35a489aa5 100644 --- a/Sources/Runway/Providers/Cursor/CursorProvider.swift +++ b/Sources/Runway/Providers/Cursor/CursorProvider.swift @@ -33,9 +33,9 @@ final class CursorProvider: ProviderRuntime { [ .percent(id: "cursor.usage", provider: provider, title: "Total Usage", metricLabel: "Total usage") .exportingLimit("totalUsage", unit: "percent"), - .percent(id: "cursor.auto", provider: provider, title: "Auto Usage", metricLabel: "Auto usage") + .percent(id: "cursor.auto", provider: provider, title: "Cursor Models", metricLabel: "Cursor models") .exportingLimit("autoUsage", unit: "percent"), - .percent(id: "cursor.api", provider: provider, title: "API Usage", metricLabel: "API usage") + .percent(id: "cursor.api", provider: provider, title: "Other Models", metricLabel: "Other models") .exportingLimit("apiUsage", unit: "percent"), .boundedDollars(id: "cursor.onDemand", provider: provider, title: "Extra Usage", metricLabel: "On-demand", limit: 100, valueWord: "spent") .exportingLimit("onDemand", unit: "usd", source: .progressOrValue(kind: .dollars)), diff --git a/Sources/Runway/Providers/Cursor/CursorUsageMapper.swift b/Sources/Runway/Providers/Cursor/CursorUsageMapper.swift index 190b8b95f..de334b4e8 100644 --- a/Sources/Runway/Providers/Cursor/CursorUsageMapper.swift +++ b/Sources/Runway/Providers/Cursor/CursorUsageMapper.swift @@ -139,7 +139,7 @@ enum CursorUsageMapper { if let autoPercentUsed = ProviderParse.number(planUsage["autoPercentUsed"]) { lines.append(.progress( - label: "Auto usage", + label: "Cursor models", used: autoPercentUsed, limit: 100, format: .percent, @@ -150,7 +150,7 @@ enum CursorUsageMapper { if let apiPercentUsed = ProviderParse.number(planUsage["apiPercentUsed"]) { lines.append(.progress( - label: "API usage", + label: "Other models", used: apiPercentUsed, limit: 100, format: .percent, diff --git a/Sources/Runway/Providers/Cursor/CursorUsageSummaryMapper.swift b/Sources/Runway/Providers/Cursor/CursorUsageSummaryMapper.swift index 82f1a7b9e..956ada86c 100644 --- a/Sources/Runway/Providers/Cursor/CursorUsageSummaryMapper.swift +++ b/Sources/Runway/Providers/Cursor/CursorUsageSummaryMapper.swift @@ -131,7 +131,7 @@ enum CursorUsageSummaryMapper { to lines: inout [MetricLine] ) { let plan = ((summary?["individualUsage"] as? [String: Any])?["plan"] as? [String: Any]) - for (key, label) in [("autoPercentUsed", "Auto usage"), ("apiPercentUsed", "API usage")] { + for (key, label) in [("autoPercentUsed", "Cursor models"), ("apiPercentUsed", "Other models")] { guard let percent = ProviderParse.number(plan?[key]) else { continue } lines.append(.progress( label: label, diff --git a/Sources/Runway/Providers/Grok/GrokLogUsageScanner.swift b/Sources/Runway/Providers/Grok/GrokLogUsageScanner.swift index 0ca5317e5..c4ec3211d 100644 --- a/Sources/Runway/Providers/Grok/GrokLogUsageScanner.swift +++ b/Sources/Runway/Providers/Grok/GrokLogUsageScanner.swift @@ -103,7 +103,9 @@ struct GrokLogUsageScanner: Sendable { private func scanSessions(since: Date, pricing: ModelPricing) async -> LogUsageScan? { let directory = grokHome.appendingPathComponent("sessions", isDirectory: true) - let discovered = Self.primarySessionFiles(under: directory, since: since) + // Child sessions can contain usage absent from their coordinator. Include every ledger; + // prompt-id dedup still drops forked parent replays. + let discovered = Self.sessionLedgerFiles(under: directory, since: since) let identity = directory.resolvingSymlinksInPath().standardizedFileURL.path guard !discovered.isEmpty else { _ = await sessionScanner.items( @@ -121,29 +123,12 @@ struct GrokLogUsageScanner: Sendable { return Self.aggregateSessionEntries(Self.dedupSessionEntries(entries), since: since, pricing: pricing) } - /// Discover only the authoritative update stream for top-level/fork sessions. A top-level turn's - /// ledger already includes its completed subagents; scanning each child session as well would - /// count that usage twice. Forks remain included because their new turns are real usage; replayed - /// parent turns are removed later by stable prompt id. - private static func primarySessionFiles(under directory: URL, since: Date) -> [JSONLScanning.DiscoveredFile] { + /// Every durable `updates.jsonl` ledger under the sessions tree, including subagent and resumed + /// child sessions. `summary.json` is not required: an unreadable or missing summary must not + /// drop an otherwise valid usage ledger. + private static func sessionLedgerFiles(under directory: URL, since: Date) -> [JSONLScanning.DiscoveredFile] { JSONLScanning.jsonlFiles(under: directory).filter { file in - guard file.mtime >= since, - URL(fileURLWithPath: file.path).lastPathComponent == "updates.jsonl" - else { return false } - let summary = URL(fileURLWithPath: file.path) - .deletingLastPathComponent() - .appendingPathComponent("summary.json") - guard let data = FileManager.default.contents(atPath: summary.path) else { - // A live session can publish updates before its summary. Include it; stable prompt-id - // dedup still protects against fork replay, and the next refresh can classify it. - return true - } - guard let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { - AppLog.warn(LogTag.plugin("grok"), "could not decode Grok session summary: \(summary.path)") - return true - } - let kind = (object["session_kind"] as? String)?.lowercased() - return kind != "subagent" && kind != "subagent_resume" + file.mtime >= since && URL(fileURLWithPath: file.path).lastPathComponent == "updates.jsonl" } } diff --git a/Sources/Runway/Resources/pricing_supplement.json b/Sources/Runway/Resources/pricing_supplement.json index dfaa59496..627a9aa8c 100644 --- a/Sources/Runway/Resources/pricing_supplement.json +++ b/Sources/Runway/Resources/pricing_supplement.json @@ -1,6 +1,6 @@ { "$comment": "Runway pricing supplement. Prices Cursor-native models that no public catalog carries, supplies fast-variant multipliers the catalogs omit, and maps provider log/CSV model slugs to canonical pricing keys (LiteLLM keys, models.dev ids, or entries in this file). Cursor Router rows name the routed model in prose instead of a slug ('Opus 5 (Auto Balanced)'), so those labels get alias rules too. Published to update-feed by .github/workflows/pricing-supplement.yml on every change, so installed apps pick edits up within about an hour - no release needed. Rates are USD per million tokens. Sources: https://cursor.com/docs/models-and-pricing.md for Cursor-native entries; https://developers.openai.com/api/docs/models/daybreak-blue-latest and https://developers.openai.com/api/docs/pricing for Daybreak aliases and rates.", - "updated_at": "2026-08-22T20:45:00Z", + "updated_at": "2026-09-04T17:30:00Z", "pricing": { "auto": { "input_per_million": 1.25, @@ -129,6 +129,34 @@ "cache_write_per_million": 0.75, "cache_read_per_million": 0.075, "output_per_million": 3.5 + }, + "gemini-3.8-flash": { + "$comment": "Gemini 3.8 Flash per Cursor's published table ($3.50 output). Google's introductory API rate lists $3.75 output; Cursor is the source of truth for CSV pricing, same as Gemini 3.7. Cache write is unpublished, so it defaults to input. The bundled snapshots do not carry the model yet.", + "input_per_million": 0.75, + "cache_write_per_million": 0.75, + "cache_read_per_million": 0.075, + "output_per_million": 3.5 + }, + "claude-fable-5.1": { + "$comment": "Claude Fable 5.1 launch pricing per Cursor's Other Models table. Same $10/$50 input/output as Fable 5; cache reads are $0.25/M (0.025x, 75% below Fable 5). Cache write is the standard 1.25x 5m rate. Neither bundled public catalog carries the model yet.", + "input_per_million": 10.0, + "cache_write_per_million": 12.5, + "cache_read_per_million": 0.25, + "output_per_million": 50.0 + }, + "glm-5.3": { + "$comment": "Z.ai's official GLM 5.3 API rates. Cursor's table does not list it yet. Cache storage has no separate charge, so cache writes bill at the input rate. Neither bundled public catalog carries the model yet.", + "input_per_million": 1.4, + "cache_write_per_million": 1.4, + "cache_read_per_million": 0.26, + "output_per_million": 4.4 + }, + "gpt-6-astra": { + "$comment": "OpenAI GPT-6 Astra official rates (developers.openai.com/api/docs/models/gpt-6-astra). Cache write is 1.25x input; cache read is 0.1x. Fast mode is 2x. Public catalogs and Cursor's table have not added it yet.", + "input_per_million": 10.0, + "cache_write_per_million": 12.5, + "cache_read_per_million": 1.0, + "output_per_million": 50.0 } }, "fast_multipliers": { @@ -142,7 +170,8 @@ "gpt-5.5": 2.5, "gpt-5.6-sol": 2.0, "gpt-5.6-terra": 2.0, - "gpt-5.6-luna": 2.0 + "gpt-5.6-luna": 2.0, + "gpt-6-astra": 2.0 }, "alias_rules": [ { "pattern": "^agent_review$", "canonical": "gpt-5.4" }, @@ -156,6 +185,7 @@ { "pattern": "^composer-2\\.5$", "canonical": "composer-2.5" }, { "pattern": "^composer-2\\.5-fast$", "canonical": "composer-2.5-fast" }, { "pattern": "^github_bugbot$", "canonical": "github_bugbot" }, + { "pattern": "^grok-bot-(?:automation|cua|default)$", "canonical": "grok-4.6" }, { "pattern": "^grok-4\\.6-build$", "canonical": "grok-4.6" }, { "pattern": "^grok-4\\.5-build$", "canonical": "grok-4.5" }, { "pattern": "^(?:cursor-)?grok-4[.-]6-fast(?:-(?:low|medium|high|xhigh))?$", "canonical": "grok-4.6-fast" }, @@ -182,6 +212,7 @@ { "pattern": "^claude-opus-4-8(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-4-8" }, { "pattern": "^claude-opus-5(?:\\[1m\\])?(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?-fast$", "canonical": "claude-opus-5-fast", "$comment": "No supplement pricing entries for Opus 5: LiteLLM already carries the launch rates and the 2x fast multiplier, and a supplement entry would shadow them on released decoders that drop fast_multipliers. claude-opus-5-fast prices off the LiteLLM base entry via the fast-variant path." }, { "pattern": "^claude-opus-5(?:\\[1m\\])?(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-opus-5" }, + { "pattern": "^claude-fable-5[.-]1(?:\\[1m\\])?(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-fable-5.1" }, { "pattern": "^claude-fable-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-fable-5" }, { "pattern": "^claude-sonnet-5(?:-thinking)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "claude-sonnet-5" }, { "pattern": "^gemini-2\\.5-flash(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-2.5-flash" }, @@ -189,6 +220,7 @@ { "pattern": "^gemini-3\\.5-flash(?:-preview)?(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-3.5-flash" }, { "pattern": "^gemini-3\\.6-flash(?:-preview)?(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-3.6-flash" }, { "pattern": "^gemini-3\\.7-flash(?:-preview)?(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-3.7-flash" }, + { "pattern": "^gemini-3\\.8-flash(?:-preview)?(?:-(?:none|low|medium|high|xhigh))?(?:-preview)?$", "canonical": "gemini-3.8-flash" }, { "pattern": "^gemini-3-pro-preview(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-3-pro-preview" }, { "pattern": "^gemini-3\\.1-pro(?:-preview)?(?:-(?:low|medium|high|xhigh))?$", "canonical": "gemini-3.1-pro-preview" }, { "pattern": "^gpt-5(?:-(?:low|high))?-fast$", "canonical": "gpt-5-fast" }, @@ -228,11 +260,14 @@ { "pattern": "^gpt-5\\.6-terra(?:-(?:none|low|medium|high|xhigh|max))?$", "canonical": "gpt-5.6-terra" }, { "pattern": "^gpt-5\\.6-luna(?:-(?:none|low|medium|high|xhigh|max))?-fast$", "canonical": "gpt-5.6-luna-fast" }, { "pattern": "^gpt-5\\.6-luna(?:-(?:none|low|medium|high|xhigh|max))?$", "canonical": "gpt-5.6-luna" }, + { "pattern": "^gpt-6-astra(?:-(?:none|low|medium|high|xhigh|max|ultra))?-fast$", "canonical": "gpt-6-astra-fast" }, + { "pattern": "^gpt-6-astra(?:-(?:none|low|medium|high|xhigh|max|ultra))?$", "canonical": "gpt-6-astra" }, { "pattern": "^(?:gpt-)?daybreak-blue-latest$", "canonical": "gpt-5.6-sol" }, { "pattern": "^kimi-k2\\.5$", "canonical": "moonshot/kimi-k2.5" }, { "pattern": "^kimi-k2p5$", "canonical": "moonshot/kimi-k2.5" }, { "pattern": "^kimi-k3(?:-code)?(?:-(?:low|medium|high|xhigh|max))?$", "canonical": "moonshot/kimi-k3" }, { "pattern": "^glm-5\\.2(?:-(?:high|max))?$", "canonical": "glm-5.2" }, + { "pattern": "(?i)^(?:(?:z-ai|zai|zhipu)/)?glm-5\\.3(?:-(?:low|high|max))?$", "canonical": "glm-5.3" }, { "pattern": "^[Pp]remium \\((?:[Gg][Pp][Tt]-5\\.3-[Cc]odex|[Cc]odex 5\\.3)\\)$", "canonical": "gpt-5.3-codex" }, { "pattern": "(?i)^Composer 2\\.5 Fast \\(Auto[^)]*\\)$", "canonical": "composer-2.5-fast" }, { "pattern": "(?i)^Composer 2\\.5 \\(Auto[^)]*\\)$", "canonical": "composer-2.5" }, @@ -247,14 +282,18 @@ { "pattern": "(?i)^(?:Claude )?Opus 5 \\(Auto[^)]*\\)$", "canonical": "claude-opus-5" }, { "pattern": "(?i)^(?:Claude )?Sonnet 5 \\(Auto[^)]*\\)$", "canonical": "claude-sonnet-5" }, { "pattern": "(?i)^(?:Claude )?Fable 5 \\(Auto[^)]*\\)$", "canonical": "claude-fable-5" }, + { "pattern": "(?i)^(?:Claude )?Fable 5\\.1 \\(Auto[^)]*\\)$", "canonical": "claude-fable-5.1" }, { "pattern": "(?i)^GPT-5\\.5 \\(Auto[^)]*\\)$", "canonical": "gpt-5.5" }, { "pattern": "(?i)^GPT-5\\.6 Sol \\(Auto[^)]*\\)$", "canonical": "gpt-5.6-sol" }, { "pattern": "(?i)^GPT-5\\.6 Terra \\(Auto[^)]*\\)$", "canonical": "gpt-5.6-terra" }, { "pattern": "(?i)^GPT-5\\.6 Luna \\(Auto[^)]*\\)$", "canonical": "gpt-5.6-luna" }, + { "pattern": "(?i)^GPT-6 Astra \\(Auto[^)]*\\)$", "canonical": "gpt-6-astra" }, { "pattern": "(?i)^Gemini 3\\.1 Pro \\(Auto[^)]*\\)$", "canonical": "gemini-3.1-pro-preview" }, { "pattern": "(?i)^Gemini 3\\.6 Flash \\(Auto[^)]*\\)$", "canonical": "gemini-3.6-flash" }, { "pattern": "(?i)^Gemini 3\\.7 Flash \\(Auto[^)]*\\)$", "canonical": "gemini-3.7-flash" }, + { "pattern": "(?i)^Gemini 3\\.8 Flash \\(Auto[^)]*\\)$", "canonical": "gemini-3.8-flash" }, { "pattern": "(?i)^GLM 5\\.2 \\(Auto[^)]*\\)$", "canonical": "glm-5.2" }, + { "pattern": "(?i)^GLM 5\\.3 \\(Auto[^)]*\\)$", "canonical": "glm-5.3" }, { "pattern": "(?i)^Kimi K3 \\(Auto[^)]*\\)$", "canonical": "moonshot/kimi-k3" } ] } diff --git a/Tests/RunwayTests/ClaudeDesktopAuthStoreTests.swift b/Tests/RunwayTests/ClaudeDesktopAuthStoreTests.swift index 9ce24f491..b5075a65d 100644 --- a/Tests/RunwayTests/ClaudeDesktopAuthStoreTests.swift +++ b/Tests/RunwayTests/ClaudeDesktopAuthStoreTests.swift @@ -103,6 +103,57 @@ final class ClaudeDesktopAuthStoreTests: XCTestCase { XCTAssertEqual(oauth.accessToken, "full-scope-token") } + func testAccountPrefixedCacheKeySelectsTheActiveAccount() throws { + let account = "11111111-1111-4111-8111-111111111111" + let otherAccount = "22222222-2222-4222-8222-222222222222" + let legacy = cacheKey(organization: organization) + let selection = ClaudeDesktopAuthStore.selectCredential( + activeOrganization: organization, + activeAccountUUID: account, + v2: [ + "acct:\(otherAccount)|\(legacy)": tokenEntry("foreign-token", expiresIn: 7_200), + "acct:\(account)|\(legacy)": tokenEntry("account-token", expiresIn: 3_600) + ], + v1: nil, + now: now + ) + + guard case .available(let oauth) = selection else { + return XCTFail("expected the active account's prefixed token, got \(selection)") + } + XCTAssertEqual(oauth.accessToken, "account-token") + } + + func testAccountPrefixedTombstoneSuppressesLegacyV1Alias() throws { + let account = "11111111-1111-4111-8111-111111111111" + let legacy = cacheKey(organization: organization) + let selection = ClaudeDesktopAuthStore.selectCredential( + activeOrganization: organization, + activeAccountUUID: account, + v2: ["acct:\(account)|\(legacy)": NSNull()], + v1: [legacy: tokenEntry("resurrected-token", expiresIn: 3_600)], + now: now + ) + + guard case .notFound = selection else { + return XCTFail("scoped V2 tombstone should suppress the matching V1 token, got \(selection)") + } + } + + func testPrefixedCacheWithoutAccountUUIDIsIgnored() throws { + let account = "11111111-1111-4111-8111-111111111111" + let selection = ClaudeDesktopAuthStore.selectCredential( + activeOrganization: organization, + v2: ["acct:\(account)|\(cacheKey(organization: organization))": tokenEntry("prefixed-token", expiresIn: 3_600)], + v1: nil, + now: now + ) + + guard case .notFound = selection else { + return XCTFail("prefixed keys must not be used without the signed-in account, got \(selection)") + } + } + func testBackgroundReadDoesNotPromptButManualReadCan() throws { let fixture = try makeFixture( activeOrganization: organization, @@ -129,6 +180,23 @@ final class ClaudeDesktopAuthStoreTests: XCTestCase { XCTAssertEqual(fixture.store.load(allowInteraction: false).status, .stale) } + func testLoadReadsAccountPrefixedCacheForSignedInUser() throws { + let account = "11111111-1111-4111-8111-111111111111" + let fixture = try makeFixture( + activeOrganization: organization, + v2: [ + "acct:\(account)|\(cacheKey(organization: organization))": + tokenEntry("prefixed-desktop-token", expiresIn: 3_600) + ], + lastKnownAccountUUID: account + ) + + let result = fixture.store.load(allowInteraction: false) + + XCTAssertEqual(result.status, .available) + XCTAssertEqual(result.oauth?.accessToken, "prefixed-desktop-token") + } + func testWorkingCLICredentialsSkipDesktopProbe() throws { let fixture = try makeFixture( activeOrganization: organization, @@ -473,7 +541,8 @@ final class ClaudeDesktopAuthStoreTests: XCTestCase { activeOrganization: String, v2: [String: Any], v1: [String: Any]? = nil, - requiresInteraction: Bool = false + requiresInteraction: Bool = false, + lastKnownAccountUUID: String? = nil ) throws -> DesktopFixture { let key = try ClaudeDesktopAuthStore.deriveKey(password: password) let cookieHost = ".claude.ai" @@ -482,6 +551,9 @@ final class ClaudeDesktopAuthStoreTests: XCTestCase { let v2Data = try JSONSerialization.data(withJSONObject: v2) let encryptedV2 = try encrypt(v2Data, key: key) var config: [String: Any] = ["oauth:tokenCacheV2": encryptedV2.base64EncodedString()] + if let lastKnownAccountUUID { + config["lastKnownAccountUuid"] = lastKnownAccountUUID + } if let v1 { let v1Data = try JSONSerialization.data(withJSONObject: v1) config["oauth:tokenCache"] = try encrypt(v1Data, key: key).base64EncodedString() diff --git a/Tests/RunwayTests/ClaudeProviderTests.swift b/Tests/RunwayTests/ClaudeProviderTests.swift index b7cfb429f..4d4dd2834 100644 --- a/Tests/RunwayTests/ClaudeProviderTests.swift +++ b/Tests/RunwayTests/ClaudeProviderTests.swift @@ -1199,6 +1199,36 @@ final class ClaudeProviderTests: XCTestCase { XCTAssertEqual(badge(corruptSnapshot.lines, "Error"), ClaudeAuthError.notLoggedIn.localizedDescription) } + func testUnauthenticatedRefreshKeepsLocalSpendWhenLogsExist() async throws { + let home = try ClaudeLogFixture.makeHome(files: [ + "proj/session.jsonl": ClaudeLogFixture.usageLine( + timestamp: "2026-09-04T08:00:00.000Z", + input: 100, + output: 50 + ) + ]) + let now = RunwayISO8601.date(from: "2026-09-04T12:00:00.000Z")! + let provider = ClaudeProvider( + authStore: ClaudeAuthStore( + environment: FakeEnvironment(), + files: FakeFiles(), + keychain: FakeKeychain() + ), + usageClient: ClaudeUsageClient(httpClient: FakeHTTPClient(response: HTTPResponse(statusCode: 200, headers: [:], body: Data()))), + logUsageScanner: ClaudeLogFixture.scanner(home: home), + now: { now }, + pricing: { TestPricing.bundled } + ) + + let snapshot = await provider.refresh() + + XCTAssertNil(badge(snapshot.lines, "Error")) + XCTAssertEqual(snapshot.warning, ClaudeAuthError.notLoggedIn.localizedDescription) + XCTAssertNil(snapshot.line(label: "Session")) + XCTAssertNotNil(values(snapshot.lines, "Today")) + XCTAssertGreaterThan(values(snapshot.lines, "Today")?.first { $0.kind == .count }?.number ?? 0, 0) + } + func testRateLimitedResponseMapsToRetryBadgeNotError() async { let now = RunwayISO8601.date(from: "2026-02-20T16:00:00.000Z")! let httpClient = FakeHTTPClient(response: HTTPResponse( diff --git a/Tests/RunwayTests/CodexProviderTests.swift b/Tests/RunwayTests/CodexProviderTests.swift index 36e3973bc..a349b9fc0 100644 --- a/Tests/RunwayTests/CodexProviderTests.swift +++ b/Tests/RunwayTests/CodexProviderTests.swift @@ -307,6 +307,17 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertEqual(progress(mapped.lines, "Session")?.periodDurationMs, CodexUsageMapper.sessionPeriodMs) } + func testFormatsBusinessPremiumEntitlement() throws { + let body = Data(#"{"plan_type":"self_serve_business_prolite","rate_limit":{"primary_window":{"used_percent":4,"limit_window_seconds":604800}}}"#.utf8) + let mapped = try CodexUsageMapper.mapUsageResponse( + HTTPResponse(statusCode: 200, headers: [:], body: body) + ) + + XCTAssertEqual(mapped.plan, "Business Premium") + XCTAssertEqual(progress(mapped.lines, "Weekly")?.used, 4) + XCTAssertNil(progress(mapped.lines, "Session")) + } + func testHeadersFillMissingWindows() throws { let body = Data(""" { diff --git a/Tests/RunwayTests/CursorProviderTests.swift b/Tests/RunwayTests/CursorProviderTests.swift index 93485341b..8dc04a4cd 100644 --- a/Tests/RunwayTests/CursorProviderTests.swift +++ b/Tests/RunwayTests/CursorProviderTests.swift @@ -32,8 +32,8 @@ final class CursorUsageMapperTests: XCTestCase { XCTAssertEqual(mapped.plan, "Pro Plan") XCTAssertEqual(try XCTUnwrap(dollarValue(mapped.lines, "Credits")), 17268.15, accuracy: 0.001) XCTAssertEqual(progress(mapped.lines, "Total usage")?.used, 20) - XCTAssertEqual(progress(mapped.lines, "Auto usage")?.used, 12.5) - XCTAssertEqual(progress(mapped.lines, "API usage")?.used, 7.5) + XCTAssertEqual(progress(mapped.lines, "Cursor models")?.used, 12.5) + XCTAssertEqual(progress(mapped.lines, "Other models")?.used, 7.5) XCTAssertEqual(progress(mapped.lines, "On-demand")?.used, 40) } @@ -206,8 +206,8 @@ final class CursorProviderTests: XCTestCase { XCTAssertEqual(snapshot.plan, "Pro Plan") XCTAssertEqual(dollarValue(snapshot.lines, "Credits") ?? -1, 500) XCTAssertEqual(progress(snapshot.lines, "Total usage")?.used, 20) - XCTAssertEqual(progress(snapshot.lines, "Auto usage")?.used, 12.5) - XCTAssertEqual(progress(snapshot.lines, "API usage")?.used, 7.5) + XCTAssertEqual(progress(snapshot.lines, "Cursor models")?.used, 12.5) + XCTAssertEqual(progress(snapshot.lines, "Other models")?.used, 7.5) XCTAssertEqual(progress(snapshot.lines, "On-demand")?.used, 40) } } diff --git a/Tests/RunwayTests/CursorUsageSummaryTests.swift b/Tests/RunwayTests/CursorUsageSummaryTests.swift index 093bdf72b..79f1992f1 100644 --- a/Tests/RunwayTests/CursorUsageSummaryTests.swift +++ b/Tests/RunwayTests/CursorUsageSummaryTests.swift @@ -42,8 +42,8 @@ final class CursorUsageSummaryMapperTests: XCTestCase { let requests = try XCTUnwrap(progress(mapped.lines, "Requests")) XCTAssertEqual(requests.used, 37) XCTAssertEqual(requests.limit, 750) - XCTAssertEqual(progress(mapped.lines, "Auto usage")?.used, 0) - XCTAssertEqual(progress(mapped.lines, "API usage")?.used, 6.25) + XCTAssertEqual(progress(mapped.lines, "Cursor models")?.used, 0) + XCTAssertEqual(progress(mapped.lines, "Other models")?.used, 6.25) let onDemand = try XCTUnwrap(progress(mapped.lines, "On-demand")) XCTAssertEqual(onDemand.used, 0) diff --git a/Tests/RunwayTests/GrokLogUsageScannerTests.swift b/Tests/RunwayTests/GrokLogUsageScannerTests.swift index cfb38ced0..431ef55b2 100644 --- a/Tests/RunwayTests/GrokLogUsageScannerTests.swift +++ b/Tests/RunwayTests/GrokLogUsageScannerTests.swift @@ -25,7 +25,7 @@ final class GrokLogUsageScannerTests: XCTestCase { XCTAssertEqual(entry.reportedTotalTokens, 1_050) } - func testModernSessionScanExcludesSubagentsAndDeduplicatesForkReplay() async throws { + func testModernSessionScanIncludesSubagentsAndDeduplicatesForkReplay() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("runway-grok-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } @@ -74,9 +74,9 @@ final class GrokLogUsageScannerTests: XCTestCase { let usage = try XCTUnwrap(scannedUsage) XCTAssertEqual(usage.series.daily.count, 1) - XCTAssertEqual(usage.series.daily.first?.totalTokens, 3_100_000) - // Main: $1.00 uncached input + $0.25 cache read + $0.60 output. Fork-only: $4 input. - XCTAssertEqual(usage.series.daily.first?.costUSD ?? 0, 5.85, accuracy: 0.0001) + XCTAssertEqual(usage.series.daily.first?.totalTokens, 12_100_000) + // Main: $1.00 uncached input + $0.25 cache read + $0.60 output. Fork-only: $4 input. Child: $18 input. + XCTAssertEqual(usage.series.daily.first?.costUSD ?? 0, 23.85, accuracy: 0.0001) XCTAssertEqual(usage.modelUsage?.daily.first?.models.map(\.model), ["grok-4.6-build"]) XCTAssertTrue(usage.unknownModelsByDay.isEmpty) } diff --git a/Tests/RunwayTests/PricingBundledResourceTests.swift b/Tests/RunwayTests/PricingBundledResourceTests.swift index aa09352f1..47fd1c139 100644 --- a/Tests/RunwayTests/PricingBundledResourceTests.swift +++ b/Tests/RunwayTests/PricingBundledResourceTests.swift @@ -51,6 +51,13 @@ final class PricingBundledResourceTests: XCTestCase { XCTAssertEqual(pricing.resolve(model: "gemini-3.6-flash-high")?.inputPerMillion, 1.5) XCTAssertEqual(pricing.resolve(model: "gemini-3.7-flash-high")?.inputPerMillion, 0.75) XCTAssertEqual(pricing.resolve(model: "gemini-3.7-flash-high")?.outputPerMillion, 3.5) + XCTAssertEqual(pricing.resolve(model: "gemini-3.8-flash-high")?.inputPerMillion, 0.75) + XCTAssertEqual(pricing.resolve(model: "gemini-3.8-flash-high")?.outputPerMillion, 3.5) + XCTAssertEqual(pricing.resolve(model: "gpt-6-astra")?.inputPerMillion, 10) + XCTAssertEqual(pricing.resolve(model: "gpt-6-astra-fast")?.inputPerMillion, 20) + XCTAssertEqual(pricing.resolve(model: "claude-fable-5.1")?.cacheReadPerMillion, 0.25) + XCTAssertEqual(pricing.resolve(model: "glm-5.3")?.inputPerMillion, 1.4) + XCTAssertEqual(pricing.resolve(model: "grok-bot-default"), pricing.resolve(model: "grok-4.6")) XCTAssertEqual(pricing.resolve(model: "kimi-k3")?.inputPerMillion, 3) XCTAssertEqual(pricing.resolve(model: "grok-4-20-thinking")?.inputPerMillion, 2) XCTAssertEqual(pricing.resolve(model: "grok-4.5")?.inputPerMillion, 2) @@ -345,7 +352,11 @@ final class PricingBundledResourceTests: XCTestCase { "Gemini 3.1 Pro (Auto)": "gemini-3.1-pro-preview", "Gemini 3.6 Flash (Auto)": "gemini-3.6-flash", "Gemini 3.7 Flash (Auto Balanced)": "gemini-3.7-flash", + "Gemini 3.8 Flash (Auto Balanced)": "gemini-3.8-flash", "GLM 5.2 (Auto Balanced)": "glm-5.2", + "GLM 5.3 (Auto Cost)": "glm-5.3", + "Fable 5.1 (Auto Balanced)": "claude-fable-5.1", + "GPT-6 Astra (Auto)": "gpt-6-astra", "Kimi K3 (Auto Cost)": "moonshot/kimi-k3" ] for (label, canonical) in expected { diff --git a/docs/menu-bar.md b/docs/menu-bar.md index 8d2703502..99e76a331 100644 --- a/docs/menu-bar.md +++ b/docs/menu-bar.md @@ -10,7 +10,7 @@ Right-click (or control-click) the menu bar icon for a quick menu with **Setting Star a metric from any row's right-click menu, or from the always-visible star beside a metric in Customize. -- On first launch the app ships with a default set of stars (Antigravity Session/Weekly, Claude Session/Weekly, Codex Weekly, Cursor Auto Usage/API Usage, Copilot Credits, OpenRouter Credits, Z.ai Session/Weekly) so the strip shows numbers right away. Each discovered Claude or Codex account card gets its family's same default stars, with values from that account; a secondary card discovered later receives them once and then keeps your changes. Change them anytime; a provider's Reset restores its defaults, and Reset All restores the full set. Only providers that are turned on render in the strip. A fresh install starts with just the providers detected on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). So the default stars don't crowd the menu bar with tools you don't use. +- On first launch the app ships with a default set of stars (Antigravity Session/Weekly, Claude Session/Weekly, Codex Weekly, Cursor Models/Other Models, Copilot Credits, OpenRouter Credits, Z.ai Session/Weekly) so the strip shows numbers right away. Each discovered Claude or Codex account card gets its family's same default stars, with values from that account; a secondary card discovered later receives them once and then keeps your changes. Change them anytime; a provider's Reset restores its defaults, and Reset All restores the full set. Only providers that are turned on render in the strip. A fresh install starts with just the providers detected on your Mac (see [Dashboard § First launch](dashboard.md#first-launch)). So the default stars don't crowd the menu bar with tools you don't use. - At most **2 applicable stars per provider**. If an account or plan change makes a starred metric unavailable, Runway keeps that preference for a future switch back, but the dormant star does not consume one of the current account's two slots. If a later switch makes more than two saved stars diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b74aaec9e..cbe35ad7e 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -29,7 +29,8 @@ Sign in with Claude Code or Claude Desktop; Runway reads the existing login. It Claude Desktop support is read-only. Runway decrypts its currently valid access token using the `Claude Safe Storage` item in your macOS Keychain. It never reads or uses Desktop's refresh token, and never changes Desktop's config, cookies, or Keychain entry. This prevents Runway from invalidating -Claude Desktop's session. +Claude Desktop's session. Recent Desktop builds store tokens under account-prefixed cache keys; Runway +reads those as well as the older format, and only uses entries that belong to the signed-in Desktop account. Launch-time and background refreshes never request Claude's Keychain secrets. After launch or a credential change, the card offers a neutral **Connect** action (not a warning — nothing is broken); @@ -65,7 +66,7 @@ Claude Code owns its login, and Runway defers to it — but when a stored token ## The spend tiles -Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Claude Code session logs under `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR`) itself — no external tools needed. Symlinks are followed, so a projects folder linked into a synced location (say, a Dropbox folder) is read all the same. Claude usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too. Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Claude usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come straight from pi; Runway does not re-estimate them. Cowork (the Claude desktop app's agent mode) counts too: it writes the same logs into per-session folders under `~/Library/Application Support/Claude/local-agent-mode-sessions/`, and Runway scans those as well. Desktop agent sessions show up in the tiles alongside terminal ones. Persisted `claude -p` runs count as well. Runs made with `--no-session-persistence` cannot appear because Claude deliberately writes no session log for Runway to read. Advisor work recorded inside a message is counted once under the advisor's own model; the parent's main-model totals are kept separate, and ordinary iteration details are not counted again. A log's recorded fast or standard speed controls its price; Runway does not infer speed from the event date. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); the token counts themselves are measured. No log data leaves your Mac. +Today / Yesterday / Last 30 Days are computed **locally**: Runway reads the Claude Code session logs under `~/.claude/projects/` (or `$CLAUDE_CONFIG_DIR`) itself — no external tools needed. Symlinks are followed, so a projects folder linked into a synced location (say, a Dropbox folder) is read all the same. Claude usage from the [pi](https://github.com/earendil-works/pi) coding agent counts too. Runway reads pi's session logs under `~/.pi/agent/sessions/` (or `$PI_CODING_AGENT_SESSION_DIR`) and folds any Claude usage there into the same tiles and trend. pi records its own per-message cost, so those dollars come straight from pi; Runway does not re-estimate them. Cowork (the Claude desktop app's agent mode) counts too: it writes the same logs into per-session folders under `~/Library/Application Support/Claude/local-agent-mode-sessions/`, and Runway scans those as well. Desktop agent sessions show up in the tiles alongside terminal ones. Persisted `claude -p` runs count as well. Runs made with `--no-session-persistence` cannot appear because Claude deliberately writes no session log for Runway to read. Advisor work recorded inside a message is counted once under the advisor's own model; the parent's main-model totals are kept separate, and ordinary iteration details are not counted again. A log's recorded fast or standard speed controls its price; Runway does not infer speed from the event date. Days are grouped in your Mac's local time zone, so they line up with your own calendar. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`); a day with no usage reads **No data** rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider. The live Session and Weekly meters are unaffected. The dollars are estimated from token counts at API rates (that's the ⓘ) using the shared [model pricing](../pricing.md); the token counts themselves are measured. No log data leaves your Mac. The spend tiles also load when there is no Claude OAuth login — for example an API-key gateway — as long as those local logs exist. The header then shows **Not logged in** because Session and Weekly cannot load. ## Multiple accounts @@ -95,7 +96,7 @@ In the [CLI](../cli.md) and [local API](../local-http-api.md), extra cards appea ## Troubleshooting -- **"Not logged in"** — run `claude` and sign in, then refresh. +- **"Not logged in"** — run `claude` to sign in, then refresh. If local session logs exist, the spend tiles still show; Session and Weekly stay empty until you sign in. - **"Claude Code login found"** (a neutral key glyph / **Connect** button, not a warning) — the login exists but hasn't been loaded this app session. Connect, and choose **Always Allow** if macOS asks for access to `Claude Code-credentials`. - **"Keychain access to the Claude Code login was declined"** — a manual read was denied. Refresh and choose **Always Allow** when macOS asks. - **"Claude Code credentials couldn't be checked"** — unlock your login keychain, then refresh Runway. diff --git a/docs/providers/codex.md b/docs/providers/codex.md index ce7560638..d0e9b7630 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -13,7 +13,7 @@ Tracks your ChatGPT/Codex subscription limits using the login from the Codex CLI | Extra Usage | Flex credits, shown verbatim as dollars + credits (e.g. `$31.84 · 796 credits`) | | Today / Yesterday / Last 30 Days | Local spend, as cost, tokens, or both (see below) | -When Codex reports your plan name, Runway shows it beside the provider name. +When Codex reports your plan name, Runway shows it beside the provider name. `self_serve_business_prolite` is shown as **Business Premium**. ## Where credentials come from diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 540366644..a7c34c698 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -9,8 +9,8 @@ Tracks your Cursor plan usage using the login from the Cursor app. | Credits | Credit balance left from grants and prepaid account balance | | Total Usage | Plan usage for the billing cycle (percent or dollars; included request count vs. cap on request-based Enterprise accounts) | | Requests | Optional copy of the included request count vs. cap for custom layouts | -| Auto Usage | Auto-model usage percent | -| API Usage | API usage percent | +| Cursor Models | Cursor-native model usage percent (Grok, Composer) | +| Other Models | Third-party model usage percent | | Extra Usage | On-demand spend; user-scoped when available, otherwise the team aggregate; shown as a meter when Cursor returns a limit | When Cursor reports your plan name, Runway shows it beside the provider name. diff --git a/docs/providers/grok.md b/docs/providers/grok.md index 9f34d31b8..5ca138b25 100644 --- a/docs/providers/grok.md +++ b/docs/providers/grok.md @@ -21,7 +21,7 @@ Sign in once with the Grok CLI (`grok login`); Runway reads the same `~/.grok/au ## The spend tiles -Today / Yesterday / Last 30 Days are computed **locally** from Grok CLI's persisted session activity under `~/.grok/sessions/` (or `$GROK_HOME/sessions/`). Grok 1.x records measured token buckets and per-model totals when each turn completes; Runway reads those records directly, excludes nested subagent sessions already included in their parent turn, and removes replayed turns from forked sessions. For older Grok CLI versions, Runway still falls back to `~/.grok/logs/unified.jsonl`. +Today / Yesterday / Last 30 Days are computed **locally** from Grok CLI's persisted session activity under `~/.grok/sessions/` (or `$GROK_HOME/sessions/`). Grok 1.x records measured token buckets and per-model totals when each turn completes; Runway reads those records directly, includes nested subagent and resumed sessions, and removes replayed turns from forked sessions. For older Grok CLI versions, Runway still falls back to `~/.grok/logs/unified.jsonl`. Each period is one tile showing cost and tokens together (`$4.08 · 1.2M tokens`), the same as Claude/Codex/Cursor. The dollars are estimated from measured token counts at public API rates using the shared [model pricing](../pricing.md) (that's the ⓘ), and these estimates are separate from the weekly subscription pool that Grok's billing API reports. No session data leaves your Mac. A period with no recorded usage reads "No data" rather than a misleading `$0.00 · 0 tokens` — the same as every other spend-tracking provider.