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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 52 additions & 10 deletions Sources/Runway/Providers/Claude/ClaudeDesktopAuthStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:<user>|<legacy key>`) 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)
Expand All @@ -251,6 +263,7 @@ struct ClaudeDesktopAuthStore: Sendable {

let selection = Self.selectCredential(
activeOrganization: activeOrg,
activeAccountUUID: lastKnownAccountUUID(),
v2: caches.v2,
v1: caches.v1,
now: now()
Expand Down Expand Up @@ -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<String, Any>().keys)
let v1Candidates = candidates(
in: v1?.filter { !v2Keys.contains($0.key) },
in: v1Entries.filter { v2Entries[$0.key] == nil },
organization: normalizedOrg,
now: now
)
Comment thread
mstallone marked this conversation as resolved.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:<user>|<legacy key>` 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[..<separator])),
let activeAccountUUID,
let active = UUID(uuidString: activeAccountUUID),
owner == active
else { continue }
key = String(rest[rest.index(after: separator)...])
}
guard let parsed = parseCacheKey(key) else { continue }
if isScoped { scoped[parsed] = entry } else { legacy[parsed] = entry }
}
return legacy.merging(scoped) { _, scopedEntry in scopedEntry }
}

private static func parseCacheKey(_ value: String) -> CacheKey? {
let marker = ":\(apiHost):"
guard let markerRange = value.range(of: marker) else { return nil }
let prefix = value[..<markerRange.lowerBound]
guard let firstColon = prefix.firstIndex(of: ":") else { return nil }
let clientID = String(prefix[..<firstColon])
let clientID = String(prefix[..<firstColon]).lowercased()
let organization = String(prefix[prefix.index(after: firstColon)...]).lowercased()
guard UUID(uuidString: clientID) != nil, UUID(uuidString: organization) != nil else {
return nil
Expand Down
20 changes: 19 additions & 1 deletion Sources/Runway/Providers/Claude/ClaudeProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ final class ClaudeProvider: ProviderRuntime {
break
}
AppLog.info(LogTag.auth("claude"), "no access token, not logged in")
return ProviderSnapshot.error(provider: provider, error: ClaudeAuthError.notLoggedIn)
return await unauthenticatedLocalUsageSnapshot()
}

// Per-source diagnostics at info level (token-free: source kind + expired boolean) so a
Expand Down Expand Up @@ -222,6 +222,24 @@ final class ClaudeProvider: ProviderRuntime {
)
}

/// API-key-only (or otherwise unauthenticated) installs still have local session logs. Serve those
/// spend tiles under the existing Not logged in notice when they contain usage; otherwise keep the
/// hard error card so an empty machine does not grow a blank Claude card.
private func unauthenticatedLocalUsageSnapshot() async -> 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
Expand Down
2 changes: 2 additions & 0 deletions Sources/Runway/Providers/Codex/CodexUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "_" })
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Runway/Providers/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
4 changes: 2 additions & 2 deletions Sources/Runway/Providers/Cursor/CursorUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 8 additions & 23 deletions Sources/Runway/Providers/Grok/GrokLogUsageScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"
}
}

Expand Down
Loading