mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
Merge main into feat/project-selector
This commit is contained in:
commit
29d6ca9ab7
73 changed files with 2153 additions and 211 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **Google AI plans and the Antigravity tier.** `codeburn plan set google-ai-pro` ($19.99), `google-ai-ultra-5x` ($99.99) or `google-ai-ultra-20x` ($199.99) tracks Antigravity spend against the plan price, also from the desktop Settings. `codeburn quota` now shows the tier Antigravity reports in `userTier.name` (for example `Google AI Ultra` or `Antigravity Starter Quota`); it used to show none because the quota summary carries no tier. The menu bar and desktop app read it the same way. (#1614)
|
||||
- **CodeBurn for VS Code.** A new extension, version 1.0.0 (`codeburn.codeburn`, source in `vscode/`) for VS Code, Cursor, Windsurf, Antigravity and VSCodium: today's spend in the status bar with `~` for estimated figures and a tooltip carrying the week, the month, this workspace, the top model and plan quotas; an activity bar summary; and the desktop dashboard in an editor tab, opening on the current workspace's projects with a switch to all projects. It bundles the CLI and runs it as the desktop's resident `serve` child on the editor's own Node (Node 24 in current editors), with a system Node 22.13+ as the fallback. No telemetry. See `docs/vscode.md`.
|
||||
- **Estimated dollars carry the same `~` in every view.** `models` (table, markdown, JSON), `sessions` (table, browser, JSON), the desktop Models, Overview, Sessions and Settings provider rows, the macOS menu bar (model rows and provider tabs) and the Windows tray now mark a figure with `~` when at least 1% of it is priced from estimates, the marker and legend `report` already used. `report`, `overview` and the MCP tables move to the same 1% rule, so a metered Codex row holding a few text-estimated turns is no longer marked. JSON gains `estimatedCostUSD` and `isEstimated` on each `models` row, `estimatedCost` and `isEstimated` on each `sessions` row, and `estimatedCostUSD` on each `providerDetails` row of `status --format menubar-json`. Totals are unchanged.
|
||||
- **Overview marks Cursor dollars that are local estimates, not the bill.** When a period still holds local Cursor or Cursor Agent usage that no dashboard import covers, `codeburn overview` prints one line naming how much of the total is priced from the IDE's local files (`includes $X of Cursor priced from local files, not Cursor's bill`) and points at `codeburn import cursor <file.csv>`, instead of letting the estimate read as the dashboard bill — on the account that reported #1545 the local figure was about 2% of what Cursor actually charged. Imported events and days an import covers never trigger the line, since covered local calls are dropped at serve time and the imported rows carry Cursor's own billed amounts. (#1545)
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@ The dollar figure is what your tokens would have cost at API rates. It is not an
|
|||
<details>
|
||||
<summary><strong>Is my plan the right size, or am I paying for capacity I never use?</strong></summary>
|
||||
|
||||
Set what you pay for with `codeburn plan set claude-max`, or `claude-pro`, `cursor-pro`, `copilot-pro`, or `custom --monthly-usd 200 --provider codex`. The Plans page then shows spend this cycle against that budget with a pacing line, either On track or on pace to exceed with the projected figure and the date. After two or three cycles the pattern is clear. Consistently under a quarter of the plan means you are buying capacity you do not use, and consistently over means the cheaper plan is costing you. CodeBurn shows the share and the pace, and leaves the choice of plan to you.
|
||||
Set what you pay for with `codeburn plan set claude-max`, or `claude-pro`, `cursor-pro`, `copilot-pro`, `google-ai-pro`, or `custom --monthly-usd 200 --provider codex`. The Plans page then shows spend this cycle against that budget with a pacing line, either On track or on pace to exceed with the projected figure and the date. After two or three cycles the pattern is clear. Consistently under a quarter of the plan means you are buying capacity you do not use, and consistently over means the cheaper plan is costing you. CodeBurn shows the share and the pace, and leaves the choice of plan to you.
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
// Endpoints (localhost only, Connect-RPC JSON):
|
||||
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary
|
||||
// (preferred; falls back to)
|
||||
// (preferred windows; the tier and fallback windows come from)
|
||||
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
|
||||
//
|
||||
// Discovery lists processes to find candidates (app language
|
||||
|
|
@ -246,24 +246,22 @@ export function decodeAntigravityStatus(body: unknown): QuotaWindow[] {
|
|||
|
||||
function planFromStatus(body: unknown): string | null {
|
||||
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
|
||||
const plan = data.planName ?? data.userStatus?.planName ?? data.account_plan
|
||||
return typeof plan === 'string' && plan.trim() ? plan.trim() : null
|
||||
// userTier is the Google subscription; planStatus.planInfo.planName says "Pro" even on the free tier.
|
||||
const plan = [data.userStatus?.userTier?.name, data.planName, data.userStatus?.planName, data.account_plan]
|
||||
.find(value => typeof value === 'string' && value.trim())
|
||||
return plan ? plan.trim() : null
|
||||
}
|
||||
|
||||
async function probePort(deps: AntigravityDeps, port: number, csrf?: string): Promise<{ windows: QuotaWindow[]; planLabel: string | null } | null> {
|
||||
const body = JSON.stringify({})
|
||||
for (const tls of [true, false]) {
|
||||
const summary = await deps.request(port, tls, SUMMARY_PATH, body, csrf)
|
||||
if (summary?.status === 200) {
|
||||
const windows = decodeAntigravitySummary(parseJson(summary.text))
|
||||
if (windows.length > 0) return { windows, planLabel: null }
|
||||
}
|
||||
const summaryWindows = summary?.status === 200 ? decodeAntigravitySummary(parseJson(summary.text)) : []
|
||||
// The summary payload carries no tier, so GetUserStatus is asked either way.
|
||||
const status = await deps.request(port, tls, STATUS_PATH, body, csrf)
|
||||
if (status?.status === 200) {
|
||||
const parsed = parseJson(status.text)
|
||||
const windows = decodeAntigravityStatus(parsed)
|
||||
if (windows.length > 0) return { windows, planLabel: planFromStatus(parsed) }
|
||||
}
|
||||
const parsed = status?.status === 200 ? parseJson(status.text) : null
|
||||
const windows = summaryWindows.length > 0 ? summaryWindows : decodeAntigravityStatus(parsed)
|
||||
if (windows.length > 0) return { windows, planLabel: planFromStatus(parsed) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export const MENUBAR_QUOTA_PROVIDERS = [
|
|||
'Z.ai',
|
||||
'ZCode',
|
||||
'Kimi Code',
|
||||
'Command Code',
|
||||
'Grok',
|
||||
'Grok Bot',
|
||||
] as const
|
||||
|
|
|
|||
|
|
@ -470,9 +470,12 @@ export type PlanId =
|
|||
| 'cursor-pro'
|
||||
| 'supergrok'
|
||||
| 'supergrok-heavy'
|
||||
| 'google-ai-pro'
|
||||
| 'google-ai-ultra-5x'
|
||||
| 'google-ai-ultra-20x'
|
||||
| 'custom'
|
||||
| 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'all'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'antigravity' | 'all'
|
||||
export type PlanStatus = 'under' | 'near' | 'over'
|
||||
|
||||
/** Serialized plan summary from `attachPlanSummaries` (src/main.ts:90). */
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { reportMemoKey } from '../lib/reportMemoKey'
|
|||
import type { JsonPlanSummary, Period, PlanId, PlanProvider, QuotaProvider, QuotaWindow, StatusJson } from '../lib/types'
|
||||
import type { SettingsPane } from './Settings'
|
||||
|
||||
const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok']
|
||||
const PROVIDER_ORDER: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok', 'antigravity']
|
||||
|
||||
const PLAN_NAMES: Record<PlanId, string> = {
|
||||
'claude-pro': 'Claude Pro',
|
||||
|
|
@ -27,6 +27,9 @@ const PLAN_NAMES: Record<PlanId, string> = {
|
|||
'cursor-pro': 'Cursor Pro',
|
||||
supergrok: 'SuperGrok',
|
||||
'supergrok-heavy': 'SuperGrok Heavy',
|
||||
'google-ai-pro': 'Google AI Pro',
|
||||
'google-ai-ultra-5x': 'Google AI Ultra 5x',
|
||||
'google-ai-ultra-20x': 'Google AI Ultra 20x',
|
||||
custom: 'Custom plan',
|
||||
none: 'API usage',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ const PLAN_PRESETS: PlanPreset[] = [
|
|||
{ id: 'cursor-pro', label: 'Cursor Pro', provider: 'cursor' },
|
||||
{ id: 'supergrok', label: 'SuperGrok', provider: 'grok' },
|
||||
{ id: 'supergrok-heavy', label: 'SuperGrok Heavy', provider: 'grok' },
|
||||
{ id: 'google-ai-pro', label: 'Google AI Pro', provider: 'antigravity' },
|
||||
{ id: 'google-ai-ultra-5x', label: 'Google AI Ultra 5x', provider: 'antigravity' },
|
||||
{ id: 'google-ai-ultra-20x', label: 'Google AI Ultra 20x', provider: 'antigravity' },
|
||||
]
|
||||
|
||||
// Claude and Codex subscriptions are detected from the CLI login (see the
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ codeburn plan set claude-max # $200/month
|
|||
codeburn plan set claude-pro # $20/month
|
||||
codeburn plan set cursor-pro # $20/month
|
||||
codeburn plan set copilot-pro # 1500 AI Credits ($15 equivalent)
|
||||
codeburn plan set google-ai-pro # $19.99/month, Antigravity usage
|
||||
codeburn plan set custom --monthly-usd 200 --provider codex # ChatGPT Pro-style custom plan
|
||||
codeburn plan set custom --credits 20000 --provider copilot # org Copilot allotment
|
||||
codeburn plan reset --provider codex # remove one provider plan
|
||||
|
|
@ -15,7 +16,7 @@ codeburn plan # show configured
|
|||
codeburn plan reset # remove plan config
|
||||
```
|
||||
|
||||
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, Copilot (AI credits), and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. USD presets use publicly stated plan prices (as of April 2026). Copilot presets use official individual AI-credit allotments (Pro 1,500 / Pro+ 7,000 / Max 20,000; fetched 2026-08-23) — not the $10 / $39 / $100 sticker prices — and spend is `total_nano_aiu / 1e9`, never token-priced USD.
|
||||
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, Copilot (AI credits), and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. USD presets use publicly stated plan prices (as of April 2026). Copilot presets use official individual AI-credit allotments (Pro 1,500 / Pro+ 7,000 / Max 20,000; fetched 2026-08-23) — not the $10 / $39 / $100 sticker prices — and spend is `total_nano_aiu / 1e9`, never token-priced USD. Google presets (`google-ai-pro` $19.99, `google-ai-ultra-5x` $99.99, `google-ai-ultra-20x` $199.99; US prices fetched 2026-10-07) count Antigravity usage only, since Gemini CLI no longer signs in with these plans.
|
||||
|
||||
## Provider quota
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,7 @@ struct AntigravityUsage: Sendable, Equatable {
|
|||
/// Per-group/per-model windows, most-constrained first.
|
||||
let details: [Window]
|
||||
var primary: Window? { details.first }
|
||||
/// Plan label from the legacy GetUserStatus payload (summary payloads
|
||||
/// carry none).
|
||||
/// Plan label from GetUserStatus (summary payloads carry none).
|
||||
let plan: String?
|
||||
let fetchedAt: Date
|
||||
}
|
||||
|
|
@ -31,7 +30,7 @@ struct AntigravityUsage: Sendable, Equatable {
|
|||
///
|
||||
/// Endpoints (loopback only, Connect-RPC JSON):
|
||||
/// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary
|
||||
/// (preferred; falls back to)
|
||||
/// (preferred windows; the tier and fallback windows come from)
|
||||
/// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
|
||||
///
|
||||
/// Discovery uses `ps` to find candidate processes (app language
|
||||
|
|
@ -274,26 +273,21 @@ enum AntigravitySubscriptionService {
|
|||
return windows
|
||||
}
|
||||
|
||||
/// planName may sit at the top level, under userStatus, or (legacy) as
|
||||
/// account_plan; the first non-blank string wins.
|
||||
/// userTier.name is the Google subscription; older servers put planName at
|
||||
/// the top level, under userStatus, or as account_plan. The first non-blank
|
||||
/// string wins. planStatus.planInfo is skipped: it says "Pro" even on the
|
||||
/// free tier.
|
||||
static func planFromStatus(_ body: Any?) -> String? {
|
||||
guard let data = body as? [String: Any] else { return nil }
|
||||
let response = data["response"] as? [String: Any]
|
||||
let userStatus = (data["userStatus"] as? [String: Any])
|
||||
?? (response?["userStatus"] as? [String: Any])
|
||||
let planStatus = userStatus?["planStatus"] as? [String: Any]
|
||||
let planInfo = planStatus?["planInfo"] as? [String: Any]
|
||||
let userTier = userStatus?["userTier"] as? [String: Any]
|
||||
let candidates = [
|
||||
userTier?["name"],
|
||||
data["planName"],
|
||||
response?["planName"],
|
||||
userStatus?["planName"],
|
||||
userTier?["name"],
|
||||
planInfo?["planDisplayName"],
|
||||
planInfo?["displayName"],
|
||||
planInfo?["planName"],
|
||||
planInfo?["productName"],
|
||||
planInfo?["planShortName"],
|
||||
data["account_plan"],
|
||||
]
|
||||
return firstNonBlankString(candidates)
|
||||
|
|
@ -365,15 +359,17 @@ enum AntigravitySubscriptionService {
|
|||
) async -> (windows: [AntigravityUsage.Window], plan: String?)? {
|
||||
let body = "{}"
|
||||
for tls in [true, false] {
|
||||
var summaryWindows: [AntigravityUsage.Window] = []
|
||||
if let summary = await deps.request(port, tls, summaryPath, body, csrf), summary.status == 200 {
|
||||
let windows = decodeSummary(parseJson(summary.text))
|
||||
if !windows.isEmpty { return (windows, nil) }
|
||||
summaryWindows = decodeSummary(parseJson(summary.text))
|
||||
}
|
||||
// The summary payload carries no tier, so GetUserStatus is asked either way.
|
||||
var parsed: Any?
|
||||
if let status = await deps.request(port, tls, statusPath, body, csrf), status.status == 200 {
|
||||
let parsed = parseJson(status.text)
|
||||
let windows = decodeStatus(parsed)
|
||||
if !windows.isEmpty { return (windows, planFromStatus(parsed)) }
|
||||
parsed = parseJson(status.text)
|
||||
}
|
||||
let windows = summaryWindows.isEmpty ? decodeStatus(parsed) : summaryWindows
|
||||
if !windows.isEmpty { return (windows, planFromStatus(parsed)) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ struct CapacityDockProvider: RawRepresentable, CaseIterable, Identifiable, Hasha
|
|||
// The ClinePass plan pays for both Cline surfaces, and the CLI records
|
||||
// the extension and the command line tool as separate rows.
|
||||
case "clinepass": ["cline", "cline-cli"]
|
||||
case "commandcode": ["command-code"]
|
||||
default: [payloadProviderID]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ final class CapacityDockProviderQuotaService {
|
|||
// No defaults here: only `.live` may reach the real adapters, so a
|
||||
// test can never silently read the local Cursor session or hit the
|
||||
// network by omitting a field.
|
||||
var refreshClinePass: @Sendable (String) async throws -> QuotaSummary
|
||||
var refreshClinePass: @Sendable (String?) async throws -> QuotaSummary
|
||||
var refreshCommandCode: @Sendable () async throws -> QuotaSummary
|
||||
var refreshCursor: @Sendable () async throws -> QuotaSummary
|
||||
var refreshDevin: @Sendable () async throws -> QuotaSummary
|
||||
var refreshGrok: @Sendable () async throws -> QuotaSummary
|
||||
|
|
@ -32,6 +33,9 @@ final class CapacityDockProviderQuotaService {
|
|||
refreshClinePass: { apiKey in
|
||||
try await ClinePassSubscriptionService.refresh(apiKey: apiKey)
|
||||
},
|
||||
refreshCommandCode: {
|
||||
try await CommandCodeSubscriptionService.refresh()
|
||||
},
|
||||
refreshCursor: {
|
||||
try await CursorSubscriptionService.refresh()
|
||||
},
|
||||
|
|
@ -99,14 +103,16 @@ final class CapacityDockProviderQuotaService {
|
|||
throw CapacityDockProviderFetchFailure(error: error)
|
||||
}
|
||||
case "clinepass":
|
||||
guard let apiKey = credential.sanitizedOverride.apiKey else {
|
||||
throw CapacityDockProviderFetchFailure(
|
||||
message: ClinePassSubscriptionService.FetchError.noCredentials.localizedDescription,
|
||||
disposition: .terminal
|
||||
)
|
||||
}
|
||||
do {
|
||||
return try await dependencies.refreshClinePass(apiKey)
|
||||
return try await dependencies.refreshClinePass(credential.sanitizedOverride.apiKey)
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
throw CapacityDockProviderFetchFailure(error: error)
|
||||
}
|
||||
case "commandcode":
|
||||
do {
|
||||
return try await dependencies.refreshCommandCode()
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch {
|
||||
|
|
@ -173,6 +179,14 @@ struct CapacityDockProviderFetchFailure: LocalizedError, Equatable, Sendable {
|
|||
return .transient
|
||||
}
|
||||
}
|
||||
if let error = error as? CommandCodeSubscriptionService.FetchError {
|
||||
switch error.classification {
|
||||
case .terminalAuth:
|
||||
return .terminal
|
||||
case .transient, .parseFailure:
|
||||
return .transient
|
||||
}
|
||||
}
|
||||
if let error = error as? CursorSubscriptionService.FetchError {
|
||||
switch error.classification {
|
||||
case .terminalAuth:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import Foundation
|
|||
|
||||
/// Live ClinePass quota from the public usage-limits HTTP contract.
|
||||
/// GET https://api.cline.bot/api/v1/users/me/plan/usage-limits with a bearer
|
||||
/// API key. The caller supplies the key; this adapter never reads Keychain.
|
||||
/// token: the caller's saved key, else CLINEPASS_API_KEY / CLINE_API_KEY, else
|
||||
/// the session the Cline CLI keeps in ~/.cline/data/settings/providers.json.
|
||||
/// That file is only read, never refreshed or written. Never reads Keychain.
|
||||
enum ClinePassSubscriptionService {
|
||||
static let usageURL = URL(string: "https://api.cline.bot/api/v1/users/me/plan/usage-limits")!
|
||||
private static let timeoutSeconds: TimeInterval = 15
|
||||
|
|
@ -10,6 +12,7 @@ enum ClinePassSubscriptionService {
|
|||
enum FetchError: Error, Equatable, LocalizedError, Sendable {
|
||||
case noCredentials
|
||||
case authenticationRejected
|
||||
case signInExpired
|
||||
case rateLimited
|
||||
case providerUnavailable
|
||||
case parseFailure
|
||||
|
|
@ -25,7 +28,9 @@ enum ClinePassSubscriptionService {
|
|||
switch self {
|
||||
case .noCredentials, .authenticationRejected:
|
||||
return .terminalAuth
|
||||
case .rateLimited, .providerUnavailable, .network:
|
||||
// Running cline refreshes the session file this only reads, so the
|
||||
// next scheduled read picks it up; keep the last quota until then.
|
||||
case .signInExpired, .rateLimited, .providerUnavailable, .network:
|
||||
return .transient
|
||||
case .parseFailure:
|
||||
return .parseFailure
|
||||
|
|
@ -37,9 +42,11 @@ enum ClinePassSubscriptionService {
|
|||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
return "Enter a ClinePass API key or token, then press Save & Connect."
|
||||
return "Sign in with Cline, or enter a ClinePass API key, then click Retry."
|
||||
case .authenticationRejected:
|
||||
return "ClinePass rejected this API key."
|
||||
case .signInExpired:
|
||||
return "Cline sign-in expired. Run cline to refresh it."
|
||||
case .rateLimited:
|
||||
return "ClinePass rate-limited the quota request."
|
||||
case .providerUnavailable:
|
||||
|
|
@ -52,8 +59,16 @@ enum ClinePassSubscriptionService {
|
|||
}
|
||||
}
|
||||
|
||||
struct Credential: Equatable, Sendable {
|
||||
let token: String
|
||||
let isOAuth: Bool
|
||||
let expiresAt: Date?
|
||||
}
|
||||
|
||||
struct Deps: Sendable {
|
||||
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
var loadAmbientCredential: @Sendable () -> Credential?
|
||||
var now: @Sendable () -> Date
|
||||
|
||||
static let live = Deps(
|
||||
fetch: { request in
|
||||
|
|
@ -62,20 +77,88 @@ enum ClinePassSubscriptionService {
|
|||
throw FetchError.network
|
||||
}
|
||||
return (data, http)
|
||||
}
|
||||
},
|
||||
loadAmbientCredential: { ambientCredential(environment: ProcessInfo.processInfo.environment) },
|
||||
now: { Date() }
|
||||
)
|
||||
}
|
||||
|
||||
static func ambientCredential(environment: [String: String]) -> Credential? {
|
||||
for name in ["CLINEPASS_API_KEY", "CLINE_API_KEY"] {
|
||||
if let key = cleaned(environment[name]) {
|
||||
return Credential(token: key, isOAuth: false, expiresAt: nil)
|
||||
}
|
||||
}
|
||||
guard let data = try? Data(contentsOf: providersFileURL(environment: environment)) else { return nil }
|
||||
return fileCredential(data)
|
||||
}
|
||||
|
||||
/// Cline's own lookup order: a settings file override, then a data dir, then a Cline dir.
|
||||
static func providersFileURL(
|
||||
environment: [String: String],
|
||||
home: URL = FileManager.default.homeDirectoryForCurrentUser
|
||||
) -> URL {
|
||||
func path(_ name: String) -> URL? {
|
||||
guard let value = cleaned(environment[name]) else { return nil }
|
||||
if value == "~" { return home }
|
||||
if value.hasPrefix("~/") { return home.appendingPathComponent(String(value.dropFirst(2))) }
|
||||
return URL(fileURLWithPath: value)
|
||||
}
|
||||
if let file = path("CLINE_PROVIDER_SETTINGS_PATH") { return file }
|
||||
let dataDir = path("CLINE_DATA_DIR")
|
||||
?? (path("CLINE_DIR") ?? home.appendingPathComponent(".cline")).appendingPathComponent("data")
|
||||
return dataDir.appendingPathComponent("settings/providers.json")
|
||||
}
|
||||
|
||||
/// Matches Cline's getApiKey: the OAuth access token wins over keys kept in the same entry.
|
||||
static func fileCredential(_ data: Data) -> Credential? {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let providers = root["providers"] as? [String: Any],
|
||||
let cline = providers["cline"] as? [String: Any],
|
||||
let settings = cline["settings"] as? [String: Any] else { return nil }
|
||||
let auth = settings["auth"] as? [String: Any]
|
||||
if let access = cleaned(auth?["accessToken"] as? String) {
|
||||
let expiresAt = (auth?["expiresAt"] as? NSNumber).map { Date(timeIntervalSince1970: $0.doubleValue / 1000) }
|
||||
return Credential(
|
||||
token: access.hasPrefix("workos:") ? access : "workos:\(access)",
|
||||
isOAuth: true,
|
||||
expiresAt: expiresAt
|
||||
)
|
||||
}
|
||||
guard let key = cleaned(settings["apiKey"] as? String) ?? cleaned(auth?["apiKey"] as? String) else {
|
||||
return nil
|
||||
}
|
||||
return Credential(token: key, isOAuth: false, expiresAt: nil)
|
||||
}
|
||||
|
||||
private static func cleaned(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func refresh(apiKey: String, deps: Deps = .live) async throws -> QuotaSummary {
|
||||
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw FetchError.noCredentials }
|
||||
static func refresh(apiKey: String?, deps: Deps = .live) async throws -> QuotaSummary {
|
||||
let credential: Credential
|
||||
if let key = cleaned(apiKey) {
|
||||
credential = Credential(token: key, isOAuth: false, expiresAt: nil)
|
||||
} else {
|
||||
let load = deps.loadAmbientCredential
|
||||
guard let ambient = await Task.detached(operation: { load() }).value else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
credential = ambient
|
||||
}
|
||||
if credential.isOAuth, let expiresAt = credential.expiresAt, expiresAt <= deps.now() {
|
||||
throw FetchError.signInExpired
|
||||
}
|
||||
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeoutSeconds
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("Bearer \(credential.token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
|
|
@ -91,7 +174,7 @@ enum ClinePassSubscriptionService {
|
|||
case 200:
|
||||
break
|
||||
case 401, 403:
|
||||
throw FetchError.authenticationRejected
|
||||
throw credential.isOAuth ? FetchError.signInExpired : FetchError.authenticationRejected
|
||||
case 429:
|
||||
throw FetchError.rateLimited
|
||||
case 500...599:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import Foundation
|
||||
|
||||
/// Live Command Code quota from its billing endpoints, with the API key the
|
||||
/// Command Code CLI keeps in ~/.commandcode/auth.json (read-only, never logged).
|
||||
/// GET /alpha/billing/credits carries the 5-hour and weekly windows as USD
|
||||
/// credits used of a cap (resetAt epoch ms, 0 while no window is open) plus
|
||||
/// the remaining credits; GET /alpha/billing/subscriptions carries the plan id.
|
||||
/// Mirrors src/quota/commandcode.ts.
|
||||
enum CommandCodeSubscriptionService {
|
||||
static let creditsURL = URL(string: "https://api.commandcode.ai/alpha/billing/credits")!
|
||||
static let subscriptionsURL = URL(string: "https://api.commandcode.ai/alpha/billing/subscriptions")!
|
||||
private static let timeoutSeconds: TimeInterval = 15
|
||||
|
||||
enum FetchError: Error, Equatable, LocalizedError, Sendable {
|
||||
case noCredentials
|
||||
case authenticationRejected
|
||||
case rateLimited
|
||||
case providerUnavailable
|
||||
case parseFailure
|
||||
case network
|
||||
|
||||
enum Classification: Equatable, Sendable {
|
||||
case terminalAuth
|
||||
case transient
|
||||
case parseFailure
|
||||
}
|
||||
|
||||
var classification: Classification {
|
||||
switch self {
|
||||
case .noCredentials, .authenticationRejected: .terminalAuth
|
||||
case .rateLimited, .providerUnavailable, .network: .transient
|
||||
case .parseFailure: .parseFailure
|
||||
}
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials:
|
||||
"Sign in with the Command Code CLI, then click Retry."
|
||||
case .authenticationRejected:
|
||||
"Command Code session expired. Sign in with the Command Code CLI again."
|
||||
case .rateLimited:
|
||||
"Command Code rate-limited the quota request."
|
||||
case .providerUnavailable:
|
||||
"Command Code is temporarily unavailable."
|
||||
case .parseFailure:
|
||||
"Command Code quota response was malformed."
|
||||
case .network:
|
||||
"Network error fetching Command Code quota."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Deps: Sendable {
|
||||
var loadAPIKey: @Sendable () -> String?
|
||||
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
|
||||
static let live = Deps(
|
||||
loadAPIKey: {
|
||||
let override = ProcessInfo.processInfo.environment["CODEBURN_COMMANDCODE_DIR"] ?? ""
|
||||
let root = override.isEmpty
|
||||
? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".commandcode")
|
||||
: URL(fileURLWithPath: override)
|
||||
return apiKey(fromAuthFile: root.appendingPathComponent("auth.json"))
|
||||
},
|
||||
fetch: { request in
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else { throw FetchError.network }
|
||||
return (data, http)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
static func apiKey(fromAuthFile url: URL) -> String? {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let key = (root["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty else { return nil }
|
||||
return key
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func refresh(deps: Deps = .live) async throws -> QuotaSummary {
|
||||
let loadAPIKey = deps.loadAPIKey
|
||||
guard let key = await Task.detached(operation: { loadAPIKey() }).value else {
|
||||
throw FetchError.noCredentials
|
||||
}
|
||||
|
||||
func request(_ url: URL) -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = timeoutSeconds
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
|
||||
// The API refuses requests without a User-Agent it recognizes.
|
||||
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
||||
return request
|
||||
}
|
||||
|
||||
let fetch = deps.fetch
|
||||
async let subscription = try? fetch(request(subscriptionsURL))
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await fetch(request(creditsURL))
|
||||
} catch let error as FetchError {
|
||||
throw error
|
||||
} catch {
|
||||
throw FetchError.network
|
||||
}
|
||||
|
||||
switch response.statusCode {
|
||||
case 200: break
|
||||
case 401, 403: throw FetchError.authenticationRejected
|
||||
case 429: throw FetchError.rateLimited
|
||||
case 500...599: throw FetchError.providerUnavailable
|
||||
default: throw FetchError.parseFailure
|
||||
}
|
||||
|
||||
var planID: String?
|
||||
if let (body, http) = await subscription, http.statusCode == 200,
|
||||
let root = try? JSONSerialization.jsonObject(with: body) as? [String: Any] {
|
||||
planID = (root["data"] as? [String: Any])?["planId"] as? String
|
||||
}
|
||||
return try decode(data, planID: planID)
|
||||
}
|
||||
|
||||
static func decode(_ data: Data, planID: String? = nil) throws -> QuotaSummary {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw FetchError.parseFailure
|
||||
}
|
||||
let limits = root["windowLimits"] as? [String: Any] ?? [:]
|
||||
let fiveHour = window("5-hour", limits["fiveHour"])
|
||||
let weekly = window("Weekly", limits["weekly"])
|
||||
let details = [fiveHour, weekly].compactMap { $0 }
|
||||
guard !details.isEmpty else { throw FetchError.parseFailure }
|
||||
return QuotaSummary(
|
||||
providerFilter: .all,
|
||||
connection: .connected,
|
||||
primary: weekly ?? fiveHour,
|
||||
details: details,
|
||||
planLabel: planLabel(planID),
|
||||
footerLines: creditsLine(root["credits"]).map { [$0] } ?? []
|
||||
)
|
||||
}
|
||||
|
||||
/// "individual-go-v1" -> "Go". Unknown shapes pass through unchanged.
|
||||
static func planLabel(_ planID: String?) -> String? {
|
||||
guard var core = planID?.trimmingCharacters(in: .whitespacesAndNewlines), !core.isEmpty else { return nil }
|
||||
if core.hasPrefix("individual-") { core.removeFirst("individual-".count) }
|
||||
if let range = core.range(of: "-v[0-9]+$", options: .regularExpression) { core.removeSubrange(range) }
|
||||
return core.prefix(1).uppercased() + core.dropFirst()
|
||||
}
|
||||
|
||||
private static func window(_ label: String, _ raw: Any?) -> QuotaSummary.Window? {
|
||||
guard let row = raw as? [String: Any],
|
||||
let used = number(row["used"]),
|
||||
let cap = number(row["cap"]), cap > 0 else { return nil }
|
||||
let resetAt = number(row["resetAt"]) ?? 0
|
||||
return QuotaSummary.Window(
|
||||
label: label,
|
||||
percent: min(1, max(0, used / cap)),
|
||||
resetsAt: resetAt > 0 ? Date(timeIntervalSince1970: resetAt / 1000) : nil
|
||||
)
|
||||
}
|
||||
|
||||
private static func creditsLine(_ raw: Any?) -> String? {
|
||||
guard let credits = raw as? [String: Any] else { return nil }
|
||||
let parts = [("monthlyCredits", "monthly"), ("purchasedCredits", "purchased"), ("freeCredits", "free")]
|
||||
.compactMap { key, name in number(credits[key]).map { (name, $0) } }
|
||||
guard !parts.isEmpty else { return nil }
|
||||
let shown = parts.filter { $0.1 > 0 }
|
||||
guard !shown.isEmpty else { return "Credits left: $0.00" }
|
||||
return "Credits left: " + shown.map { String(format: "$%.2f %@", $0.1, $0.0) }.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private static func number(_ value: Any?) -> Double? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
|
||||
let double = number.doubleValue
|
||||
return double.isFinite ? double : nil
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,8 @@ enum ProviderConnectionCatalog {
|
|||
entry("azureopenai", "Azure OpenAI", [.automatic, .api], [.apiTokenOrCloudCredentials]),
|
||||
entry("claude", "Claude", [.automatic, .api, .web, .cli, .oauth],
|
||||
[.localAppOrCLI, .oauth, .apiTokenOrCloudCredentials, .cookieOrWebSession], live: true),
|
||||
entry("clinepass", "ClinePass", [.automatic, .api], [.apiTokenOrCloudCredentials], live: true),
|
||||
entry("clinepass", "ClinePass", [.automatic, .api, .cli],
|
||||
[.localAppOrCLI, .apiTokenOrCloudCredentials], live: true),
|
||||
entry("cursor", "Cursor", [.automatic, .cli, .web], [.localAppOrCLI, .cookieOrWebSession], live: true),
|
||||
entry("opencode", "OpenCode", [.automatic, .web], [.cookieOrWebSession]),
|
||||
entry("opencodego", "OpenCode Go", [.automatic, .api, .web],
|
||||
|
|
@ -115,7 +116,7 @@ enum ProviderConnectionCatalog {
|
|||
[.localAppOrCLI, .apiTokenOrCloudCredentials]),
|
||||
entry("crof", "Crof", [.automatic, .api], [.apiTokenOrCloudCredentials]),
|
||||
entry("venice", "Venice", [.automatic, .api], [.apiTokenOrCloudCredentials]),
|
||||
entry("commandcode", "Command Code", [.automatic, .web], [.cookieOrWebSession]),
|
||||
entry("commandcode", "Command Code", [.automatic, .cli], [.localAppOrCLI], live: true),
|
||||
entry("qoder", "Qoder", [.automatic, .web], [.cookieOrWebSession]),
|
||||
entry("stepfun", "StepFun", [.automatic, .web], [.cookieOrWebSession]),
|
||||
entry("bedrock", "AWS Bedrock", [.automatic, .api], [.apiTokenOrCloudCredentials]),
|
||||
|
|
@ -166,6 +167,10 @@ enum ProviderConnectionCatalog {
|
|||
enum ProviderConnectionGuidance {
|
||||
static func instruction(for provider: CapacityDockProvider) -> String {
|
||||
let methods = provider.catalogEntry.authMethods
|
||||
// ClinePass rides on the Cline sign-in; there is no ClinePass app.
|
||||
if provider.id == "clinepass" {
|
||||
return L("Sign in with Cline, or enter a ClinePass API key below, then click Retry.")
|
||||
}
|
||||
if methods == [.apiTokenOrCloudCredentials] {
|
||||
return L("Enter an API key or token below, then press Save & Connect.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "Enter an API key or token below, then press Save & Connect.";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "Sign in to %@ in a supported browser, then click Retry.";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "Sign in with the %@ app or CLI, then click Retry.";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "Sign in with Cline, or enter a ClinePass API key below, then click Retry.";
|
||||
"Complete %@ OAuth, then click Retry." = "Complete %@ OAuth, then click Retry.";
|
||||
"Start the local %@ service, then click Retry." = "Start the local %@ service, then click Retry.";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "Enter the required API or cloud credentials below, then press Save & Connect.";
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "Entrez une clé API ou un jeton ci-dessous, puis appuyez sur Enregistrer et connecter.";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "Connectez-vous à %@ dans un navigateur pris en charge, puis cliquez sur Réessayer.";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "Connectez-vous via l'app ou la CLI %@, puis cliquez sur Réessayer.";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "Connectez-vous avec Cline, ou entrez une clé API ClinePass ci-dessous, puis cliquez sur Réessayer.";
|
||||
"Complete %@ OAuth, then click Retry." = "Terminez l'authentification OAuth %@, puis cliquez sur Réessayer.";
|
||||
"Start the local %@ service, then click Retry." = "Démarrez le service local %@, puis cliquez sur Réessayer.";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "Entrez les identifiants API ou cloud requis ci-dessous, puis appuyez sur Enregistrer et connecter.";
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "下にAPIキーまたはトークンを入力し、「保存して接続」を押してください。";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "対応ブラウザで%@にサインインし、「再試行」をクリックしてください。";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "%@のアプリまたはCLIでサインインし、「再試行」をクリックしてください。";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "Clineでサインインするか、下にClinePassのAPIキーを入力して、「再試行」をクリックしてください。";
|
||||
"Complete %@ OAuth, then click Retry." = "%@のOAuth認証を完了し、「再試行」をクリックしてください。";
|
||||
"Start the local %@ service, then click Retry." = "ローカルの%@サービスを起動し、「再試行」をクリックしてください。";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "下に必要なAPIまたはクラウド認証情報を入力し、「保存して接続」を押してください。";
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "아래에 API 키 또는 토큰을 입력한 다음 “저장 및 연결”을 누르세요.";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "지원되는 브라우저에서 %@에 로그인한 다음 “재시도”를 클릭하세요.";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "%@ 앱 또는 CLI로 로그인한 다음 “재시도”를 클릭하세요.";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "Cline으로 로그인하거나 아래에 ClinePass API 키를 입력한 다음 “재시도”를 클릭하세요.";
|
||||
"Complete %@ OAuth, then click Retry." = "%@ OAuth 인증을 완료한 다음 “재시도”를 클릭하세요.";
|
||||
"Start the local %@ service, then click Retry." = "로컬 %@ 서비스를 실행한 다음 “재시도”를 클릭하세요.";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "아래에 필요한 API 또는 클라우드 자격 증명을 입력한 다음 “저장 및 연결”을 누르세요.";
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "在下方输入 API 密钥或令牌,然后点按“存储并连接”。";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "在受支持的浏览器中登录 %@,然后点按“重试”。";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "通过 %@ 应用或命令行工具登录,然后点按“重试”。";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "通过 Cline 登录,或在下方输入 ClinePass API 密钥,然后点按“重试”。";
|
||||
"Complete %@ OAuth, then click Retry." = "完成 %@ 的 OAuth 授权,然后点按“重试”。";
|
||||
"Start the local %@ service, then click Retry." = "启动本地 %@ 服务,然后点按“重试”。";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "在下方填入所需的 API 或云凭证,然后点按“存储并连接”。";
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
"Enter an API key or token below, then press Save & Connect." = "在下方輸入 API 金鑰或權杖,然後點按“儲存並連線”。";
|
||||
"Sign in to %@ in a supported browser, then click Retry." = "在受支援的瀏覽器中登入 %@,然後點按“重試”。";
|
||||
"Sign in with the %@ app or CLI, then click Retry." = "透過 %@ 應用或命令列工具登入,然後點按“重試”。";
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry." = "透過 Cline 登入,或在下方輸入 ClinePass API 金鑰,然後點按“重試”。";
|
||||
"Complete %@ OAuth, then click Retry." = "完成 %@ 的 OAuth 授權,然後點按“重試”。";
|
||||
"Start the local %@ service, then click Retry." = "啟動本地 %@ 服務,然後點按“重試”。";
|
||||
"Enter the required API or cloud credentials below, then press Save & Connect." = "在下方填入所需的 API 或雲憑證,然後點按“儲存並連線”。";
|
||||
|
|
|
|||
|
|
@ -255,6 +255,24 @@ final class AntigravityQuotaTests: XCTestCase {
|
|||
XCTAssertNil(recorder.calls.first?.csrf)
|
||||
}
|
||||
|
||||
func testLabelsSummaryWindowsWithUserTierNotPlanInfo() async throws {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
ps: Self.cliLine,
|
||||
lsof: "agy 1237 user 5u IPv4 0x1 0t0 TCP *:60555 (LISTEN)\n",
|
||||
recorder: recorder
|
||||
) { _, tls, path in
|
||||
guard tls else { return nil }
|
||||
if path.contains("RetrieveUserQuotaSummary") {
|
||||
return (200, #"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[{"bucketId":"gemini-weekly","remainingFraction":0.4}]}]}}"#)
|
||||
}
|
||||
return (200, #"{"userStatus":{"userTier":{"id":"free-tier","name":"Antigravity Starter Quota"},"planStatus":{"planInfo":{"planName":"Pro"}}}}"#)
|
||||
}
|
||||
let usage = try await AntigravitySubscriptionService.refresh(deps: deps)
|
||||
XCTAssertEqual(usage.plan, "Antigravity Starter Quota")
|
||||
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 60, accuracy: 0.001)
|
||||
}
|
||||
|
||||
func testFallsBackToGetUserStatusAndLiftsPlanNameWhenSummaryHasNoWindows() async throws {
|
||||
let recorder = CallRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
|
|
|
|||
|
|
@ -18,12 +18,20 @@ struct CapacityDockConnectionActionTests {
|
|||
|
||||
@Test("API-only providers route the dock action to credential entry")
|
||||
func apiCredentialActionTitle() {
|
||||
let provider = CapacityDockProvider(rawValue: "clinepass")!
|
||||
let provider = CapacityDockProvider(rawValue: "openrouter")!
|
||||
#expect(CapacityDockConnectionAction.reconnect.title(for: provider) == "Add API Key")
|
||||
#expect(ProviderConnectionGuidance.dockInstruction(for: provider) ==
|
||||
"Add an API key or token in Provider Settings.")
|
||||
}
|
||||
|
||||
@Test("ClinePass reconnects through the Cline sign-in, with a key as the alternative")
|
||||
func clinePassActionTitle() {
|
||||
let provider = CapacityDockProvider(rawValue: "clinepass")!
|
||||
#expect(CapacityDockConnectionAction.reconnect.title(for: provider) == CapacityDockConnectionAction.reconnect.title)
|
||||
#expect(ProviderConnectionGuidance.dockInstruction(for: provider) ==
|
||||
"Sign in with Cline, or enter a ClinePass API key below, then click Retry.")
|
||||
}
|
||||
|
||||
@Test("active and automatically recovering states do not offer a connection action")
|
||||
func noActionWhileActive() {
|
||||
let states: [QuotaSummary.Connection] = [
|
||||
|
|
|
|||
|
|
@ -413,16 +413,24 @@ struct CapacityDockPresentationTests {
|
|||
|
||||
@Test("Credential-only providers receive an actionable connection instruction")
|
||||
func apiCredentialGuidance() {
|
||||
let provider = CapacityDockProvider(rawValue: "clinepass")!
|
||||
let provider = CapacityDockProvider(rawValue: "openrouter")!
|
||||
#expect(ProviderConnectionGuidance.instruction(for: provider) ==
|
||||
"Enter an API key or token below, then press Save & Connect.")
|
||||
}
|
||||
|
||||
@Test("Browser-session providers receive an actionable connection instruction")
|
||||
func browserSessionGuidance() {
|
||||
let provider = CapacityDockProvider(rawValue: "qoder")!
|
||||
#expect(ProviderConnectionGuidance.instruction(for: provider) ==
|
||||
"Sign in to Qoder in a supported browser, then click Retry.")
|
||||
}
|
||||
|
||||
@Test("Command Code connects from its CLI sign-in")
|
||||
func commandCodeGuidance() {
|
||||
let provider = CapacityDockProvider(rawValue: "commandcode")!
|
||||
#expect(ProviderConnectionGuidance.instruction(for: provider) ==
|
||||
"Sign in to Command Code in a supported browser, then click Retry.")
|
||||
"Sign in with the Command Code app or CLI, then click Retry.")
|
||||
#expect(provider.payloadProviderIDs == ["command-code"])
|
||||
}
|
||||
|
||||
@Test("Grok Build offers direct one-click local login discovery")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
}
|
||||
nonisolated private static let unusedCommandCode: @Sendable () async throws -> QuotaSummary = {
|
||||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
}
|
||||
nonisolated private static let unusedDevin: @Sendable () async throws -> QuotaSummary = {
|
||||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
|
|
@ -38,9 +42,10 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
let expected = Self.summary(percent: 0.42)
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { apiKey in
|
||||
await capture.record(apiKey)
|
||||
await capture.record(apiKey ?? "")
|
||||
return expected
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -70,6 +75,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -100,6 +106,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: { expected },
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -125,6 +132,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: { expected },
|
||||
|
|
@ -150,6 +158,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -175,6 +184,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -208,6 +218,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: refresh,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -225,13 +236,15 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test("ClinePass requires its own saved API key")
|
||||
func requiresClinePassKey() async throws {
|
||||
@Test("ClinePass without a saved key falls back to the Cline sign-in")
|
||||
func clinePassWithoutSavedKey() async throws {
|
||||
let capture = SecretCapture()
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in
|
||||
Issue.record("Adapter must not run without a key")
|
||||
return Self.summary()
|
||||
refreshClinePass: { apiKey in
|
||||
await capture.record(apiKey ?? "<none>")
|
||||
throw ClinePassSubscriptionService.FetchError.noCredentials
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -241,22 +254,80 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
))
|
||||
let provider = try #require(CapacityDockProvider(rawValue: "clinepass"))
|
||||
|
||||
await #expect(throws: CapacityDockProviderFetchFailure.self) {
|
||||
try await service.fetch(
|
||||
provider: provider,
|
||||
credential: CapacityDockProviderCredential(sourceMode: "api", apiKey: " ")
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await service.fetch(
|
||||
provider: provider,
|
||||
credential: CapacityDockProviderCredential(sourceMode: "api", apiKey: "")
|
||||
credential: CapacityDockProviderCredential(sourceMode: "api", apiKey: " ")
|
||||
)
|
||||
Issue.record("Expected a terminal missing-key failure")
|
||||
Issue.record("Expected a terminal missing-credential failure")
|
||||
} catch let failure as CapacityDockProviderFetchFailure {
|
||||
#expect(failure.disposition == .terminal)
|
||||
#expect(failure.message == "Enter a ClinePass API key or token, then press Save & Connect.")
|
||||
#expect(failure.message == "Sign in with Cline, or enter a ClinePass API key, then click Retry.")
|
||||
}
|
||||
#expect(await capture.values == ["<none>"])
|
||||
}
|
||||
|
||||
@Test("An expired Cline sign-in is retried with its guidance; a rejected key is terminal")
|
||||
func clinePassExpiredSignInIsTransient() async throws {
|
||||
let provider = try #require(CapacityDockProvider(rawValue: "clinepass"))
|
||||
let cases: [(ClinePassSubscriptionService.FetchError, CapacityDockProviderFetchFailureDisposition)] = [
|
||||
(.signInExpired, .transient),
|
||||
(.authenticationRejected, .terminal),
|
||||
(.noCredentials, .terminal),
|
||||
]
|
||||
for (error, expectedDisposition) in cases {
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in throw error },
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
refreshGrokBot: Self.unusedGrokBot,
|
||||
refreshZai: Self.unusedZai,
|
||||
refreshZcode: Self.unusedZcode
|
||||
))
|
||||
do {
|
||||
_ = try await service.fetch(provider: provider, credential: CapacityDockProviderCredential())
|
||||
Issue.record("Expected \(error) to fail")
|
||||
} catch let failure as CapacityDockProviderFetchFailure {
|
||||
#expect(failure.disposition == expectedDisposition)
|
||||
#expect(failure.message == error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Command Code dispatches to the CLI key and an expired session is terminal")
|
||||
func dispatchesCommandCode() async throws {
|
||||
let expected = Self.summary(percent: 0.5)
|
||||
let provider = try #require(CapacityDockProvider(rawValue: "commandcode"))
|
||||
let cases: [(@Sendable () async throws -> QuotaSummary, CapacityDockProviderFetchFailureDisposition?)] = [
|
||||
({ expected }, nil),
|
||||
({ throw CommandCodeSubscriptionService.FetchError.noCredentials }, .terminal),
|
||||
({ throw CommandCodeSubscriptionService.FetchError.authenticationRejected }, .terminal),
|
||||
({ throw CommandCodeSubscriptionService.FetchError.network }, .transient),
|
||||
({ throw CommandCodeSubscriptionService.FetchError.parseFailure }, .transient),
|
||||
]
|
||||
for (refresh, expectedDisposition) in cases {
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in
|
||||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: refresh,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
refreshGrokBot: Self.unusedGrokBot,
|
||||
refreshZai: Self.unusedZai,
|
||||
refreshZcode: Self.unusedZcode
|
||||
))
|
||||
do {
|
||||
let result = try await service.fetch(provider: provider, credential: CapacityDockProviderCredential())
|
||||
#expect(expectedDisposition == nil)
|
||||
#expect(result == expected)
|
||||
} catch let failure as CapacityDockProviderFetchFailure {
|
||||
#expect(failure.disposition == expectedDisposition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -267,6 +338,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -293,6 +365,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
func classifiesAuthenticationFailure() async throws {
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in throw ClinePassSubscriptionService.FetchError.authenticationRejected },
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -322,6 +395,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
for error in errors {
|
||||
let service = CapacityDockProviderQuotaService(dependencies: .init(
|
||||
refreshClinePass: { _ in throw error },
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -362,6 +436,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: { throw error },
|
||||
|
|
@ -399,6 +474,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
Issue.record("Wrong adapter dispatched")
|
||||
return Self.summary()
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
@ -434,6 +510,7 @@ struct CapacityDockProviderQuotaServiceTests {
|
|||
await gate.pause()
|
||||
return Self.summary(percent: 0.73)
|
||||
},
|
||||
refreshCommandCode: Self.unusedCommandCode,
|
||||
refreshCursor: Self.unusedCursor,
|
||||
refreshDevin: Self.unusedDevin,
|
||||
refreshGrok: Self.unusedGrok,
|
||||
|
|
|
|||
|
|
@ -39,16 +39,109 @@ final class ClinePassQuotaTests: XCTestCase {
|
|||
|
||||
private static func makeDeps(
|
||||
recorder: RequestRecorder,
|
||||
ambient: ClinePassSubscriptionService.Credential? = nil,
|
||||
respond: @escaping @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
) -> ClinePassSubscriptionService.Deps {
|
||||
ClinePassSubscriptionService.Deps(
|
||||
fetch: { request in
|
||||
recorder.record(request)
|
||||
return try await respond(request)
|
||||
}
|
||||
},
|
||||
loadAmbientCredential: { ambient },
|
||||
now: { Date(timeIntervalSince1970: 1_800_000_000) }
|
||||
)
|
||||
}
|
||||
|
||||
private static func providersFile(_ settings: String) -> Data {
|
||||
#"{"providers":{"cline":{"settings":\#(settings)},"anthropic":{"settings":{"apiKey":"other"}}}}"#
|
||||
.data(using: .utf8)!
|
||||
}
|
||||
|
||||
func testCredentialFilePrefersAccessTokenThenApiKeyThenAuthApiKey() {
|
||||
let oauth = ClinePassSubscriptionService.fileCredential(Self.providersFile(
|
||||
#"{"apiKey":"k1","auth":{"accessToken":"workos:tok","apiKey":"k2","expiresAt":1800000060000}}"#))
|
||||
XCTAssertEqual(oauth, .init(token: "workos:tok", isOAuth: true, expiresAt: Date(timeIntervalSince1970: 1_800_000_060)))
|
||||
XCTAssertEqual(
|
||||
ClinePassSubscriptionService.fileCredential(Self.providersFile(#"{"auth":{"accessToken":"bare"}}"#))?.token,
|
||||
"workos:bare")
|
||||
XCTAssertEqual(
|
||||
ClinePassSubscriptionService.fileCredential(Self.providersFile(#"{"apiKey":"k1","auth":{"apiKey":"k2"}}"#)),
|
||||
.init(token: "k1", isOAuth: false, expiresAt: nil))
|
||||
XCTAssertEqual(
|
||||
ClinePassSubscriptionService.fileCredential(Self.providersFile(#"{"auth":{"apiKey":"k2"}}"#))?.token, "k2")
|
||||
XCTAssertNil(ClinePassSubscriptionService.fileCredential(Self.providersFile(#"{"provider":"cline"}"#)))
|
||||
}
|
||||
|
||||
func testProvidersFileFollowsClineOverrides() {
|
||||
let home = URL(fileURLWithPath: "/home/u")
|
||||
let path = { (env: [String: String]) in
|
||||
ClinePassSubscriptionService.providersFileURL(environment: env, home: home).path
|
||||
}
|
||||
XCTAssertEqual(path([:]), "/home/u/.cline/data/settings/providers.json")
|
||||
XCTAssertEqual(path(["CLINE_DIR": "~/c"]), "/home/u/c/data/settings/providers.json")
|
||||
XCTAssertEqual(path(["CLINE_DIR": "/c", "CLINE_DATA_DIR": "/d"]), "/d/settings/providers.json")
|
||||
XCTAssertEqual(path(["CLINE_DATA_DIR": "/d", "CLINE_PROVIDER_SETTINGS_PATH": "/f.json"]), "/f.json")
|
||||
}
|
||||
|
||||
func testWithoutSavedKeyUsesTheClineSession() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
recorder: recorder,
|
||||
ambient: .init(token: "workos:session", isOAuth: true, expiresAt: Date(timeIntervalSince1970: 1_800_000_060))
|
||||
) { request in
|
||||
(Self.successBody.data(using: .utf8)!, Self.httpResponse(request, status: 200))
|
||||
}
|
||||
_ = try await ClinePassSubscriptionService.refresh(apiKey: " ", deps: deps)
|
||||
_ = try await ClinePassSubscriptionService.refresh(apiKey: Self.syntheticKey, deps: deps)
|
||||
XCTAssertEqual(
|
||||
recorder.requests.map { $0.value(forHTTPHeaderField: "Authorization") },
|
||||
["Bearer workos:session", "Bearer \(Self.syntheticKey)"])
|
||||
}
|
||||
|
||||
func testExpiredClineSessionStopsBeforeTheNetwork() async {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(
|
||||
recorder: recorder,
|
||||
ambient: .init(token: "workos:old", isOAuth: true, expiresAt: Date(timeIntervalSince1970: 1_799_999_999))
|
||||
) { request in
|
||||
(Self.successBody.data(using: .utf8)!, Self.httpResponse(request, status: 200))
|
||||
}
|
||||
do {
|
||||
_ = try await ClinePassSubscriptionService.refresh(apiKey: nil, deps: deps)
|
||||
XCTFail("Expected an expired sign-in")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ClinePassSubscriptionService.FetchError, .signInExpired)
|
||||
XCTAssertEqual(error.localizedDescription, "Cline sign-in expired. Run cline to refresh it.")
|
||||
XCTAssertEqual((error as? ClinePassSubscriptionService.FetchError)?.classification, .transient)
|
||||
}
|
||||
XCTAssertTrue(recorder.requests.isEmpty)
|
||||
}
|
||||
|
||||
func testRejectedClineSessionReadsAsExpiredAndMissingCredentialIsTerminal() async {
|
||||
let recorder = RequestRecorder()
|
||||
let rejected = Self.makeDeps(
|
||||
recorder: recorder,
|
||||
ambient: .init(token: "workos:revoked", isOAuth: true, expiresAt: nil)
|
||||
) { request in
|
||||
(Data("{}".utf8), Self.httpResponse(request, status: 401))
|
||||
}
|
||||
do {
|
||||
_ = try await ClinePassSubscriptionService.refresh(apiKey: nil, deps: rejected)
|
||||
XCTFail("Expected an expired sign-in")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ClinePassSubscriptionService.FetchError, .signInExpired)
|
||||
}
|
||||
let none = Self.makeDeps(recorder: recorder) { request in
|
||||
(Data("{}".utf8), Self.httpResponse(request, status: 200))
|
||||
}
|
||||
do {
|
||||
_ = try await ClinePassSubscriptionService.refresh(apiKey: nil, deps: none)
|
||||
XCTFail("Expected missing credentials")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? ClinePassSubscriptionService.FetchError, .noCredentials)
|
||||
}
|
||||
}
|
||||
|
||||
func testSuccessfulPayloadMapsFiveHourWeeklyAndMonthlyWindows() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let deps = Self.makeDeps(recorder: recorder) { request in
|
||||
|
|
|
|||
108
mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift
Normal file
108
mac/Tests/CodeBurnMenubarTests/CommandCodeQuotaTests.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Command Code quota")
|
||||
@MainActor
|
||||
struct CommandCodeQuotaTests {
|
||||
private final class RequestRecorder: @unchecked Sendable {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
func record(_ request: URLRequest) { requests.append(request) }
|
||||
}
|
||||
|
||||
nonisolated private static let creditsBody = """
|
||||
{"credits":{"monthlyCredits":3.9706645399,"purchasedCredits":0,"freeCredits":0},
|
||||
"windowLimits":{"limited":true,"exceeded":"weekly",
|
||||
"fiveHour":{"used":0.75,"cap":3,"exceeded":false,"resetAt":0},
|
||||
"weekly":{"used":6.0293354601,"cap":6,"exceeded":true,"resetAt":1791653049551}}}
|
||||
"""
|
||||
nonisolated private static let subscriptionBody = #"{"success":true,"data":{"status":"active","planId":"individual-go-v1"}}"#
|
||||
|
||||
nonisolated private static func response(_ request: URLRequest, _ status: Int) -> HTTPURLResponse {
|
||||
HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!
|
||||
}
|
||||
|
||||
private static func deps(
|
||||
key: String? = "synthetic-commandcode-key",
|
||||
recorder: RequestRecorder = RequestRecorder(),
|
||||
respond: @escaping @Sendable (URLRequest) throws -> (Data, HTTPURLResponse)
|
||||
) -> CommandCodeSubscriptionService.Deps {
|
||||
CommandCodeSubscriptionService.Deps(
|
||||
loadAPIKey: { key },
|
||||
fetch: { request in
|
||||
recorder.record(request)
|
||||
return try respond(request)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Test("maps both windows, the plan and the remaining credits")
|
||||
func decodesLiveShape() async throws {
|
||||
let recorder = RequestRecorder()
|
||||
let summary = try await CommandCodeSubscriptionService.refresh(deps: Self.deps(recorder: recorder) { request in
|
||||
let body = request.url == CommandCodeSubscriptionService.creditsURL ? Self.creditsBody : Self.subscriptionBody
|
||||
return (Data(body.utf8), Self.response(request, 200))
|
||||
})
|
||||
#expect(summary.details.map(\.label) == ["5-hour", "Weekly"])
|
||||
#expect(summary.details.map(\.percent) == [0.25, 1])
|
||||
#expect(summary.details[0].resetsAt == nil)
|
||||
#expect(summary.details[1].resetsAt == Date(timeIntervalSince1970: 1_791_653_049.551))
|
||||
#expect(summary.primary?.label == "Weekly")
|
||||
#expect(summary.planLabel == "Go")
|
||||
#expect(summary.footerLines == ["Credits left: $3.97 monthly"])
|
||||
#expect(recorder.requests.count == 2)
|
||||
for request in recorder.requests {
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-commandcode-key")
|
||||
#expect(request.value(forHTTPHeaderField: "User-Agent") == "CodeBurn")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a failed subscription call only drops the plan label")
|
||||
func subscriptionFailureKeepsWindows() async throws {
|
||||
let summary = try await CommandCodeSubscriptionService.refresh(deps: Self.deps { request in
|
||||
request.url == CommandCodeSubscriptionService.creditsURL
|
||||
? (Data(Self.creditsBody.utf8), Self.response(request, 200))
|
||||
: (Data("{}".utf8), Self.response(request, 500))
|
||||
})
|
||||
#expect(summary.planLabel == nil)
|
||||
#expect(summary.details.count == 2)
|
||||
}
|
||||
|
||||
@Test("missing key, rejected key and bad bodies map to their errors")
|
||||
func errors() async {
|
||||
let noKey = Self.deps(key: nil) { _ in
|
||||
Issue.record("must not fetch without a key")
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
await #expect(throws: CommandCodeSubscriptionService.FetchError.noCredentials) {
|
||||
try await CommandCodeSubscriptionService.refresh(deps: noKey)
|
||||
}
|
||||
let cases: [(Int, String, CommandCodeSubscriptionService.FetchError)] = [
|
||||
(401, "{}", .authenticationRejected),
|
||||
(429, "{}", .rateLimited),
|
||||
(503, "{}", .providerUnavailable),
|
||||
(200, #"{"windowLimits":{}}"#, .parseFailure),
|
||||
]
|
||||
for (status, body, expected) in cases {
|
||||
await #expect(throws: expected) {
|
||||
try await CommandCodeSubscriptionService.refresh(deps: Self.deps { request in
|
||||
(Data(body.utf8), Self.response(request, status))
|
||||
})
|
||||
}
|
||||
}
|
||||
await #expect(throws: CommandCodeSubscriptionService.FetchError.network) {
|
||||
try await CommandCodeSubscriptionService.refresh(deps: Self.deps { _ in throw URLError(.notConnectedToInternet) })
|
||||
}
|
||||
}
|
||||
|
||||
@Test("plan labels and the auth file")
|
||||
func planLabelAndAuthFile() throws {
|
||||
#expect(CommandCodeSubscriptionService.planLabel("individual-pro-v1") == "Pro")
|
||||
#expect(CommandCodeSubscriptionService.planLabel("") == nil)
|
||||
let file = FileManager.default.temporaryDirectory.appendingPathComponent("cc-auth-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: file) }
|
||||
try Data(#"{"apiKey":" synthetic ","userName":"x"}"#.utf8).write(to: file)
|
||||
#expect(CommandCodeSubscriptionService.apiKey(fromAuthFile: file) == "synthetic")
|
||||
#expect(CommandCodeSubscriptionService.apiKey(fromAuthFile: file.appendingPathExtension("missing")) == nil)
|
||||
}
|
||||
}
|
||||
|
|
@ -34,8 +34,8 @@ struct ProviderConnectionCatalogTests {
|
|||
func pinnedSourceModeCoverage() {
|
||||
let providers = ProviderConnectionCatalog.providers
|
||||
#expect(providers.count(with: .automatic) == 71)
|
||||
#expect(providers.count(with: .web) == 29)
|
||||
#expect(providers.count(with: .cli) == 17)
|
||||
#expect(providers.count(with: .web) == 28)
|
||||
#expect(providers.count(with: .cli) == 19)
|
||||
#expect(providers.count(with: .oauth) == 5)
|
||||
#expect(providers.count(with: .api) == 44)
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ struct ProviderConnectionCatalogTests {
|
|||
.filter(\.hasLiveCodeBurnQuotaAdapter)
|
||||
.map(\.id)
|
||||
.sorted()
|
||||
#expect(live == ["antigravity", "claude", "clinepass", "codex", "copilot", "cursor", "devin", "gemini", "grok", "grokbot", "kimi", "zai", "zcode"])
|
||||
#expect(live == ["antigravity", "claude", "clinepass", "codex", "commandcode", "copilot", "cursor", "devin", "gemini", "grok", "grokbot", "kimi", "zai", "zcode"])
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ import { isWslUncPath } from './wsl.js'
|
|||
// v21: service_tier "flex" bills at the published Flex rates (it billed at
|
||||
// standard), and `gpt-reserve` / `gpt-5.3-spark` price as GPT-5.6 Luna /
|
||||
// GPT-5.3 Codex Spark instead of $0. Stored costUSD and speed are the old ones.
|
||||
export const CODEX_CACHE_VERSION = 21
|
||||
// v22: a fork's replay burst drops only records found in the parent rollout
|
||||
// and counts the rest. v21 entries dropped the whole burst.
|
||||
export const CODEX_CACHE_VERSION = 22
|
||||
export const CODEX_LEGACY_CACHE_FILE = 'codex-results.json'
|
||||
export function codexCacheFileName(version = CODEX_CACHE_VERSION): string {
|
||||
return `codex-results.v${version}.json`
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import { stat } from 'fs/promises'
|
||||
import { basename, dirname } from 'path'
|
||||
|
||||
import { readSessionLines } from './fs-utils.js'
|
||||
import { walkRolloutFiles } from './launcher-homes.js'
|
||||
|
||||
/** Maximum timestamp gap between copied records in Codex's fork replay burst. */
|
||||
export const CODEX_FORK_REPLAY_MAX_GAP_MS = 1000
|
||||
|
||||
|
|
@ -57,3 +63,92 @@ export function isCodexForkReplayState(value: unknown): value is CodexForkReplay
|
|||
&& Number.isFinite(state['lastReplayAtMs'])
|
||||
&& typeof state['active'] === 'boolean'
|
||||
}
|
||||
|
||||
type CodexUsageCounters = {
|
||||
input_tokens?: number
|
||||
cached_input_tokens?: number
|
||||
cache_write_input_tokens?: number
|
||||
output_tokens?: number
|
||||
reasoning_output_tokens?: number
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
const usageCounters = (u: CodexUsageCounters | undefined) => u
|
||||
? [u.input_tokens ?? 0, u.cached_input_tokens ?? 0, u.cache_write_input_tokens ?? 0, u.output_tokens ?? 0, u.reasoning_output_tokens ?? 0, u.total_tokens ?? 0]
|
||||
: null
|
||||
|
||||
/** Identity of one token_count record, shared by a parent and the fork that copied it. */
|
||||
export function codexReplayUsageIdentity(info: { total_token_usage?: CodexUsageCounters; last_token_usage?: CodexUsageCounters }): string {
|
||||
return JSON.stringify([usageCounters(info.total_token_usage), usageCounters(info.last_token_usage)])
|
||||
}
|
||||
|
||||
export const codexReplayResponseIdentity = (responseId: string): string => `response:${responseId}`
|
||||
|
||||
// Rollout names are `rollout-YYYY-MM-DDTHH-MM-SS-<id>.jsonl`.
|
||||
const ROLLOUT_ID_OFFSET = 'rollout-YYYY-MM-DDTHH-MM-SS-'.length
|
||||
const rolloutIndexes = new Map<string, Map<string, string | null>>()
|
||||
|
||||
function findRollout(home: string, id: string): string | undefined {
|
||||
let index = rolloutIndexes.get(home)
|
||||
if (!index?.has(id)) {
|
||||
index = new Map()
|
||||
walkRolloutFiles(home, path => index!.set(basename(path).slice(ROLLOUT_ID_OFFSET, -'.jsonl'.length), path))
|
||||
if (!index.has(id)) index.set(id, null)
|
||||
rolloutIndexes.set(home, index)
|
||||
}
|
||||
return index.get(id) ?? undefined
|
||||
}
|
||||
|
||||
function codexHomeOf(path: string): string | undefined {
|
||||
for (let dir = dirname(path); dir !== dirname(dir); dir = dirname(dir)) {
|
||||
const name = basename(dir)
|
||||
if (name === 'sessions' || name === 'archived_sessions') return dirname(dir)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type ParentUsage = { sig: string; records: Promise<Array<[number, string]>> }
|
||||
const parentUsage = new Map<string, ParentUsage>()
|
||||
|
||||
async function readParentUsage(path: string): Promise<Array<[number, string]>> {
|
||||
const records: Array<[number, string]> = []
|
||||
const skip = (head: string) => !head.includes('"token_count"') && !head.includes('"token_usage_record"')
|
||||
for await (const line of readSessionLines(path, skip)) {
|
||||
let entry: { type?: string; timestamp?: string; payload?: { type?: string; response_id?: unknown; info?: Parameters<typeof codexReplayUsageIdentity>[0] } }
|
||||
try { entry = JSON.parse(line) } catch { continue }
|
||||
const at = entry.timestamp ? Date.parse(entry.timestamp) : NaN
|
||||
if (entry.type === 'token_usage_record' && typeof entry.payload?.response_id === 'string') {
|
||||
records.push([at, codexReplayResponseIdentity(entry.payload.response_id)])
|
||||
} else if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload.info) {
|
||||
records.push([at, codexReplayUsageIdentity(entry.payload.info)])
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage identities the parent rollout recorded up to the fork, read from the
|
||||
* parent's raw file (its own copied history included, so a chain resolves one
|
||||
* level at a time). Null when the parent is not on disk next to the fork; the
|
||||
* caller then drops the whole replay burst.
|
||||
*/
|
||||
export async function loadCodexParentReplay(forkPath: string, parentId: string, forkedAtMs: number): Promise<Set<string> | null> {
|
||||
const home = codexHomeOf(forkPath)
|
||||
const path = home ? findRollout(home, parentId) : undefined
|
||||
if (!path || path === forkPath) return null
|
||||
let sig: string
|
||||
try {
|
||||
const st = await stat(path)
|
||||
sig = `${st.size}:${st.mtimeMs}`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
let cached = parentUsage.get(path)
|
||||
if (cached?.sig !== sig) {
|
||||
cached = { sig, records: readParentUsage(path) }
|
||||
parentUsage.set(path, cached)
|
||||
}
|
||||
// Records the parent wrote after the fork were never copied into it.
|
||||
const ids = new Set((await cached.records).filter(([at]) => !(at > forkedAtMs)).map(([, id]) => id))
|
||||
return ids.size > 0 ? ids : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { homedir } from 'os'
|
|||
import { randomBytes } from 'crypto'
|
||||
import { PLAN_PROVIDERS } from './plans.js'
|
||||
|
||||
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max' | 'custom' | 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'copilot' | 'all'
|
||||
export type PlanId = 'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max' | 'google-ai-pro' | 'google-ai-ultra-5x' | 'google-ai-ultra-20x' | 'custom' | 'none'
|
||||
export type PlanProvider = 'claude' | 'codex' | 'cursor' | 'grok' | 'copilot' | 'antigravity' | 'all'
|
||||
|
||||
export type Plan = {
|
||||
id: PlanId
|
||||
|
|
|
|||
|
|
@ -301,7 +301,34 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// v61: #1579 Claude Desktop usage-ledger records (Claude-3p Cowork and Code)
|
||||
// are read and de-duplicated against matching transcript calls. Calls only
|
||||
// rise, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 61
|
||||
// v62: Devin's SWE-2 prices at Cognition's list rate and swe-1-7-lightning as
|
||||
// swe-1.7-lightning instead of $0. Only cost rises; call counts are unchanged,
|
||||
// so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
// v63: Cline CLI input tokens include cache reads and writes, which were then
|
||||
// billed again at the cache rates. Only input tokens and estimated cost fall;
|
||||
// call counts are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is
|
||||
// needed.
|
||||
// v64: Kimi Code's `kimi-for-coding` prices by the model it served on the call's
|
||||
// date (K2.5, K2.6, K2.7 Code) instead of retired K2 Thinking, and
|
||||
// `kimi-for-coding-highspeed` prices at K2.7 Code HighSpeed instead of $0.
|
||||
// From 11 Sep 2026 the alias served K2.8 Preview, which has no published rate;
|
||||
// those calls price as K2.7 Code and are marked estimated.
|
||||
// Calls are unchanged, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
// v65: Grok Build reads per-request usage from logs/unified.jsonl and uses it
|
||||
// in place of a session dir's one-call rollup for every session the log holds;
|
||||
// days finalized without it re-derive. A logged session's single rollup call,
|
||||
// dated at its last activity, becomes one call per request dated at that
|
||||
// request, so a session that crossed midnight moves calls to an earlier day and
|
||||
// grok joins PENDING_REDERIVE_PROVIDER_VERSIONS at 65.
|
||||
// v66: Codex fork replay bursts drop only records found in the parent rollout;
|
||||
// burst records the parent kept only inside a running total now count. Calls
|
||||
// only rise, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
// v67: a day's project split is keyed per (label, path) instead of per label,
|
||||
// which kept only the first path a label showed that day and handed the whole
|
||||
// label (e.g. every home-folder Claude session) to whichever project owned it.
|
||||
// Day and provider totals are unchanged, only the split inside them moves, so
|
||||
// no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed.
|
||||
export const DAILY_CACHE_VERSION = 67
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
@ -347,6 +374,9 @@ const PENDING_REDERIVE_PROVIDER_VERSIONS: Readonly<Record<string, number>> = {
|
|||
// 59: standalone rows without created_at moved from the file mtime to the
|
||||
// first step's time.
|
||||
antigravity: 59,
|
||||
// 65: logged sessions moved from one rollup at last activity to per-request
|
||||
// calls at request time.
|
||||
grok: 65,
|
||||
}
|
||||
|
||||
function providersPendingRederiveFrom(fromVersion: number): string[] {
|
||||
|
|
@ -378,6 +408,22 @@ export type CategoryDayStats = { turns: number; cost: number; savingsUSD: number
|
|||
/// mapping are gone.
|
||||
export type ProjectDayStats = { cost: number; calls: number; savingsUSD: number; sessions: number; path?: string }
|
||||
|
||||
/// One project label can span several real paths (every Claude session started
|
||||
/// from the home folder shares `-Users-<name>`), so since v67 a day's project
|
||||
/// split is keyed per (label, path). Days written earlier key by label alone
|
||||
/// and keep one path for the whole label; they stay readable through
|
||||
/// projectDayIdentity.
|
||||
const PROJECT_KEY_SEP = '\u0000'
|
||||
|
||||
export function projectDayKey(project: string, path?: string): string {
|
||||
return path ? `${project}${PROJECT_KEY_SEP}${path}` : project
|
||||
}
|
||||
|
||||
export function projectDayIdentity(key: string, stats: ProjectDayStats): ProjectFilterTarget & { projectPath: string } {
|
||||
const sep = key.indexOf(PROJECT_KEY_SEP)
|
||||
return { project: sep === -1 ? key : key.slice(0, sep), projectPath: stats.path ?? '' }
|
||||
}
|
||||
|
||||
export type ProviderDaySlice = {
|
||||
calls: number
|
||||
cost: number
|
||||
|
|
@ -1754,8 +1800,8 @@ export function cachedProjectIdentities(cache: DailyCache, startStr: string, end
|
|||
const identities: ProjectFilterTarget[] = []
|
||||
for (const day of cache.days) {
|
||||
if (day.date < startStr || day.date > endStr || !day.projects) continue
|
||||
for (const [name, stats] of Object.entries(day.projects)) {
|
||||
identities.push({ project: name, projectPath: stats.path ?? '' })
|
||||
for (const [key, stats] of Object.entries(day.projects)) {
|
||||
identities.push(projectDayIdentity(key, stats))
|
||||
}
|
||||
}
|
||||
return identities
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DailyEntry, ProjectDayStats, ProviderDaySlice } from './daily-cache.js'
|
||||
import { projectDayKey, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import type { PeriodData } from './menubar-json.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
|
||||
|
|
@ -67,14 +67,14 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn:
|
|||
}
|
||||
const ensureProject = (holder: { projects?: Record<string, ProjectDayStats> }, project: string, path?: string): ProjectDayStats => {
|
||||
const projects = (holder.projects ??= {})
|
||||
const key = projectDayKey(project, path)
|
||||
// defineProperty so a project directory named "__proto__" becomes an own
|
||||
// key instead of mutating the prototype link.
|
||||
let p = Object.hasOwn(projects, project) ? projects[project] : undefined
|
||||
let p = Object.hasOwn(projects, key) ? projects[key] : undefined
|
||||
if (!p) {
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0 }
|
||||
Object.defineProperty(projects, project, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
p = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, ...(path ? { path } : {}) }
|
||||
Object.defineProperty(projects, key, { value: p, enumerable: true, writable: true, configurable: true })
|
||||
}
|
||||
if (!p.path && path) p.path = path
|
||||
return p
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function rolloutFileSessionId(filePath: string): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function walkRolloutFiles(codexDir: string, visit: (filePath: string) => void): void {
|
||||
export function walkRolloutFiles(codexDir: string, visit: (filePath: string) => void): void {
|
||||
const take = (dir: string, file: string) => {
|
||||
if (file.startsWith('rollout-') && file.endsWith('.jsonl')) visit(join(dir, file))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,12 +120,20 @@ const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
|
|||
// deepseek-v3.2: DeepSeek's last published price was $0.28 miss / $0.028 hit /
|
||||
// $0.42 output; LiteLLM's deepseek/deepseek-v3.2 row says $0.40 output while its
|
||||
// own deepseek-chat row says $0.42. Drop once upstream corrects it.
|
||||
// swe-2: Cognition's list price at docs.devin.ai/desktop/models, $3 input, $15
|
||||
// output, $0.30 cache read per 1M, no cache-write rate. Plan promotions (free on
|
||||
// self-serve until 15 Oct 2026, 75% off for enterprise until 31 Dec 2026) are
|
||||
// left out, as they depend on the plan.
|
||||
const BUILTIN_PRICE_OVERRIDES: Record<string, SnapshotEntry> = {
|
||||
'deepseek-v3.2': [0.28e-6, 0.42e-6, null, 0.028e-6],
|
||||
'swe-2': [3e-6, 15e-6, null, 0.3e-6],
|
||||
'composer-2.5': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6],
|
||||
'composer-2': [0.5e-6, 2.5e-6, 0.5e-6, 0.2e-6],
|
||||
'composer-1.5': [3.5e-6, 17.5e-6, 3.5e-6, 0.35e-6],
|
||||
'composer-1': [1.25e-6, 10e-6, 1.25e-6, 0.125e-6],
|
||||
// Moonshot's published rate (platform.kimi.ai/docs/pricing/chat): $1.90 miss,
|
||||
// $0.38 hit, $8.00 output. LiteLLM only carries a reseller row for it.
|
||||
'kimi-k2.7-code-highspeed': [1.9e-6, 8e-6, null, 0.38e-6],
|
||||
}
|
||||
|
||||
// Assemble a ModelCosts, applying the cache-cost heuristics (write = 1.25x
|
||||
|
|
@ -671,8 +679,14 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'orcarouter/fusion-flash': 'openai/gpt-oss-120b',
|
||||
'orcarouter/fusion-mini': 'openai/gpt-oss-120b',
|
||||
'kimi-auto': 'kimi-k2-thinking',
|
||||
'kimi-code': 'kimi-k2-thinking',
|
||||
'kimi-for-coding': 'kimi-k2-thinking',
|
||||
// `kimi-for-coding` is Kimi Code's moving alias; `kimi-code` is kimi-cli's
|
||||
// spelling of the same SKU. pricingModelAt prices older calls by the model
|
||||
// the alias served then. K2.8 Preview (11 Sep 2026 on) has no Open Platform
|
||||
// price, so it stays on K2.7 Code's.
|
||||
'kimi-code': 'kimi-k2.7-code',
|
||||
'kimi-for-coding': 'kimi-k2.7-code',
|
||||
// HighSpeed has been K2.7 Code HighSpeed since it launched on 9 Jul 2026.
|
||||
'kimi-for-coding-highspeed': 'kimi-k2.7-code-highspeed',
|
||||
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
|
||||
// alias those calls priced at $0 and the provider looked absent in the UI.
|
||||
'k3': 'kimi-k3',
|
||||
|
|
@ -984,7 +998,6 @@ export function isBuiltInFlatRateModel(model: string): boolean {
|
|||
if (
|
||||
leaf === 'auto'
|
||||
|| leaf === 'auto-genius'
|
||||
|| leaf === 'kimi-for-coding-highspeed'
|
||||
) return true
|
||||
if (leaf.startsWith('grok-composer-')) return true
|
||||
if (leaf.startsWith('warp-auto-')) return true
|
||||
|
|
@ -1239,13 +1252,39 @@ function stripKnownPricingVariantSuffix(model: string): string | null {
|
|||
}
|
||||
|
||||
const AUTO_REVIEW_LUNA_FROM = Date.parse('2026-07-30T00:00:00Z')
|
||||
// kimi-cli labelled the alias "powered by kimi-k2.5" from 27 Jan 2026 (1.2) and
|
||||
// dropped that on 13 Apr 2026 (#1860) as K2.6 rolled out; Kimi Code's What's
|
||||
// New dates K2.7 Code to 12 Jun 2026. What it served before K2.5 is unsourced,
|
||||
// so those calls keep the K2 Thinking rate they always had.
|
||||
const KIMI_CODING_K2_5_FROM = Date.parse('2026-01-27T00:00:00Z')
|
||||
const KIMI_CODING_K2_6_FROM = Date.parse('2026-04-13T00:00:00Z')
|
||||
const KIMI_CODING_K2_7_FROM = Date.parse('2026-06-12T00:00:00Z')
|
||||
const KIMI_CODING_K2_8_FROM = Date.parse('2026-09-11T00:00:00Z')
|
||||
|
||||
/// The model a call is priced by. Only `codex-auto-review` depends on the
|
||||
/// call's date (see BUILTIN_ALIASES); a user alias for it still wins, and a
|
||||
/// missing or unparseable timestamp keeps the forward default.
|
||||
/// The model a call is priced by. Only `codex-auto-review` and the Kimi Code
|
||||
/// alias depend on the call's date (see BUILTIN_ALIASES); a user alias for
|
||||
/// them still wins, and a missing or unparseable timestamp keeps the forward
|
||||
/// default.
|
||||
export function pricingModelAt(model: string, timestamp: string | undefined): string {
|
||||
if (model.toLowerCase() !== 'codex-auto-review' || Object.hasOwn(userAliases, model) || userPriceOverrides.has(model)) return model
|
||||
return Date.parse(timestamp ?? '') < AUTO_REVIEW_LUNA_FROM ? 'gpt-5.4' : model
|
||||
const id = model.toLowerCase()
|
||||
if (id !== 'codex-auto-review' && id !== 'kimi-for-coding' && id !== 'kimi-code') return model
|
||||
if (Object.hasOwn(userAliases, model) || userPriceOverrides.has(model)) return model
|
||||
const at = Date.parse(timestamp ?? '')
|
||||
if (id === 'codex-auto-review') return at < AUTO_REVIEW_LUNA_FROM ? 'gpt-5.4' : model
|
||||
if (at < KIMI_CODING_K2_5_FROM) return 'kimi-k2-thinking'
|
||||
if (at < KIMI_CODING_K2_6_FROM) return 'kimi-k2.5'
|
||||
return at < KIMI_CODING_K2_7_FROM ? 'kimi-k2.6' : model
|
||||
}
|
||||
|
||||
/// True when pricingModelAt stands in for a model with no published rate:
|
||||
/// Kimi Code's alias served K2.8 Preview from 11 Sep 2026, priced as K2.7 Code.
|
||||
/// A missing or unparseable timestamp gets the forward default, so it counts.
|
||||
export function isStandInPricedAt(model: string, timestamp: string | undefined): boolean {
|
||||
const id = model.toLowerCase()
|
||||
if (id !== 'kimi-for-coding' && id !== 'kimi-code') return false
|
||||
if (Object.hasOwn(userAliases, model) || userPriceOverrides.has(model)) return false
|
||||
const at = Date.parse(timestamp ?? '')
|
||||
return !(at < KIMI_CODING_K2_8_FROM)
|
||||
}
|
||||
|
||||
export function getModelCosts(model: string): ModelCosts | null {
|
||||
|
|
@ -1543,6 +1582,8 @@ const autoModelNames: Record<string, string> = {
|
|||
'openclaw-auto': 'OpenClaw (auto)',
|
||||
'qwen-auto': 'Qwen (auto)',
|
||||
'kimi-auto': 'Kimi (auto)',
|
||||
'kimi-for-coding': 'Kimi for Coding',
|
||||
'kimi-for-coding-highspeed': 'Kimi for Coding HighSpeed',
|
||||
'codex-auto-review': 'Codex Auto Review',
|
||||
'gpt-reserve': 'Luna Reserve',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { createHash } from 'crypto'
|
|||
import { performance } from 'node:perf_hooks'
|
||||
import { basename, dirname, join, resolve, sep } from 'path'
|
||||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from './fs-utils.js'
|
||||
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, pricingModelAt, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback, getModelRoute } from './models.js'
|
||||
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, pricingModelAt, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback, getModelRoute, isStandInPricedAt } from './models.js'
|
||||
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
|
||||
import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js'
|
||||
import { discoverAllSessions, discoverAllSessionsWithFailures, getProvider } from './providers/index.js'
|
||||
|
|
@ -2869,7 +2869,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
|
|||
webSearchRequests: u.webSearchRequests,
|
||||
},
|
||||
costUSD: call.costUSD ?? recordedCostFallback(call.model, costUSD, call.fallbackCostUSD) ?? costUSD,
|
||||
isEstimated: call.isEstimated,
|
||||
isEstimated: call.isEstimated || (call.costUSD === undefined && isStandInPricedAt(call.model, call.timestamp)) || undefined,
|
||||
tools: call.tools,
|
||||
mcpTools: extractMcpTools(call.tools),
|
||||
skills: call.skills,
|
||||
|
|
@ -3221,6 +3221,11 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string
|
|||
// reparses. A sessions.db source's fingerprint folds in the WAL already.
|
||||
if (providerName === 'devin') return sourcePath.endsWith('.json')
|
||||
|
||||
// A Grok session dir parses to nothing while logs/unified.jsonl holds its
|
||||
// session, which its fingerprint does not track. Once the log rotates or is
|
||||
// truncated past it, the dir has to count again.
|
||||
if (providerName === 'grok') return cached.turns.length === 0
|
||||
|
||||
if (providerName !== 'gemini') return false
|
||||
|
||||
return cached.turns.some(turn =>
|
||||
|
|
|
|||
33
src/plans.ts
33
src/plans.ts
|
|
@ -1,8 +1,8 @@
|
|||
import type { Plan, PlanId, PlanProvider } from './config.js'
|
||||
import { AI_CREDIT_USD } from './copilot-aiu.js'
|
||||
|
||||
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok', 'copilot']
|
||||
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'copilot-pro', 'copilot-pro-plus', 'copilot-max', 'custom', 'none']
|
||||
export const PLAN_PROVIDERS: PlanProvider[] = ['all', 'claude', 'codex', 'cursor', 'grok', 'copilot', 'antigravity']
|
||||
export const PLAN_IDS: PlanId[] = ['claude-pro', 'claude-max', 'claude-max-5x', 'cursor-pro', 'supergrok', 'supergrok-heavy', 'copilot-pro', 'copilot-pro-plus', 'copilot-max', 'google-ai-pro', 'google-ai-ultra-5x', 'google-ai-ultra-20x', 'custom', 'none']
|
||||
|
||||
// Official Copilot individual allotments from GitHub Docs, *Usage-based
|
||||
// billing for individuals*, fetched 2026-08-23. Flex is documented as
|
||||
|
|
@ -13,7 +13,7 @@ const COPILOT_PRO_CREDITS = 1500
|
|||
const COPILOT_PRO_PLUS_CREDITS = 7000
|
||||
const COPILOT_MAX_CREDITS = 20000
|
||||
|
||||
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max', Omit<Plan, 'setAt'>> = {
|
||||
export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x' | 'cursor-pro' | 'supergrok' | 'supergrok-heavy' | 'copilot-pro' | 'copilot-pro-plus' | 'copilot-max' | 'google-ai-pro' | 'google-ai-ultra-5x' | 'google-ai-ultra-20x', Omit<Plan, 'setAt'>> = {
|
||||
'claude-pro': {
|
||||
id: 'claude-pro',
|
||||
monthlyUsd: 20,
|
||||
|
|
@ -71,6 +71,27 @@ export const PRESET_PLANS: Record<'claude-pro' | 'claude-max' | 'claude-max-5x'
|
|||
provider: 'copilot',
|
||||
resetDay: 1,
|
||||
},
|
||||
// US monthly prices from https://gemini.google/us/subscriptions/, fetched
|
||||
// 2026-10-07. Antigravity is the only local tool these plans cover since
|
||||
// Google retired Gemini CLI OAuth for individual tiers.
|
||||
'google-ai-pro': {
|
||||
id: 'google-ai-pro',
|
||||
monthlyUsd: 19.99,
|
||||
provider: 'antigravity',
|
||||
resetDay: 1,
|
||||
},
|
||||
'google-ai-ultra-5x': {
|
||||
id: 'google-ai-ultra-5x',
|
||||
monthlyUsd: 99.99,
|
||||
provider: 'antigravity',
|
||||
resetDay: 1,
|
||||
},
|
||||
'google-ai-ultra-20x': {
|
||||
id: 'google-ai-ultra-20x',
|
||||
monthlyUsd: 199.99,
|
||||
provider: 'antigravity',
|
||||
resetDay: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export function isPlanProvider(value: string): value is PlanProvider {
|
||||
|
|
@ -108,6 +129,12 @@ export function planDisplayName(id: PlanId): string {
|
|||
return 'Copilot Pro+'
|
||||
case 'copilot-max':
|
||||
return 'Copilot Max'
|
||||
case 'google-ai-pro':
|
||||
return 'Google AI Pro'
|
||||
case 'google-ai-ultra-5x':
|
||||
return 'Google AI Ultra 5x'
|
||||
case 'google-ai-ultra-20x':
|
||||
return 'Google AI Ultra 20x'
|
||||
case 'custom':
|
||||
return 'Custom'
|
||||
case 'none':
|
||||
|
|
|
|||
|
|
@ -89,11 +89,15 @@ type ClineCliMetrics = {
|
|||
|
||||
function parseMetrics(value: unknown): ClineCliMetrics | null {
|
||||
if (!isRecord(value)) return null
|
||||
const cacheReadTokens = safeTokenCount(value['cacheReadTokens'])
|
||||
const cacheWriteTokens = safeTokenCount(value['cacheWriteTokens'])
|
||||
const metrics: ClineCliMetrics = {
|
||||
inputTokens: safeTokenCount(value['inputTokens']),
|
||||
// Cline's inputTokens is the full prompt, cache read and write included
|
||||
// (its own pricing bills max(0, input - cacheRead - cacheWrite) at the input rate).
|
||||
inputTokens: Math.max(0, safeTokenCount(value['inputTokens']) - cacheReadTokens - cacheWriteTokens),
|
||||
outputTokens: safeTokenCount(value['outputTokens']),
|
||||
cacheReadTokens: safeTokenCount(value['cacheReadTokens']),
|
||||
cacheWriteTokens: safeTokenCount(value['cacheWriteTokens']),
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
cost: safeNonNegativeNumber(value['cost']),
|
||||
// A negative cost is not a credit we can represent — treat it as absent and
|
||||
// fall back to token pricing, rather than reporting a clamped $0 as metered.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from '../fs
|
|||
import { billableOutputTokens, calculateCost, getModelCosts, pricingModelAt } from '../models.js'
|
||||
import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile, type CodexFileFingerprint } from '../codex-cache.js'
|
||||
import { mergeToolIntervals } from '../codex-throughput.js'
|
||||
import { isCodexForkReplay, isCodexForkReplayState, startCodexForkReplay, type CodexForkReplayState } from '../codex-fork-replay.js'
|
||||
import { codexReplayResponseIdentity, codexReplayUsageIdentity, isCodexForkReplay, isCodexForkReplayState, loadCodexParentReplay, startCodexForkReplay, type CodexForkReplayState } from '../codex-fork-replay.js'
|
||||
import { normalizeContentBlocks } from '../content-utils.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import { wslHomes } from '../wsl.js'
|
||||
|
|
@ -801,6 +801,23 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
let sessionCwd: string | undefined = resume?.state.sessionCwd
|
||||
let forkedFromId = resume?.state.forkedFromId ?? ''
|
||||
let forkReplayState = resume?.state.forkReplayState ? { ...resume.state.forkReplayState } : undefined
|
||||
// What the parent rollout recorded before the fork: a burst record found
|
||||
// there is a copy and is dropped. The rest is the parent's work that its
|
||||
// rollout kept only inside a running total, so it counts here. Loaded on
|
||||
// the first burst usage record; null drops the whole burst.
|
||||
let parentReplay: Set<string> | null | undefined
|
||||
let parentReplayAnchored = false
|
||||
const isParentReplay = async (identity: string | undefined): Promise<boolean> => {
|
||||
if (parentReplay === undefined) {
|
||||
parentReplay = forkedFromId && forkReplayState ? await loadCodexParentReplay(source.path, forkedFromId, forkReplayState.startedAtMs) : null
|
||||
}
|
||||
if (!parentReplay || identity === undefined) return true
|
||||
if (parentReplay.has(identity)) { parentReplayAnchored = true; return true }
|
||||
// A burst that opens with a record the parent lacks was not copied from
|
||||
// this parent file; fall back to dropping the burst.
|
||||
if (!parentReplayAnchored) { parentReplay = null; return true }
|
||||
return false
|
||||
}
|
||||
// Null sentinel rather than `0` so the FIRST event is never confused
|
||||
// with a duplicate. A session that only emits last_token_usage (no
|
||||
// total_token_usage) reports cumulativeTotal=0 on every event; with a
|
||||
|
|
@ -1018,6 +1035,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
|| entry.payload?.parent_thread_id
|
||||
|| entry.payload?.source?.subagent?.thread_spawn?.parent_thread_id
|
||||
|| ''
|
||||
parentReplay = undefined
|
||||
parentReplayAnchored = false
|
||||
if (forkedFromId) {
|
||||
forkReplayState = startCodexForkReplay(entry.timestamp)
|
||||
// Byte identity only collapses consecutive records inside one
|
||||
|
|
@ -1079,7 +1098,8 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
)) continue
|
||||
|
||||
if (isForkReplay && entry.type === 'response_item' && entry.payload?.type === 'message') continue
|
||||
if (isForkReplay && entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
|
||||
if (isForkReplay && entry.type === 'event_msg' && entry.payload?.type === 'token_count'
|
||||
&& await isParentReplay(entry.payload.info ? codexReplayUsageIdentity(entry.payload.info) : undefined)) {
|
||||
const info = entry.payload.info
|
||||
if (info) {
|
||||
const total = info.total_token_usage
|
||||
|
|
@ -1320,10 +1340,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>, capture?: {
|
|||
// turns, so it becomes the source for this rollout once seen.
|
||||
const usage: unknown = entry.payload?.usage
|
||||
if (!isValidCodexTokenUsage(usage)) continue
|
||||
if (isForkReplay) continue
|
||||
const responseId = entry.payload?.response_id
|
||||
if (isForkReplay && await isParentReplay(typeof responseId === 'string' && responseId ? codexReplayResponseIdentity(responseId) : undefined)) continue
|
||||
hasTokenUsageRecord = true
|
||||
|
||||
const responseId = entry.payload?.response_id
|
||||
const dedupKey = typeof responseId === 'string' && responseId
|
||||
? `codex:${forkedFromId || sessionId}:response:${responseId}`
|
||||
: `codex:usage-record:${JSON.stringify([source.path, tracker.lastCompleteLineOffset])}`
|
||||
|
|
|
|||
|
|
@ -330,11 +330,12 @@ function getFriendlyGptName(model: string): string {
|
|||
return reconstructed;
|
||||
}
|
||||
|
||||
// Devin minor versions are always a single digit (gpt-5-3-codex). Restrict the
|
||||
// dash-to-dot rewrite to a single-digit minor at a token boundary so a dated
|
||||
// snapshot like gpt-4-1106-preview is not misread as version 4.1106.
|
||||
// Devin minor versions are always a single digit (gpt-5-3-codex,
|
||||
// swe-1-7-lightning). Restrict the dash-to-dot rewrite to a single-digit minor
|
||||
// at a token boundary so a dated snapshot like gpt-4-1106-preview is not
|
||||
// misread as version 4.1106.
|
||||
function normalizeDevinGptId(model: string): string {
|
||||
return model.replace(/^gpt-(\d+)-(\d)(?=-|$)/, "gpt-$1.$2");
|
||||
return model.replace(/^(gpt|swe)-(\d+)-(\d)(?=-|$)/, "$1-$2.$3");
|
||||
}
|
||||
|
||||
function getDevinDisplayModelName(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises'
|
|||
import { basename, dirname, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionFile } from '../fs-utils.js'
|
||||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionFile, readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getModelCosts, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
|
@ -21,6 +21,13 @@ import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderC
|
|||
// `signals.json.contextTokensUsed` and the running `_meta.totalTokens` curve; for
|
||||
// those we retain the old compaction-aware estimate and mark its cost estimated.
|
||||
// `costUsdTicks` is deliberately ignored because its scale is not documented.
|
||||
//
|
||||
// <grok-home>/logs/unified.jsonl outlives the session dirs and logs every
|
||||
// inference as `shell.turn.inference_done` (prompt_tokens includes
|
||||
// cached_prompt_tokens, completion_tokens includes reasoning_tokens). A session
|
||||
// with records there is read from the log, one call per request so the
|
||||
// long-context tier applies per request; its session dir is then skipped. Session
|
||||
// dirs only count for sessions the log no longer holds (rotated or truncated).
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
|
|
@ -370,13 +377,16 @@ function hasPositiveTotals(totals: GrokTokenTotals): boolean {
|
|||
|| totals.reasoning > 0
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>, unifiedLogPath: string): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const dir = dirname(source.path)
|
||||
const summary = await readJson<GrokSummary>(join(dir, 'summary.json'))
|
||||
if (!summary) return
|
||||
const logged = await loggedSessionIds(unifiedLogPath)
|
||||
if (logged.has(summary.info?.id ?? basename(dir)) || logged.has(basename(dir))) return
|
||||
const updates = await readSessionFile(source.path)
|
||||
if (!summary || updates === null) return
|
||||
if (updates === null) return
|
||||
|
||||
const signals = await readJson<GrokSignals>(join(dir, 'signals.json'))
|
||||
const existingModel =
|
||||
|
|
@ -448,6 +458,151 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
}
|
||||
}
|
||||
|
||||
type UnifiedLogLine = {
|
||||
msg?: unknown
|
||||
sid?: unknown
|
||||
pid?: unknown
|
||||
ts?: unknown
|
||||
ctx?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function inferenceUsage(record: UnifiedLogLine): { sid: string; ts: string; prompt: number; cached: number; completion: number; reasoning: number } | null {
|
||||
const { sid, ts, ctx } = record
|
||||
if (record.msg !== 'shell.turn.inference_done' || typeof sid !== 'string' || !sid || typeof ts !== 'string' || !isRecord(ctx)) return null
|
||||
const prompt = finiteNonNegative(ctx['prompt_tokens'])
|
||||
const completion = finiteNonNegative(ctx['completion_tokens'])
|
||||
if (prompt === undefined || completion === undefined || (prompt === 0 && completion === 0)) return null
|
||||
return {
|
||||
sid,
|
||||
ts,
|
||||
prompt,
|
||||
cached: Math.min(finiteNonNegative(ctx['cached_prompt_tokens']) ?? 0, prompt),
|
||||
completion,
|
||||
reasoning: Math.min(finiteNonNegative(ctx['reasoning_tokens']) ?? 0, completion),
|
||||
}
|
||||
}
|
||||
|
||||
let loggedSessions: { key: string; sids: Promise<Set<string>> } | undefined
|
||||
|
||||
async function readLoggedSessionIds(path: string): Promise<Set<string>> {
|
||||
const sids = new Set<string>()
|
||||
for await (const line of readSessionLines(path)) {
|
||||
if (!line.includes('shell.turn.inference_done')) continue
|
||||
try {
|
||||
const usage = inferenceUsage(JSON.parse(line) as UnifiedLogLine)
|
||||
if (usage) sids.add(usage.sid)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return sids
|
||||
}
|
||||
|
||||
// Every session dir asks; read the log once per version of it.
|
||||
async function loggedSessionIds(path: string): Promise<Set<string>> {
|
||||
const st = await stat(path).catch(() => null)
|
||||
if (!st) return new Set()
|
||||
const key = `${path}:${st.size}:${st.mtimeMs}`
|
||||
if (loggedSessions?.key !== key) loggedSessions = { key, sids: readLoggedSessionIds(path) }
|
||||
return loggedSessions.sids
|
||||
}
|
||||
|
||||
// createParser skips a session dir the log holds; its summary, tools and
|
||||
// commands ride on that session's first logged call instead.
|
||||
async function readSessionDirInfo(dir: string | undefined) {
|
||||
const summary = dir ? await readJson<GrokSummary>(join(dir, 'summary.json')) : null
|
||||
const updates = dir ? await readSessionFile(join(dir, 'updates.jsonl')) : null
|
||||
const parsed = updates === null ? null : parseUpdates(updates)
|
||||
return {
|
||||
model: summary?.current_model_id,
|
||||
cwd: summary?.info?.cwd,
|
||||
userMessage: summary?.session_summary ?? summary?.generated_title ?? '',
|
||||
tools: parsed?.tools ?? [],
|
||||
bashCommands: parsed?.bashCommands ?? [],
|
||||
subagentTypes: parsed?.subagentTypes ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function createUnifiedLogParser(source: SessionSource, seenKeys: Set<string>, sessionsDir: string): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const models = new Map<string, string>()
|
||||
const pidModels = new Map<unknown, string>()
|
||||
const cwds = new Map<string, string>()
|
||||
const sessionDirs = new Map((await discoverSessions(sessionsDir)).map(s => [basename(dirname(s.path)), dirname(s.path)]))
|
||||
const dirInfo = new Map<string, Awaited<ReturnType<typeof readSessionDirInfo>>>()
|
||||
|
||||
for await (const line of readSessionLines(source.path)) {
|
||||
let record: UnifiedLogLine
|
||||
try {
|
||||
record = JSON.parse(line) as UnifiedLogLine
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const sid = record.sid
|
||||
const ctx = record.ctx
|
||||
if (!isRecord(ctx)) continue
|
||||
// A process announces its model before a session that predates the log
|
||||
// file's first line can name one.
|
||||
if (record.msg === 'model catalog: notifying clients' && typeof ctx['current_model_id'] === 'string') {
|
||||
pidModels.set(record.pid, ctx['current_model_id'])
|
||||
continue
|
||||
}
|
||||
if (typeof sid !== 'string' || !sid) continue
|
||||
|
||||
if (record.msg === 'model changed' && typeof ctx['model'] === 'string') {
|
||||
models.set(sid, ctx['model'])
|
||||
continue
|
||||
}
|
||||
if (record.msg === 'session created' && typeof ctx['cwd'] === 'string') {
|
||||
cwds.set(sid, ctx['cwd'])
|
||||
continue
|
||||
}
|
||||
const usage = inferenceUsage(record)
|
||||
if (!usage) continue
|
||||
const { prompt, cached, completion, reasoning } = usage
|
||||
|
||||
const deduplicationKey = `grok:unified:${sid}:${usage.ts}:${String(ctx['loop_index'] ?? 1)}`
|
||||
if (seenKeys.has(deduplicationKey)) continue
|
||||
seenKeys.add(deduplicationKey)
|
||||
|
||||
let info = dirInfo.get(sid)
|
||||
const first = !info
|
||||
if (!info) {
|
||||
info = await readSessionDirInfo(sessionDirs.get(sid))
|
||||
dirInfo.set(sid, info)
|
||||
}
|
||||
const namedModel = models.get(sid) ?? pidModels.get(record.pid) ?? info.model
|
||||
const model = namedModel ?? 'grok-build'
|
||||
const cwd = cwds.get(sid) ?? info.cwd
|
||||
yield {
|
||||
provider: source.provider,
|
||||
model,
|
||||
inputTokens: prompt - cached,
|
||||
outputTokens: completion - reasoning,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: cached,
|
||||
cachedInputTokens: cached,
|
||||
reasoningTokens: reasoning,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(model, prompt - cached, completion, 0, cached, 0),
|
||||
costIsEstimated: namedModel === undefined,
|
||||
tools: first ? info.tools : [],
|
||||
bashCommands: first ? info.bashCommands : [],
|
||||
subagentTypes: first ? info.subagentTypes : [],
|
||||
timestamp: usage.ts,
|
||||
speed: 'standard',
|
||||
deduplicationKey,
|
||||
userMessage: first ? info.userMessage : '',
|
||||
sessionId: sid,
|
||||
project: cwd ? basename(cwd) : source.project,
|
||||
projectPath: cwd,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessions(sessionsDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
|
|
@ -486,13 +641,14 @@ async function discoverSessions(sessionsDir: string): Promise<SessionSource[]> {
|
|||
|
||||
export function createGrokProvider(sessionsDir?: string): Provider {
|
||||
const dir = sessionsDir ?? defaultSessionsDir()
|
||||
const unifiedLogPath = join(dirname(dir), 'logs', 'unified.jsonl')
|
||||
|
||||
return {
|
||||
name: 'grok',
|
||||
displayName: 'Grok Build',
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dir, label: 'sessions' }]
|
||||
return [{ path: dir, label: 'sessions' }, { path: unifiedLogPath, label: 'unified log' }]
|
||||
},
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
|
|
@ -505,11 +661,16 @@ export function createGrokProvider(sessionsDir?: string): Provider {
|
|||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessions(dir)
|
||||
const sources = await discoverSessions(dir)
|
||||
if (await stat(unifiedLogPath).catch(() => null)) {
|
||||
sources.push({ path: unifiedLogPath, project: 'grok', provider: 'grok' })
|
||||
}
|
||||
return sources
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
if (source.path === unifiedLogPath) return createUnifiedLogParser(source, seenKeys, dir)
|
||||
return createParser(source, seenKeys, unifiedLogPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { homedir } from 'os'
|
|||
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { calculateCost, getShortModelName, pricingModelAt } from '../models.js'
|
||||
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
|
@ -298,7 +298,7 @@ function createParser(source: SessionSource, shareDir: string, seenKeys: Set<str
|
|||
|
||||
const model = stringField(envelope.payload, 'model') ?? stringField(envelope.payload, 'model_name') ?? configuredModel
|
||||
const costUSD = calculateCost(
|
||||
model,
|
||||
pricingModelAt(model, envelope.timestamp),
|
||||
usage.inputTokens,
|
||||
usage.outputTokens,
|
||||
usage.cacheCreationInputTokens,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from 'node:os'
|
|||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { calculateCost } from '../models.js'
|
||||
import { calculateCost, pricingModelAt } from '../models.js'
|
||||
import { FS_SCAN_CONCURRENCY, mapWithConcurrency } from '../fs-utils.js'
|
||||
import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: calculateCost(
|
||||
realModel,
|
||||
pricingModelAt(realModel, timestamp),
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile, readSessionLines } from '../fs-utils.js'
|
||||
|
|
@ -7,6 +7,7 @@ import { calculateCost } from '../models.js'
|
|||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import { safeNumber } from '../parser.js'
|
||||
import { wslHomes } from '../wsl.js'
|
||||
import { readUnifiedVibeCalls } from './mistral-vibe-unified.js'
|
||||
|
||||
const METADATA_FILENAME = 'meta.json'
|
||||
|
|
@ -404,6 +405,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
|
|||
|
||||
export function createMistralVibeProvider(sessionsDir?: string): Provider {
|
||||
const dir = getMistralVibeSessionsDir(sessionsDir)
|
||||
// Extra WSL homes' ~/.vibe/logs/session; empty off-win32 and when a
|
||||
// caller passes an explicit sessionsDir. Resolved lazily: probing spawns wsl.exe.
|
||||
const wslDirs = (): string[] =>
|
||||
sessionsDir ? [] : wslHomes().map(home => join(home, '.vibe', 'logs', 'session'))
|
||||
|
||||
return {
|
||||
name: 'mistral-vibe',
|
||||
|
|
@ -418,41 +423,19 @@ export function createMistralVibeProvider(sessionsDir?: string): Provider {
|
|||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: dir, label: 'sessions' }]
|
||||
return [dir, ...wslDirs()].map(path => ({ path, label: 'sessions' }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
const dirs = await discoverSessionDirs(dir)
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
for (const sessionDir of dirs) {
|
||||
const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME))
|
||||
if (!metadata) continue
|
||||
const cwd = metadata.environment?.working_directory
|
||||
sources.push({
|
||||
path: sessionDir,
|
||||
project: cwd ? basename(cwd) : basename(sessionDir),
|
||||
provider: 'mistral-vibe',
|
||||
})
|
||||
}
|
||||
|
||||
const unifiedDir = join(dir, 'unified')
|
||||
for (const entry of (await readdir(unifiedDir).catch(() => [])).sort()) {
|
||||
const sessionDir = join(unifiedDir, entry)
|
||||
const currentPath = join(sessionDir, 'CURRENT')
|
||||
if (!await isFile(currentPath)) continue
|
||||
const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME))
|
||||
const cwd = metadata?.environment?.working_directory
|
||||
sources.push({ path: currentPath, project: cwd ? basename(cwd) : entry, provider: 'mistral-vibe' })
|
||||
}
|
||||
|
||||
for (const root of [dir, ...wslDirs()]) sources.push(...await discoverRoot(root))
|
||||
return sources
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
if (basename(source.path) === 'CURRENT') {
|
||||
return { async *parse() {
|
||||
const defaultModel = await configuredModel(dir)
|
||||
const defaultModel = await configuredModel(dirname(dirname(dirname(source.path))))
|
||||
for (const call of await readUnifiedVibeCalls(source.path, name => toolNameMap[name] ?? name, defaultModel)) {
|
||||
if (seenKeys.has(call.deduplicationKey)) continue
|
||||
seenKeys.add(call.deduplicationKey)
|
||||
|
|
@ -465,4 +448,32 @@ export function createMistralVibeProvider(sessionsDir?: string): Provider {
|
|||
}
|
||||
}
|
||||
|
||||
async function discoverRoot(dir: string): Promise<SessionSource[]> {
|
||||
const dirs = await discoverSessionDirs(dir)
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
for (const sessionDir of dirs) {
|
||||
const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME))
|
||||
if (!metadata) continue
|
||||
const cwd = metadata.environment?.working_directory
|
||||
sources.push({
|
||||
path: sessionDir,
|
||||
project: cwd ? basename(cwd) : basename(sessionDir),
|
||||
provider: 'mistral-vibe',
|
||||
})
|
||||
}
|
||||
|
||||
const unifiedDir = join(dir, 'unified')
|
||||
for (const entry of (await readdir(unifiedDir).catch(() => [])).sort()) {
|
||||
const sessionDir = join(unifiedDir, entry)
|
||||
const currentPath = join(sessionDir, 'CURRENT')
|
||||
if (!await isFile(currentPath)) continue
|
||||
const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME))
|
||||
const cwd = metadata?.environment?.working_directory
|
||||
sources.push({ path: currentPath, project: cwd ? basename(cwd) : entry, provider: 'mistral-vibe' })
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export const mistralVibe = createMistralVibeProvider()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
//
|
||||
// Endpoints (localhost only, Connect-RPC JSON):
|
||||
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary
|
||||
// (preferred; falls back to)
|
||||
// (preferred windows; the tier and fallback windows come from)
|
||||
// - POST https://127.0.0.1:<port>/exa.language_server_pb.LanguageServerService/GetUserStatus
|
||||
//
|
||||
// Discovery lists processes to find candidates (app language
|
||||
|
|
@ -246,24 +246,22 @@ export function decodeAntigravityStatus(body: unknown): QuotaWindow[] {
|
|||
|
||||
function planFromStatus(body: unknown): string | null {
|
||||
const data = body && typeof body === 'object' ? body as Record<string, any> : {}
|
||||
const plan = data.planName ?? data.userStatus?.planName ?? data.account_plan
|
||||
return typeof plan === 'string' && plan.trim() ? plan.trim() : null
|
||||
// userTier is the Google subscription; planStatus.planInfo.planName says "Pro" even on the free tier.
|
||||
const plan = [data.userStatus?.userTier?.name, data.planName, data.userStatus?.planName, data.account_plan]
|
||||
.find(value => typeof value === 'string' && value.trim())
|
||||
return plan ? plan.trim() : null
|
||||
}
|
||||
|
||||
async function probePort(deps: AntigravityDeps, port: number, csrf?: string): Promise<{ windows: QuotaWindow[]; planLabel: string | null } | null> {
|
||||
const body = JSON.stringify({})
|
||||
for (const tls of [true, false]) {
|
||||
const summary = await deps.request(port, tls, SUMMARY_PATH, body, csrf)
|
||||
if (summary?.status === 200) {
|
||||
const windows = decodeAntigravitySummary(parseJson(summary.text))
|
||||
if (windows.length > 0) return { windows, planLabel: null }
|
||||
}
|
||||
const summaryWindows = summary?.status === 200 ? decodeAntigravitySummary(parseJson(summary.text)) : []
|
||||
// The summary payload carries no tier, so GetUserStatus is asked either way.
|
||||
const status = await deps.request(port, tls, STATUS_PATH, body, csrf)
|
||||
if (status?.status === 200) {
|
||||
const parsed = parseJson(status.text)
|
||||
const windows = decodeAntigravityStatus(parsed)
|
||||
if (windows.length > 0) return { windows, planLabel: planFromStatus(parsed) }
|
||||
}
|
||||
const parsed = status?.status === 200 ? parseJson(status.text) : null
|
||||
const windows = summaryWindows.length > 0 ? summaryWindows : decodeAntigravityStatus(parsed)
|
||||
if (windows.length > 0) return { windows, planLabel: planFromStatus(parsed) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,20 @@
|
|||
// - GET https://api.cline.bot/api/v1/users/me/plan/usage-limits
|
||||
// Bearer API key, one 5-hour / weekly / monthly window each.
|
||||
//
|
||||
// Credential: ClinePass has no local login file to read; the key is one the
|
||||
// user supplies. On the Mac that is a CodeBurn Keychain entry, and the CLI's
|
||||
// equivalent is the environment: CLINEPASS_API_KEY, then CLINE_API_KEY. The
|
||||
// Credential: CLINEPASS_API_KEY, then CLINE_API_KEY, then the session the
|
||||
// Cline CLI keeps in ~/.cline/data/settings/providers.json (read-only, never
|
||||
// refreshed or written; an expired session asks the user to run cline). The
|
||||
// key is used for one request and never persisted or logged.
|
||||
import { quotaRequestSignal, sanitizeError } from './security.js'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
|
||||
import type { QuotaProvider, QuotaWindow } from './types.js'
|
||||
|
||||
const USAGE_ENDPOINT = 'https://api.cline.bot/api/v1/users/me/plan/usage-limits'
|
||||
const API_KEY_VARS = ['CLINEPASS_API_KEY', 'CLINE_API_KEY'] as const
|
||||
const REJECTED_FOOTER = ['ClinePass rejected this API key.']
|
||||
const EXPIRED_FOOTER = ['Cline sign-in expired. Run cline to refresh it.']
|
||||
const RATE_LIMITED_FOOTER = ['ClinePass rate-limited the quota request.']
|
||||
const UNAVAILABLE_FOOTER = ['ClinePass is temporarily unavailable.']
|
||||
const PARSE_FOOTER = ['ClinePass quota response was malformed.']
|
||||
|
|
@ -23,10 +27,13 @@ const WINDOW_LABELS: Record<string, string> = { five_hour: '5-hour', weekly: 'We
|
|||
export type ClinePassDeps = {
|
||||
fetch: typeof fetch
|
||||
env: NodeJS.ProcessEnv
|
||||
readFile: typeof readSecureFile
|
||||
homeDir: string
|
||||
now: () => number
|
||||
}
|
||||
|
||||
function defaultDeps(): ClinePassDeps {
|
||||
return { fetch: globalThis.fetch, env: process.env }
|
||||
return { fetch: globalThis.fetch, env: process.env, readFile: readSecureFile, homeDir: os.homedir(), now: Date.now }
|
||||
}
|
||||
|
||||
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
|
||||
|
|
@ -41,6 +48,41 @@ export function clinePassApiKey(env: NodeJS.ProcessEnv): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
/** Cline's own lookup order: a settings file override, then a data dir, then a Cline dir. */
|
||||
export function clineProvidersPath(env: NodeJS.ProcessEnv, homeDir: string): string {
|
||||
const resolve = (name: string): string | null => {
|
||||
const value = env[name]?.trim()
|
||||
if (!value) return null
|
||||
if (value === '~') return homeDir
|
||||
return value.startsWith('~/') ? path.join(homeDir, value.slice(2)) : value
|
||||
}
|
||||
const file = resolve('CLINE_PROVIDER_SETTINGS_PATH')
|
||||
if (file) return file
|
||||
const dataDir = resolve('CLINE_DATA_DIR') ?? path.join(resolve('CLINE_DIR') ?? path.join(homeDir, '.cline'), 'data')
|
||||
return path.join(dataDir, 'settings', 'providers.json')
|
||||
}
|
||||
|
||||
export type ClineFileCredential = { token: string; oauth: boolean; expiresAt: number | null }
|
||||
|
||||
/** Matches Cline's getApiKey: the OAuth access token wins over keys kept in the same entry. */
|
||||
export function parseClineProviders(raw: string): ClineFileCredential | null {
|
||||
const root = JSON.parse(raw) as { providers?: { cline?: { settings?: Record<string, any> } } }
|
||||
const settings = root?.providers?.cline?.settings
|
||||
if (!settings || typeof settings !== 'object') return null
|
||||
const auth = settings['auth'] && typeof settings['auth'] === 'object' ? settings['auth'] : {}
|
||||
const clean = (value: unknown) => typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
const access = clean(auth.accessToken)
|
||||
if (access) {
|
||||
return {
|
||||
token: access.startsWith('workos:') ? access : `workos:${access}`,
|
||||
oauth: true,
|
||||
expiresAt: num(auth.expiresAt),
|
||||
}
|
||||
}
|
||||
const key = clean(settings['apiKey']) ?? clean(auth.apiKey)
|
||||
return key ? { token: key, oauth: false, expiresAt: null } : null
|
||||
}
|
||||
|
||||
function num(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
|
@ -97,15 +139,23 @@ export type ClinePassResult = { quota: QuotaProvider; retryAfterSeconds?: number
|
|||
|
||||
export async function fetchClinePassQuota(options: Partial<ClinePassDeps> & { signal?: AbortSignal } = {}): Promise<ClinePassResult> {
|
||||
const deps = { ...defaultDeps(), ...options }
|
||||
const key = clinePassApiKey(deps.env)
|
||||
if (!key) return { quota: empty('disconnected') }
|
||||
|
||||
try {
|
||||
const envKey = clinePassApiKey(deps.env)
|
||||
const file = envKey ? null : await deps.readFile(clineProvidersPath(deps.env, deps.homeDir), 1024 * 1024)
|
||||
const credential = envKey ? { token: envKey, oauth: false, expiresAt: null } : file ? parseClineProviders(file) : null
|
||||
if (!credential) return { quota: empty('disconnected') }
|
||||
// Not terminal: running cline refreshes the session file this only reads,
|
||||
// so the next scheduled read recovers on its own.
|
||||
if (credential.oauth && credential.expiresAt !== null && credential.expiresAt <= deps.now()) {
|
||||
return { quota: empty('transientFailure', EXPIRED_FOOTER) }
|
||||
}
|
||||
const response = await deps.fetch(USAGE_ENDPOINT, {
|
||||
method: 'GET', signal: quotaRequestSignal(options.signal),
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${key}`, 'User-Agent': 'CodeBurn' },
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${credential.token}`, 'User-Agent': 'CodeBurn' },
|
||||
})
|
||||
if (response.status === 401 || response.status === 403) return { quota: empty('terminalFailure', REJECTED_FOOTER) }
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { quota: credential.oauth ? empty('transientFailure', EXPIRED_FOOTER) : empty('terminalFailure', REJECTED_FOOTER) }
|
||||
}
|
||||
if (response.status === 429) {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
const seconds = raw === null ? NaN : Number(raw)
|
||||
|
|
|
|||
140
src/quota/commandcode.ts
Normal file
140
src/quota/commandcode.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Live Command Code quota from its billing endpoints:
|
||||
//
|
||||
// - GET https://api.commandcode.ai/alpha/billing/credits
|
||||
// windowLimits.{fiveHour,weekly}.{used,cap,resetAt}: USD credits spent of
|
||||
// the window's cap; resetAt is epoch ms, 0 while no window is open.
|
||||
// credits.{monthlyCredits,purchasedCredits,freeCredits}: USD remaining.
|
||||
// - GET https://api.commandcode.ai/alpha/billing/subscriptions
|
||||
// data.planId for the plan label; optional, a failure only drops the label.
|
||||
//
|
||||
// Credential: the CLI's own ~/.commandcode/auth.json `apiKey`, read-only.
|
||||
// The API refuses requests without a User-Agent, so one is always sent.
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { fraction, quotaRequestSignal, readSecureFile, sanitizeError } from './security.js'
|
||||
import type { QuotaProvider, QuotaWindow } from './types.js'
|
||||
|
||||
const API_BASE = 'https://api.commandcode.ai/alpha/billing'
|
||||
const EXPIRED_FOOTER = ['Command Code session expired. Sign in with the Command Code CLI again.']
|
||||
const RATE_LIMITED_FOOTER = ['Command Code rate-limited the quota request.']
|
||||
const UNAVAILABLE_FOOTER = ['Command Code is temporarily unavailable.']
|
||||
const PARSE_FOOTER = ['Command Code quota response was malformed.']
|
||||
|
||||
export type CommandCodeDeps = {
|
||||
fetch: typeof fetch
|
||||
authPath: string
|
||||
readFile: typeof readSecureFile
|
||||
}
|
||||
|
||||
function defaultDeps(): CommandCodeDeps {
|
||||
const root = process.env['CODEBURN_COMMANDCODE_DIR'] || path.join(os.homedir(), '.commandcode')
|
||||
return { fetch: globalThis.fetch, authPath: path.join(root, 'auth.json'), readFile: readSecureFile }
|
||||
}
|
||||
|
||||
function empty(connection: QuotaProvider['connection'], footerLines: string[] = []): QuotaProvider {
|
||||
return { provider: 'commandcode', connection, primary: null, details: [], planLabel: null, footerLines }
|
||||
}
|
||||
|
||||
function num(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function windowOf(label: string, raw: unknown): QuotaWindow | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const row = raw as Record<string, unknown>
|
||||
const used = num(row['used'])
|
||||
const cap = num(row['cap'])
|
||||
if (used === null || cap === null || cap <= 0) return null
|
||||
const resetAt = num(row['resetAt'])
|
||||
return {
|
||||
label,
|
||||
percent: fraction(used / cap * 100)!,
|
||||
resetsAt: resetAt !== null && resetAt > 0 ? new Date(resetAt).toISOString() : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** "individual-go-v1" -> "Go". Unknown shapes pass through unchanged. */
|
||||
export function commandCodePlanLabel(planId: unknown): string | null {
|
||||
if (typeof planId !== 'string' || !planId.trim()) return null
|
||||
const core = planId.trim().replace(/^individual-/, '').replace(/-v\d+$/, '')
|
||||
return core.charAt(0).toUpperCase() + core.slice(1)
|
||||
}
|
||||
|
||||
function creditsNote(raw: unknown): string | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const credits = raw as Record<string, unknown>
|
||||
const parts = ([['monthlyCredits', 'monthly'], ['purchasedCredits', 'purchased'], ['freeCredits', 'free']] as const)
|
||||
.map(([key, name]) => ({ name, value: num(credits[key]) }))
|
||||
.filter((part): part is { name: typeof part.name; value: number } => part.value !== null)
|
||||
if (parts.length === 0) return null
|
||||
const shown = parts.filter(part => part.value > 0)
|
||||
if (shown.length === 0) return 'Credits left: $0.00'
|
||||
return `Credits left: ${shown.map(part => `$${part.value.toFixed(2)} ${part.name}`).join(', ')}`
|
||||
}
|
||||
|
||||
/** `null` when neither window is readable. */
|
||||
export function decodeCommandCodeCredits(body: unknown, planId?: unknown): QuotaProvider | null {
|
||||
if (!body || typeof body !== 'object') return null
|
||||
const root = body as Record<string, unknown>
|
||||
const limits = root['windowLimits'] && typeof root['windowLimits'] === 'object'
|
||||
? root['windowLimits'] as Record<string, unknown>
|
||||
: {}
|
||||
const fiveHour = windowOf('5-hour', limits['fiveHour'])
|
||||
const weekly = windowOf('Weekly', limits['weekly'])
|
||||
const details = [fiveHour, weekly].filter((row): row is QuotaWindow => row !== null)
|
||||
if (details.length === 0) return null
|
||||
const note = creditsNote(root['credits'])
|
||||
return {
|
||||
provider: 'commandcode', connection: 'connected',
|
||||
primary: weekly ?? fiveHour!,
|
||||
details,
|
||||
planLabel: commandCodePlanLabel(planId),
|
||||
footerLines: note ? [note] : [],
|
||||
...(note ? { notes: [note] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function readApiKey(deps: CommandCodeDeps): Promise<string | null> {
|
||||
const raw = await deps.readFile(deps.authPath, 64 * 1024)
|
||||
if (!raw) return null
|
||||
const key = (JSON.parse(raw) as Record<string, unknown>)['apiKey']
|
||||
return typeof key === 'string' && key.trim() ? key.trim() : null
|
||||
}
|
||||
|
||||
export type CommandCodeResult = { quota: QuotaProvider; retryAfterSeconds?: number }
|
||||
|
||||
export async function fetchCommandCodeQuota(options: Partial<CommandCodeDeps> & { signal?: AbortSignal } = {}): Promise<CommandCodeResult> {
|
||||
const deps = { ...defaultDeps(), ...options }
|
||||
try {
|
||||
const key = await readApiKey(deps)
|
||||
if (!key) return { quota: empty('disconnected') }
|
||||
|
||||
const signal = quotaRequestSignal(options.signal)
|
||||
const get = (endpoint: string) => deps.fetch(`${API_BASE}/${endpoint}`, {
|
||||
method: 'GET', signal,
|
||||
headers: { Accept: 'application/json', Authorization: `Bearer ${key}`, 'User-Agent': 'CodeBurn' },
|
||||
})
|
||||
const [credits, subscription] = await Promise.all([get('credits'), get('subscriptions').catch(() => null)])
|
||||
if (credits.status === 401 || credits.status === 403) return { quota: empty('terminalFailure', EXPIRED_FOOTER) }
|
||||
if (credits.status === 429) {
|
||||
const raw = credits.headers.get('Retry-After')
|
||||
const seconds = raw === null ? NaN : Number(raw)
|
||||
return {
|
||||
quota: { ...empty('transientFailure', RATE_LIMITED_FOOTER), rateLimited: true },
|
||||
retryAfterSeconds: Math.max(Number.isFinite(seconds) ? Math.ceil(seconds) : 300, 60),
|
||||
}
|
||||
}
|
||||
if (credits.status >= 500) return { quota: empty('transientFailure', UNAVAILABLE_FOOTER) }
|
||||
if (!credits.ok) return { quota: empty('transientFailure', PARSE_FOOTER) }
|
||||
// Never log either body - they carry account data.
|
||||
const planId = subscription?.ok
|
||||
? await subscription.json().then(body => (body as { data?: { planId?: unknown } })?.data?.planId, () => undefined)
|
||||
: undefined
|
||||
const quota = decodeCommandCodeCredits(await credits.json(), planId)
|
||||
return { quota: quota ?? empty('transientFailure', PARSE_FOOTER) }
|
||||
} catch (error) {
|
||||
console.warn(`Command Code quota unavailable: ${sanitizeError(error)}`)
|
||||
return { quota: empty('transientFailure') }
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { fetchAntigravityQuota } from './antigravity.js'
|
|||
import { fetchClaudeQuota } from './claude.js'
|
||||
import { fetchClinePassQuota } from './clinepass.js'
|
||||
import { fetchCodexQuota } from './codex.js'
|
||||
import { fetchCommandCodeQuota } from './commandcode.js'
|
||||
import { fetchCopilotQuota } from './copilot.js'
|
||||
import { fetchCursorQuota } from './cursor.js'
|
||||
import { fetchDevinQuota } from './devin.js'
|
||||
|
|
@ -90,6 +91,7 @@ const READERS: { id: ProviderName; name: string; read: ProviderReader }[] = [
|
|||
{ id: 'grokbot', name: 'Grok Bot', read: async signal => (await fetchGrokbotQuota({ signal })).quota },
|
||||
{ id: 'clinepass', name: 'ClinePass', read: async signal => (await fetchClinePassQuota({ signal })).quota },
|
||||
{ id: 'devin', name: 'Devin', read: async () => (await fetchDevinQuota()).quota },
|
||||
{ id: 'commandcode', name: 'Command Code', read: async signal => (await fetchCommandCodeQuota({ signal })).quota },
|
||||
]
|
||||
|
||||
/** Grok Bot is an optional desktop app rather than a signed-in account. With
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export type QuotaWindow = {
|
|||
}
|
||||
|
||||
export type QuotaProvider = {
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'copilot' | 'antigravity' | 'kimi' | 'cursor' | 'zai' | 'zcode' | 'grok' | 'grokbot' | 'clinepass' | 'devin'
|
||||
provider: 'claude' | 'codex' | 'gemini' | 'copilot' | 'antigravity' | 'kimi' | 'cursor' | 'zai' | 'zcode' | 'grok' | 'grokbot' | 'clinepass' | 'devin' | 'commandcode'
|
||||
connection: 'connected' | 'disconnected' | 'accessDenied' | 'loading' | 'stale' | 'transientFailure' | 'terminalFailure'
|
||||
primary: QuotaWindow | null
|
||||
details: QuotaWindow[]
|
||||
|
|
|
|||
|
|
@ -377,7 +377,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// reported-cost-v1: the CLI reports its own per-message cost, so entries
|
||||
// cached before cline-cli joined the reported-cost allowlist in parser.ts
|
||||
// hold costUSD: undefined and get re-priced from tokens on every read.
|
||||
'cline-cli': 'reported-cost-v1-est-reprice-v1',
|
||||
// cache-inclusive-input-v1: cached calls hold inputTokens with cache reads
|
||||
// and writes still inside it, so they must re-parse.
|
||||
'cline-cli': 'reported-cost-v1-est-reprice-v1-cache-inclusive-input-v1',
|
||||
codewhale: 'aggregate-session-v1-est-cost',
|
||||
'command-code': 'cache-inclusive-input-v1',
|
||||
// Bump when the Codex parser changes attribution so unchanged, already-cached
|
||||
|
|
@ -420,8 +422,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// auto-review model by date (gpt-5.4 before 30 Jul 2026, Luna after).
|
||||
// codex-flex-reserve-v1: flex turns record speed 'flex' (cached calls hold
|
||||
// 'standard'), and `gpt-reserve` now splits cache writes like GPT-5.6 Luna.
|
||||
// codex-fork-mask-v1: a fork's replay burst drops only records the parent
|
||||
// rollout holds, so cached forks that dropped the whole burst re-parse.
|
||||
// Compose every suffix so cached sessions receive all accounting fixes.
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1-fork-replay-burst-v1-codex-token-usage-record-v1-codex-priority-tier-v1-codex-auto-review-date-v1-codex-flex-reserve-v1',
|
||||
codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1-fork-replay-burst-v1-codex-token-usage-record-v1-codex-priority-tier-v1-codex-auto-review-date-v1-codex-flex-reserve-v1-codex-fork-mask-v1',
|
||||
// reported-cost-v1: cached Crush calls stored costUSD: undefined and must
|
||||
// re-parse to keep the recorded session cost.
|
||||
crush: 'reported-cost-v1',
|
||||
|
|
@ -482,7 +486,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// authoritative-usage-v4: persist one Grok session call from top-level
|
||||
// authoritative totals, use modelUsage only for priced attribution, clamp
|
||||
// reasoning per record, and label mixed sessions estimated.
|
||||
grok: 'authoritative-usage-v4',
|
||||
// unified-log-v1: per-request usage from logs/unified.jsonl replaces the
|
||||
// session-dir rollup for every session the log holds.
|
||||
grok: 'authoritative-usage-v4-unified-log-v1',
|
||||
// Estimated from message text: Grok Bot's local mirror records no tokens.
|
||||
// import-guess-est-v1: synced Grok Bot rows with no dollar amount are estimated.
|
||||
grokbot: 'estimated-usage-v1-import-guess-est-v1',
|
||||
|
|
@ -512,7 +518,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// database is unusable. The legacy metadata.metrics path no longer carves
|
||||
// cache reads out of an input count that never held them. Devin is not
|
||||
// durable, so the bump rebuilds its section and old step_id keys go with it.
|
||||
devin: 'sessions-db-v1',
|
||||
// swe-pricing-v1: cached Devin calls carry their parse-time cost, so SWE-2
|
||||
// calls cached at $0 must re-parse to pick up its price.
|
||||
devin: 'sessions-db-v1-swe-pricing-v1',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
// project-path-v1: the parser now records the session's full working
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { scanAndDetect } from './optimize.js'
|
|||
import { callBillableOutputTokens, sessionBillableOutput, sessionBillableOutputTokens, inferSessionProvider } from './session-output.js'
|
||||
import { getDateRange } from './cli-date.js'
|
||||
import { activityStreak } from './streak.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, cachedProjectIdentities, projectDayIdentity, emptyCache, mergeDayEntries, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
|
||||
import { buildGranularHistory } from './granular-history.js'
|
||||
import { spendProjectIdentity } from './spend-flow.js'
|
||||
import { AGGREGATE_ONLY_PROVIDER, excludeAggregateOnlyProjects, excludesAggregateOnlyProviders } from './parser.js'
|
||||
|
|
@ -602,7 +602,7 @@ function sumMatchingProjects(
|
|||
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
|
||||
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, matched: 0 }
|
||||
for (const [name, p] of Object.entries(projects)) {
|
||||
if (!matches({ project: name, projectPath: p.path ?? '' })) continue
|
||||
if (!matches(projectDayIdentity(name, p))) continue
|
||||
out.cost += p.cost
|
||||
out.calls += p.calls
|
||||
out.savingsUSD += p.savingsUSD ?? 0
|
||||
|
|
@ -1419,7 +1419,8 @@ export function buildPayloadProjects(
|
|||
}
|
||||
if (cacheDays) {
|
||||
for (const d of cacheDays) {
|
||||
for (const [name, p] of Object.entries(d.projects ?? {})) {
|
||||
for (const [key, p] of Object.entries(d.projects ?? {})) {
|
||||
const name = projectDayIdentity(key, p).project
|
||||
if (p.path) {
|
||||
const id = spendProjectIdentity({ project: name, projectPath: p.path }).id
|
||||
let byId = knownBySlug.get(name)
|
||||
|
|
|
|||
|
|
@ -814,7 +814,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
const loaded = await loadDailyCache()
|
||||
// From v32, Copilot (v56), Codex, Hermes, Devin, Cursor Agent (v58) and Antigravity (v59) are owed
|
||||
// re-derivation; dsh's v32 contract is already satisfied.
|
||||
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity'])
|
||||
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity', 'grok'])
|
||||
})
|
||||
|
||||
it('preserves an older cache pending repair while adding a newer provider repair', async () => {
|
||||
|
|
@ -834,7 +834,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
)
|
||||
|
||||
const loaded = await loadDailyCache()
|
||||
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity'])
|
||||
expect(loaded.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'devin', 'cursor-agent', 'antigravity', 'grok'])
|
||||
})
|
||||
|
||||
it('still carries the slice whole when the sources are gone (never-lose, #1033)', async () => {
|
||||
|
|
@ -868,7 +868,7 @@ describe('#946: a migration re-derives copilot instead of carrying it', () => {
|
|||
it('a PARTIAL parse does not spend the entitlement', async () => {
|
||||
await seedOlderCache([day(settled, { copilot: PRE_STORE })])
|
||||
const partial = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
|
||||
expect(partial.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'dsh', 'devin', 'cursor-agent', 'antigravity'])
|
||||
expect(partial.pendingRederive).toEqual(['copilot', 'codex', 'hermes', 'dsh', 'devin', 'cursor-agent', 'antigravity', 'grok'])
|
||||
// The next COMPLETE run still gets to re-derive.
|
||||
const out = await ensureCacheHydrated(noSessions, () => [day(settled, { copilot: STORE_BACKED })], 'cfg-A')
|
||||
expect(out.days.find(d => d.date === settled)!.providers['copilot'])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from '../src/day-aggregator.js'
|
||||
import { isTurnResidueOnly } from '../src/daily-cache.js'
|
||||
import { cachedProjectIdentities, isTurnResidueOnly, projectDayIdentity, projectDayKey, type DailyEntry } from '../src/daily-cache.js'
|
||||
import { makeProjectFilter } from '../src/parser.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary> & { sessions: ProjectSummary['sessions'] }): ProjectSummary {
|
||||
|
|
@ -369,9 +370,55 @@ describe('aggregateProjectsIntoDays', () => {
|
|||
expect(day.categories['coding']).toMatchObject({ turns: 1, cost: 10 })
|
||||
// Per-project rollup at day level and inside each provider slice; path is
|
||||
// stored so display layers can derive a friendly name once sessions expire.
|
||||
expect(day.projects!['p']).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' })
|
||||
expect(day.providers['claude']!.projects!['p']).toMatchObject({ cost: 7, calls: 1 })
|
||||
expect(day.providers['codex']!.projects!['p']).toMatchObject({ cost: 3, calls: 1 })
|
||||
const key = projectDayKey('p', '/p')
|
||||
expect(day.projects![key]).toEqual({ cost: 10, calls: 2, savingsUSD: 0, sessions: 1, path: '/p' })
|
||||
expect(day.providers['claude']!.projects![key]).toMatchObject({ cost: 7, calls: 1 })
|
||||
expect(day.providers['codex']!.projects![key]).toMatchObject({ cost: 3, calls: 1 })
|
||||
})
|
||||
|
||||
it('splits one project label across the real paths its sessions ran in', () => {
|
||||
// Every Claude session started from the home folder shares one label; the
|
||||
// canonical cwd groups them into different projects. Keying the day split
|
||||
// by label alone kept the first path and billed the whole label to it.
|
||||
const timestamp = '2026-04-10T10:00:00'
|
||||
const home = '-home-u'
|
||||
const withPath = (projectPath: string, cost: number, sessionId: string): ProjectSummary => {
|
||||
const p = makeSingleTurnProject([makeCall(timestamp, cost)])
|
||||
p.project = home
|
||||
p.projectPath = projectPath
|
||||
p.sessions[0]!.project = home
|
||||
p.sessions[0]!.sessionId = sessionId
|
||||
p.sessions[0]!.turns[0]!.assistantCalls[0]!.deduplicationKey = `dk-${sessionId}`
|
||||
return p
|
||||
}
|
||||
const days = aggregateProjectsIntoDays([withPath('/home/u', 2, 'a'), withPath('/home/u/app', 5, 'b'), withPath('/home/u/web', 11, 'c')])
|
||||
const day = days[0]!
|
||||
const unscoped = aggregateProjectsIntoDays([withPath('/home/u', 2, 'a'), withPath('/home/u/app', 5, 'b'), withPath('/home/u/web', 11, 'c')].map(p => ({ ...p, projectPath: '/home/u' })))[0]!
|
||||
expect(day.cost).toBe(18)
|
||||
expect(day.cost).toBe(unscoped.cost)
|
||||
expect(day.calls).toBe(unscoped.calls)
|
||||
expect(day.sessions).toBe(unscoped.sessions)
|
||||
|
||||
const totalFor = (holder: { projects?: DailyEntry['projects'] }, pattern: string) => {
|
||||
const matches = makeProjectFilter([pattern])
|
||||
return Object.entries(holder.projects ?? {})
|
||||
.filter(([key, stats]) => matches(projectDayIdentity(key, stats)))
|
||||
.reduce((sum, [, stats]) => sum + stats.cost, 0)
|
||||
}
|
||||
expect(totalFor(day, '/home/u/app')).toBe(5)
|
||||
expect(totalFor(day, '/home/u/web')).toBe(11)
|
||||
expect(totalFor(day.providers['claude']!, '/home/u/app')).toBe(5)
|
||||
expect(Object.values(day.projects!).reduce((s, p) => s + p.cost, 0)).toBe(day.cost)
|
||||
expect(Object.values(day.projects!).reduce((s, p) => s + p.sessions, 0)).toBe(day.sessions)
|
||||
expect(cachedProjectIdentities({ version: 0, savingsConfigHash: '', lastComputedDate: null, days }, day.date, day.date))
|
||||
.toEqual(expect.arrayContaining([{ project: home, projectPath: '/home/u/app' }, { project: home, projectPath: '/home/u/web' }]))
|
||||
})
|
||||
|
||||
it('still reads a day split written before v67 (label key, one path)', () => {
|
||||
const legacy = { cost: 4, calls: 1, savingsUSD: 0, sessions: 1, path: '/home/u/app' }
|
||||
expect(projectDayIdentity('-home-u', legacy)).toEqual({ project: '-home-u', projectPath: '/home/u/app' })
|
||||
expect(makeProjectFilter(['/home/u/app'])(projectDayIdentity('-home-u', legacy))).toBe(true)
|
||||
expect(projectDayIdentity('pathless', { cost: 1, calls: 1, savingsUSD: 0, sessions: 0 })).toEqual({ project: 'pathless', projectPath: '' })
|
||||
})
|
||||
|
||||
it('attributes a multi-provider turn to the majority provider exactly once', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { appendFile, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
import { calculateCost } from '../src/models.js'
|
||||
|
|
@ -276,4 +276,35 @@ describe('Grok parser through the session-cache pipeline', () => {
|
|||
const warmCall = (await parseGrokSessions())[0]!.turns[0]!.assistantCalls[0]!
|
||||
expect(warmCall.costUSD).toBeCloseTo(expected, 12)
|
||||
})
|
||||
|
||||
it('reads a logged session from the log, then from its dir once the log no longer holds it', async () => {
|
||||
const uuid = '019edf9c-0000-7000-8000-000000000107'
|
||||
await writeSession(usage({ input: 1000, output: 100 }), uuid)
|
||||
const dir = join(GROK_HOME, 'sessions', '%2FUsers%2Ftest%2Fgrok-pipeline', uuid)
|
||||
await appendFile(join(dir, 'updates.jsonl'), JSON.stringify({
|
||||
method: 'session/update',
|
||||
params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', title: 'run_terminal_command', rawInput: { command: 'git status' } } },
|
||||
}) + '\n')
|
||||
const logPath = join(GROK_HOME, 'logs', 'unified.jsonl')
|
||||
await mkdir(join(GROK_HOME, 'logs'), { recursive: true })
|
||||
const inference = (ts: string) => JSON.stringify({
|
||||
ts, pid: 1, msg: 'shell.turn.inference_done', sid: uuid,
|
||||
ctx: { loop_index: 1, prompt_tokens: 500, cached_prompt_tokens: 0, completion_tokens: 50, reasoning_tokens: 0 },
|
||||
})
|
||||
await writeFile(logPath, [inference('2026-08-17T09:01:00.000Z'), inference('2026-08-17T09:02:00.000Z')].join('\n') + '\n')
|
||||
|
||||
const logged = (await parseGrokSessions())[0]!
|
||||
const calls = logged.turns.flatMap(turn => turn.assistantCalls)
|
||||
expect(calls.map(call => call.deduplicationKey.startsWith('grok:unified:'))).toEqual([true, true])
|
||||
expect(logged.turns[0]!.userMessage).toBe('pipeline regression')
|
||||
expect(calls[0]!.bashCommands).toEqual(['git'])
|
||||
expect(calls[1]!.tools).toEqual([])
|
||||
expect(calls[0]!.isEstimated).toBeFalsy()
|
||||
|
||||
await writeFile(logPath, JSON.stringify({ ts: '2026-08-18T00:00:00.000Z', pid: 2, msg: 'AuthManager::new' }) + '\n')
|
||||
clearSessionCache()
|
||||
const rotated = (await parseGrokSessions())[0]!
|
||||
expect(rotated.apiCalls).toBe(1)
|
||||
expect(rotated.turns[0]!.assistantCalls[0]!.deduplicationKey).not.toContain('unified')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
57
tests/kimi-stand-in-estimated.test.ts
Normal file
57
tests/kimi-stand-in-estimated.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// The K2.8 Preview stand-in flag is set where every call is read
|
||||
// (cachedCallToApiCall), so it reaches calls from any provider, cold or warm.
|
||||
// Own file because the codex provider captures CODEX_HOME at import.
|
||||
|
||||
import { afterAll, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
const testRoot = vi.hoisted(() => {
|
||||
const root = `${process.env['TMPDIR'] || '/tmp'}/kimi-stand-in-${process.pid}-${Date.now()}`
|
||||
process.env['HOME'] = `${root}/home`
|
||||
process.env['USERPROFILE'] = `${root}/home`
|
||||
process.env['CODEX_HOME'] = `${root}/codex`
|
||||
return root
|
||||
})
|
||||
|
||||
const CODEX_HOME = join(testRoot, 'codex')
|
||||
const CACHE_DIR = join(testRoot, 'cache')
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['HOME'] = join(testRoot, 'home')
|
||||
process.env['USERPROFILE'] = join(testRoot, 'home')
|
||||
process.env['CODEX_HOME'] = CODEX_HOME
|
||||
process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(testRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function rollout(id: string, day: string): string {
|
||||
const usage = { input_tokens: 1000, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 0, total_tokens: 1100 }
|
||||
return [
|
||||
JSON.stringify({ type: 'session_meta', timestamp: `${day}T10:00:00Z`, payload: { session_id: id, model: 'kimi-for-coding', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
|
||||
JSON.stringify({ type: 'response_item', timestamp: `${day}T10:00:10Z`, payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] } }),
|
||||
JSON.stringify({ type: 'event_msg', timestamp: `${day}T10:01:00Z`, payload: { type: 'token_count', info: { model: 'kimi-for-coding', last_token_usage: usage, total_token_usage: usage } } }),
|
||||
].join('\n') + '\n'
|
||||
}
|
||||
|
||||
it('marks kimi-for-coding calls from 11 Sep 2026 estimated on a cold parse and a cache-rehydrated read', async () => {
|
||||
await mkdir(join(CODEX_HOME, 'sessions', '2026', '09', '10'), { recursive: true })
|
||||
await mkdir(join(CODEX_HOME, 'sessions', '2026', '09', '12'), { recursive: true })
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
await writeFile(join(CODEX_HOME, 'sessions', '2026', '09', '10', 'rollout-k27.jsonl'), rollout('k27', '2026-09-10'))
|
||||
await writeFile(join(CODEX_HOME, 'sessions', '2026', '09', '12', 'rollout-k28.jsonl'), rollout('k28', '2026-09-12'))
|
||||
|
||||
const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')
|
||||
const flags = (projects: Awaited<ReturnType<typeof parseAllSessions>>) => projects
|
||||
.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
|
||||
.map(c => [c.timestamp.slice(0, 10), c.isEstimated ?? false])
|
||||
.sort()
|
||||
|
||||
clearSessionCache()
|
||||
expect(flags(await parseAllSessions(undefined, 'codex'))).toEqual([['2026-09-10', false], ['2026-09-12', true]])
|
||||
clearSessionCache()
|
||||
expect(flags(await parseAllSessions(undefined, 'codex'))).toEqual([['2026-09-10', false], ['2026-09-12', true]])
|
||||
})
|
||||
|
|
@ -208,7 +208,7 @@ describe('buildMenubarPayload', () => {
|
|||
const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')!
|
||||
expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2)
|
||||
expect(kimiK3.calls).toBe(78 + 2 + 40)
|
||||
const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')!
|
||||
const k2 = payload.current.topModels.find(m => m.name === 'Kimi for Coding')!
|
||||
expect(k2.cost).toBeCloseTo(0.06)
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined()
|
||||
expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined()
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ describe('post-hoist resolution stability', () => {
|
|||
|
||||
it('kimi managed aliases resolve to priced Kimi models', () => {
|
||||
expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)')
|
||||
expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking')
|
||||
expect(getShortModelName('kimi-for-coding')).toBe('Kimi for Coding')
|
||||
expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo')
|
||||
expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6')
|
||||
expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
snapshotPricingState,
|
||||
restorePricingState,
|
||||
pricingModelAt,
|
||||
isStandInPricedAt,
|
||||
} from '../src/models.js'
|
||||
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
|
||||
import snapshotData from '../src/data/litellm-snapshot.json' with { type: 'json' }
|
||||
|
|
@ -572,7 +573,7 @@ describe('resolveCanonicalModelId', () => {
|
|||
expect(resolveCanonicalModelId('gpt-5-fast')).toBe('gpt-5')
|
||||
expect(resolveCanonicalModelId('gpt-5-untracked-xyz')).toBe('gpt-5-untracked-xyz')
|
||||
expect(resolveCanonicalModelId('claude-opus-4.6')).toBe('claude-opus-4-6')
|
||||
expect(resolveCanonicalModelId('kimi-code')).toBe('kimi-k2-thinking')
|
||||
expect(resolveCanonicalModelId('kimi-code')).toBe('kimi-k2.7-code')
|
||||
expect(resolveCanonicalModelId('cline-pass/kimi-k3')).toBe('kimi-k3')
|
||||
expect(resolveCanonicalModelId('orcarouter/auto')).not.toBe(resolveCanonicalModelId('claude-sonnet-4-5'))
|
||||
expect(resolveCanonicalModelId('orcarouter/fusion')).toBe(resolveCanonicalModelId('openai/gpt-oss-120b'))
|
||||
|
|
@ -1196,6 +1197,79 @@ describe('Cursor model variants resolve to pricing', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('Kimi Code moving alias', () => {
|
||||
const rates = (model: string) => {
|
||||
const c = getModelCosts(model)!
|
||||
return [c.inputCostPerToken * 1e6, c.cacheReadCostPerToken * 1e6, c.outputCostPerToken * 1e6].map(v => +v.toFixed(4))
|
||||
}
|
||||
|
||||
it('prices kimi-for-coding by the model it served on the call date', () => {
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-01-26T23:59:59.999Z')).toBe('kimi-k2-thinking')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-01-27T00:00:00.000Z')).toBe('kimi-k2.5')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-04-12T23:59:59.999Z')).toBe('kimi-k2.5')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-04-13T00:00:00.000Z')).toBe('kimi-k2.6')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-06-11T23:59:59.999Z')).toBe('kimi-k2.6')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-06-12T00:00:00.000Z')).toBe('kimi-for-coding')
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-09-27T10:00:00Z')).toBe('kimi-for-coding')
|
||||
expect(pricingModelAt('kimi-code', '2026-03-01T00:00:00Z')).toBe('kimi-k2.5')
|
||||
expect(pricingModelAt('kimi-for-coding', undefined)).toBe('kimi-for-coding')
|
||||
expect(pricingModelAt('kimi-for-coding', 'not a date')).toBe('kimi-for-coding')
|
||||
expect(pricingModelAt('kimi-for-coding-highspeed', '2026-03-01T00:00:00Z')).toBe('kimi-for-coding-highspeed')
|
||||
expect(pricingModelAt('k3', '2026-03-01T00:00:00Z')).toBe('k3')
|
||||
})
|
||||
|
||||
it('resolves each period to Moonshot list prices ($/M input, cache hit, output)', () => {
|
||||
expect(rates('kimi-k2.5')).toEqual([0.6, 0.1, 3])
|
||||
expect(rates('kimi-k2.6')).toEqual([0.95, 0.16, 4])
|
||||
expect(rates('kimi-for-coding')).toEqual([0.95, 0.19, 4])
|
||||
expect(rates('kimi-code')).toEqual([0.95, 0.19, 4])
|
||||
expect(rates('kimi-for-coding-highspeed')).toEqual([1.9, 0.38, 8])
|
||||
expect(rates('k3')).toEqual([3, 0.3, 15])
|
||||
})
|
||||
|
||||
it('treats highspeed as priced, not a flat-rate SKU', () => {
|
||||
expect(isFlatRateModel('kimi-for-coding-highspeed')).toBe(false)
|
||||
expect(calculateCost('kimi-for-coding-highspeed', 1_000_000, 1_000_000, 0, 1_000_000, 0)).toBeCloseTo(10.28)
|
||||
})
|
||||
|
||||
it('marks only the K2.8 Preview period (from 11 Sep 2026) as stand-in priced', () => {
|
||||
expect(isStandInPricedAt('kimi-for-coding', '2026-09-10T23:59:59.999Z')).toBe(false)
|
||||
expect(isStandInPricedAt('kimi-for-coding', '2026-09-11T00:00:00.000Z')).toBe(true)
|
||||
expect(isStandInPricedAt('kimi-code', '2026-09-27T10:00:00Z')).toBe(true)
|
||||
expect(isStandInPricedAt('kimi-for-coding', '2026-07-01T00:00:00Z')).toBe(false)
|
||||
expect(isStandInPricedAt('kimi-for-coding', '2026-03-01T00:00:00Z')).toBe(false)
|
||||
expect(isStandInPricedAt('kimi-for-coding', undefined)).toBe(true)
|
||||
expect(isStandInPricedAt('kimi-for-coding-highspeed', '2026-09-27T10:00:00Z')).toBe(false)
|
||||
expect(isStandInPricedAt('k3', '2026-09-27T10:00:00Z')).toBe(false)
|
||||
setModelAliases({ 'kimi-for-coding': 'kimi-k3' })
|
||||
try {
|
||||
expect(isStandInPricedAt('kimi-for-coding', '2026-09-27T10:00:00Z')).toBe(false)
|
||||
} finally {
|
||||
setModelAliases({})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the alias name for display', () => {
|
||||
expect(getShortModelName('kimi-for-coding')).toBe('Kimi for Coding')
|
||||
expect(getShortModelName('kimi-for-coding-highspeed')).toBe('Kimi for Coding HighSpeed')
|
||||
})
|
||||
|
||||
it('lets a user alias or price override win over the date rule', () => {
|
||||
setModelAliases({ 'kimi-for-coding': 'kimi-k3' })
|
||||
try {
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-03-01T00:00:00Z')).toBe('kimi-for-coding')
|
||||
} finally {
|
||||
setModelAliases({})
|
||||
}
|
||||
setPriceOverrides({ 'kimi-for-coding': { input: 1, output: 2 } })
|
||||
try {
|
||||
expect(pricingModelAt('kimi-for-coding', '2026-03-01T00:00:00Z')).toBe('kimi-for-coding')
|
||||
} finally {
|
||||
setPriceOverrides({})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex activity ids (#1047)', () => {
|
||||
it('keeps the activity label instead of collapsing to the underlying model name', () => {
|
||||
expect(getShortModelName('codex-auto-review')).toBe('Codex Auto Review')
|
||||
|
|
@ -1786,8 +1860,6 @@ describe('findUnpricedModels', () => {
|
|||
{ model: 'auto-genius', calls: 898, cost: 0, tokens: 35_300_000 },
|
||||
{ model: 'cline-pass/auto-genius', calls: 4, cost: 0, tokens: 33_900 },
|
||||
{ model: 'auto', calls: 449, cost: 0, tokens: 17_700_000 },
|
||||
{ model: 'kimi-for-coding-highspeed', calls: 12, cost: 0, tokens: 3_400_000 },
|
||||
{ model: 'moonshot/kimi-for-coding-highspeed', calls: 2, cost: 0, tokens: 80_000 },
|
||||
{ model: 'grok-composer-2.5-fast', calls: 10, cost: 0, tokens: 1_900_000 },
|
||||
{ model: 'Grok Composer 2.5 Fast', calls: 10, cost: 0, tokens: 1_900_000 },
|
||||
{ model: 'Warp Auto (efficient)', calls: 3, cost: 0, tokens: 50_000 },
|
||||
|
|
@ -1827,7 +1899,7 @@ describe('findUnpricedModels', () => {
|
|||
expect(isExpectedFreeModel('warp-auto-efficient')).toBe(false)
|
||||
expect(isExpectedFreeModel('auto-genius')).toBe(true)
|
||||
expect(isExpectedFreeModel('auto')).toBe(true)
|
||||
expect(isExpectedFreeModel('kimi-for-coding-highspeed')).toBe(true)
|
||||
expect(isExpectedFreeModel('kimi-for-coding-highspeed')).toBe(false)
|
||||
expect(isExpectedFreeModel('warp')).toBe(false)
|
||||
expect(isExpectedFreeModel('codex-auto-review')).toBe(false)
|
||||
expect(isExpectedFreeModel('zz-mystery-paid-model-999')).toBe(false)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ describe('plan presets', () => {
|
|||
expect(getPresetPlan('copilot-pro')).toMatchObject({ id: 'copilot-pro', monthlyCredits: 1500, monthlyUsd: 15, provider: 'copilot' })
|
||||
expect(getPresetPlan('copilot-pro-plus')).toMatchObject({ id: 'copilot-pro-plus', monthlyCredits: 7000, monthlyUsd: 70, provider: 'copilot' })
|
||||
expect(getPresetPlan('copilot-max')).toMatchObject({ id: 'copilot-max', monthlyCredits: 20000, monthlyUsd: 200, provider: 'copilot' })
|
||||
expect(getPresetPlan('google-ai-pro')).toMatchObject({ id: 'google-ai-pro', monthlyUsd: 19.99, provider: 'antigravity' })
|
||||
expect(getPresetPlan('google-ai-ultra-5x')).toMatchObject({ id: 'google-ai-ultra-5x', monthlyUsd: 99.99, provider: 'antigravity' })
|
||||
expect(getPresetPlan('google-ai-ultra-20x')).toMatchObject({ id: 'google-ai-ultra-20x', monthlyUsd: 199.99, provider: 'antigravity' })
|
||||
expect(getPresetPlan('custom')).toBeNull()
|
||||
})
|
||||
|
||||
|
|
@ -27,6 +30,7 @@ describe('plan presets', () => {
|
|||
expect(isPlanProvider('all')).toBe(true)
|
||||
expect(isPlanProvider('claude')).toBe(true)
|
||||
expect(isPlanProvider('copilot')).toBe(true)
|
||||
expect(isPlanProvider('antigravity')).toBe(true)
|
||||
expect(isPlanProvider('invalid')).toBe(false)
|
||||
expect(isPlanId('copilot-pro')).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -79,9 +79,11 @@ describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
|
|||
it('grok reports exactly its resolved sessions dir', async () => {
|
||||
expect(await createGrokProvider('/tmp/grok-a').probeRoots!()).toEqual([
|
||||
{ path: '/tmp/grok-a', label: 'sessions' },
|
||||
{ path: join('/tmp', 'logs', 'unified.jsonl'), label: 'unified log' },
|
||||
])
|
||||
expect(await createGrokProvider().probeRoots!()).toEqual([
|
||||
{ path: join(homedir(), '.grok', 'sessions'), label: 'sessions' },
|
||||
{ path: join(homedir(), '.grok', 'logs', 'unified.jsonl'), label: 'unified log' },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from 'path'
|
|||
import { tmpdir } from 'os'
|
||||
|
||||
import { clineCli, createClineCliProvider, getClineCliSessionsDir } from '../../src/providers/cline-cli.js'
|
||||
import { calculateCost } from '../../src/models.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
|
@ -211,7 +212,7 @@ describe('cline-cli provider - parsing', () => {
|
|||
const calls = await collect(tmpDir)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(c => c.inputTokens)).toEqual([100, 200])
|
||||
expect(calls.map(c => c.inputTokens)).toEqual([93, 200])
|
||||
expect(calls.map(c => c.outputTokens)).toEqual([10, 20])
|
||||
expect(calls[0]?.cacheReadInputTokens).toBe(5)
|
||||
expect(calls[0]?.cacheCreationInputTokens).toBe(2)
|
||||
|
|
@ -328,6 +329,22 @@ describe('cline-cli provider - parsing', () => {
|
|||
expect(call?.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('takes cache reads out of the cache-inclusive input before estimating', async () => {
|
||||
await writeSession(tmpDir, 'sess-a', {
|
||||
messages: [{
|
||||
role: 'assistant', text: 'a', model: 'cline-pass/deepseek-v4.1-flash',
|
||||
metrics: { inputTokens: 14689, outputTokens: 220, cacheReadTokens: 11136, cacheWriteTokens: 0 },
|
||||
}],
|
||||
})
|
||||
|
||||
const [call] = await collect(tmpDir)
|
||||
|
||||
expect(call?.inputTokens).toBe(3553)
|
||||
expect(call?.cacheReadInputTokens).toBe(11136)
|
||||
expect(call?.costUSD).toBeCloseTo(calculateCost('cline-pass/deepseek-v4.1-flash', 3553, 220, 0, 11136, 0), 12)
|
||||
expect(call?.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps a metered $0 cost reported instead of re-estimating it', async () => {
|
||||
await writeSession(tmpDir, 'sess-a', {
|
||||
messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: 0 } }],
|
||||
|
|
@ -428,7 +445,7 @@ describe('cline-cli provider - rollup fallback', () => {
|
|||
const calls = await collect(tmpDir)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.inputTokens).toBe(5483)
|
||||
expect(calls[0]?.inputTokens).toBe(5433)
|
||||
expect(calls[0]?.outputTokens).toBe(133)
|
||||
expect(calls[0]?.cacheReadInputTokens).toBe(50)
|
||||
expect(calls[0]?.costUSD).toBeCloseTo(0.0081984, 7)
|
||||
|
|
|
|||
|
|
@ -1405,6 +1405,123 @@ describe('codex provider - forked session dedupe', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('codex provider - fork replay masking against the parent rollout', () => {
|
||||
const PARENT = '019e0000-0000-7000-8000-000000000001'
|
||||
const CHILD = '019e0000-0000-7000-8000-000000000002'
|
||||
const GRANDCHILD = '019e0000-0000-7000-8000-000000000003'
|
||||
const rollout = (id: string, lines: string[]) =>
|
||||
writeSession(tmpDir, '2026-04-14', `rollout-2026-04-14T10-00-00-${id}.jsonl`, lines)
|
||||
const at = (s: string) => `2026-04-14T10:${s}Z`
|
||||
type U = { input?: number; output?: number }
|
||||
const cumulative: Record<string, U> = {}
|
||||
// Each call advances that rollout's running total by its own usage.
|
||||
const call = (rolloutId: string, ts: string, last: U) => {
|
||||
const t = (cumulative[rolloutId] ??= { input: 0, output: 0 })
|
||||
t.input! += last.input ?? 0
|
||||
t.output! += last.output ?? 0
|
||||
return tokenCount({ timestamp: at(ts), model: 'gpt-5.5', last, total: { input: t.input, output: t.output } })
|
||||
}
|
||||
const cost = (u: U) => calculateCost('gpt-5.5', u.input ?? 0, u.output ?? 0, 0, 0, 0, 'standard', 0, 'codex')
|
||||
|
||||
async function parseFile(path: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const c of provider.createSessionParser({ path, project: 'test', provider: 'codex' }, seenKeys).parse()) calls.push(c)
|
||||
return calls
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(cumulative)) delete cumulative[k]
|
||||
})
|
||||
|
||||
it('counts fork-only burst calls one by one through a parent -> child -> grandchild chain', async () => {
|
||||
const p = [call(PARENT, '00:01', { input: 1_000 }), call(PARENT, '00:02', { input: 2_000 })]
|
||||
cumulative[CHILD] = { ...cumulative[PARENT] }
|
||||
const longContext: U = { input: 300_000, output: 100 }
|
||||
const c = [
|
||||
call(CHILD, '01:00.020', longContext),
|
||||
call(CHILD, '01:00.030', { input: 500, output: 50 }),
|
||||
call(CHILD, '01:30', { input: 700 }),
|
||||
]
|
||||
cumulative[GRANDCHILD] = { ...cumulative[CHILD] }
|
||||
const g = call(GRANDCHILD, '02:30', { input: 900 })
|
||||
const retime = (line: string, ts: string) => line.replace(/"timestamp":"[^"]*"/, `"timestamp":"${at(ts)}"`)
|
||||
|
||||
const parentPath = await rollout(PARENT, [sessionMeta({ session_id: PARENT, model: 'gpt-5.5' }), ...p])
|
||||
const childPath = await rollout(CHILD, [
|
||||
sessionMeta({ session_id: CHILD, forked_from_id: PARENT, model: 'gpt-5.5', timestamp: at('01:00') }),
|
||||
retime(p[0]!, '01:00.005'), retime(p[1]!, '01:00.010'),
|
||||
...c,
|
||||
])
|
||||
const grandchildPath = await rollout(GRANDCHILD, [
|
||||
sessionMeta({ session_id: GRANDCHILD, forked_from_id: CHILD, model: 'gpt-5.5', timestamp: at('02:00') }),
|
||||
...[...p, ...c].map((line, i) => retime(line, `02:00.${String(i + 1).padStart(3, '0')}`)),
|
||||
g,
|
||||
])
|
||||
|
||||
const seen = new Set<string>()
|
||||
const parent = await parseFile(parentPath, seen)
|
||||
const child = await parseFile(childPath, seen)
|
||||
const grandchild = await parseFile(grandchildPath, seen)
|
||||
|
||||
expect(parent.map(x => x.costUSD)).toEqual([cost({ input: 1_000 }), cost({ input: 2_000 })])
|
||||
expect(child.map(x => x.costUSD)).toEqual([cost(longContext), cost({ input: 500, output: 50 }), cost({ input: 700 })])
|
||||
expect(grandchild.map(x => x.costUSD)).toEqual([cost({ input: 900 })])
|
||||
// The long-context tier applies to the one call that crossed it.
|
||||
expect(child[0]!.costUSD).toBeGreaterThan(calculateCost('gpt-5.5', 300_000, 100, 0, 0, 0))
|
||||
})
|
||||
|
||||
it('drops the whole burst when the parent rollout is not on disk', async () => {
|
||||
const p = [call(PARENT, '00:01', { input: 1_000 })]
|
||||
cumulative[CHILD] = { ...cumulative[PARENT] }
|
||||
const childPath = await rollout(CHILD, [
|
||||
sessionMeta({ session_id: CHILD, forked_from_id: PARENT, timestamp: at('01:00') }),
|
||||
p[0]!.replace(at('00:01'), at('01:00.005')),
|
||||
call(CHILD, '01:00.010', { input: 500 }),
|
||||
call(CHILD, '01:30', { input: 700 }),
|
||||
])
|
||||
expect((await parseFile(childPath)).map(x => x.inputTokens)).toEqual([700])
|
||||
})
|
||||
|
||||
it('drops the whole burst when its first record is not in the parent', async () => {
|
||||
await rollout(PARENT, [sessionMeta({ session_id: PARENT }), call(PARENT, '00:01', { input: 1_000 })])
|
||||
cumulative[CHILD] = { input: 5, output: 0 }
|
||||
const childPath = await rollout(CHILD, [
|
||||
sessionMeta({ session_id: CHILD, forked_from_id: PARENT, timestamp: at('01:00') }),
|
||||
call(CHILD, '01:00.005', { input: 500 }),
|
||||
call(CHILD, '01:30', { input: 700 }),
|
||||
])
|
||||
expect((await parseFile(childPath)).map(x => x.inputTokens)).toEqual([700])
|
||||
})
|
||||
|
||||
it('does not mask with parent records written after the fork', async () => {
|
||||
const p1 = call(PARENT, '00:01', { input: 1_000 })
|
||||
cumulative[CHILD] = { ...cumulative[PARENT] }
|
||||
const forkOnly = call(CHILD, '01:00.010', { input: 500 })
|
||||
await rollout(PARENT, [sessionMeta({ session_id: PARENT }), p1, forkOnly.replace(at('01:00.010'), at('05:00'))])
|
||||
const childPath = await rollout(CHILD, [
|
||||
sessionMeta({ session_id: CHILD, forked_from_id: PARENT, timestamp: at('01:00') }),
|
||||
p1.replace(at('00:01'), at('01:00.005')),
|
||||
forkOnly,
|
||||
])
|
||||
expect((await parseFile(childPath)).map(x => x.inputTokens)).toEqual([500])
|
||||
})
|
||||
|
||||
it('masks replayed response records and counts a fork-only one once', async () => {
|
||||
const record = (ts: string, responseId: string, input: number) =>
|
||||
tokenUsageRecord({ timestamp: at(ts), responseId, usage: { input } })
|
||||
const p = [record('00:01', 'resp-1', 1_000), tokenCount({ timestamp: at('00:01'), last: { input: 1_000 }, total: { input: 1_000 } })]
|
||||
await rollout(PARENT, [sessionMeta({ session_id: PARENT }), ...p])
|
||||
const childPath = await rollout(CHILD, [
|
||||
sessionMeta({ session_id: CHILD, forked_from_id: PARENT, timestamp: at('01:00') }),
|
||||
...p.map(line => line.replace(at('00:01'), at('01:00.005'))),
|
||||
record('01:00.010', 'resp-2', 500),
|
||||
tokenCount({ timestamp: at('01:00.010'), last: { input: 500 }, total: { input: 1_500 } }),
|
||||
])
|
||||
expect((await parseFile(childPath)).map(x => x.inputTokens)).toEqual([500])
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex provider - token_usage_record accounting', () => {
|
||||
async function parseCalls(lines: string[]): Promise<ParsedProviderCall[]> {
|
||||
const filePath = await writeSession(tmpDir, '2026-09-27', 'rollout-usage-record.jsonl', lines)
|
||||
|
|
|
|||
|
|
@ -128,7 +128,8 @@ skipUnlessSqlite('devin provider sessions.db usage', () => {
|
|||
userMessage: 'fix the ledger tests',
|
||||
})
|
||||
expect(calls[1]!.costUSD).toBeGreaterThan(0)
|
||||
expect(calls[2]).toMatchObject({ model: 'swe-2-high', costUSD: 0, inputTokens: 10 })
|
||||
expect(calls[2]).toMatchObject({ model: 'swe-2-high', inputTokens: 10 })
|
||||
expect(calls[2]!.costUSD).toBeCloseTo(10 * 3e-6 + 1 * 15e-6, 12)
|
||||
expect(calls[3]).toMatchObject({ model: 'compactor', costUSD: 0, inputTokens: 7 })
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -845,9 +845,12 @@ skipUnlessSqlite('devin provider sessions.db enrichment', () => {
|
|||
|
||||
expect(expectedKimiCost).toBeGreaterThan(0)
|
||||
expect(sum(c => (c.model === 'Kimi K3' ? c.costUSD : 0))).toBeCloseTo(expectedKimiCost, 12)
|
||||
// swe-2-high has no pricing table entry: unpriced, never silently priced.
|
||||
expect(getModelCosts('swe-2-high')).toBeNull()
|
||||
expect(calls.filter(c => c.model === 'swe-2-high').every(c => c.costUSD === 0)).toBe(true)
|
||||
// Cognition's SWE-2 list price: $3 input, $15 output, $0.30 cache read per 1M.
|
||||
const expectedSweCost = steps
|
||||
.filter(([model]) => model === 'swe-2-high')
|
||||
.reduce((total, [, prompt, completion, cached]) =>
|
||||
total + (prompt - cached) * 3e-6 + completion * 15e-6 + cached * 0.3e-6, 0)
|
||||
expect(sum(c => (c.model === 'swe-2-high' ? c.costUSD : 0))).toBeCloseTo(expectedSweCost, 12)
|
||||
|
||||
expect(calls[0]).toMatchObject({
|
||||
project: 'codeburn',
|
||||
|
|
|
|||
|
|
@ -478,6 +478,125 @@ describe('grok provider - parsing', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('grok provider - unified log', () => {
|
||||
const SID_A = '019f0000-0000-7000-8000-00000000000a'
|
||||
const SID_B = '019f0000-0000-7000-8000-00000000000b'
|
||||
|
||||
function logLine(msg: string, sid: string | undefined, ts: string, ctx?: Record<string, unknown>) {
|
||||
return JSON.stringify({ ts, src: 'shell', pid: 4242, lvl: 'info', msg, ...(sid ? { sid } : {}), ...(ctx ? { ctx } : {}) })
|
||||
}
|
||||
|
||||
function inferenceDone(sid: string, ts: string, loopIndex: number, prompt: number, cached: number, completion: number, reasoning: number) {
|
||||
return logLine('shell.turn.inference_done', sid, ts, {
|
||||
loop_index: loopIndex, model_elapsed_ms: 1000, attempts: 1,
|
||||
prompt_tokens: prompt, cached_prompt_tokens: cached, completion_tokens: completion, reasoning_tokens: reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
async function writeLog(lines: string[]) {
|
||||
await mkdir(join(tmpDir, 'logs'), { recursive: true })
|
||||
await writeFile(join(tmpDir, 'logs', 'unified.jsonl'), [
|
||||
logLine('AuthManager::new', undefined, '2026-07-28T09:59:59.000Z', { result: 'ok' }),
|
||||
...lines,
|
||||
].join('\n') + '\n')
|
||||
}
|
||||
|
||||
async function parseAll() {
|
||||
const provider = createGrokProvider(join(tmpDir, 'sessions'))
|
||||
const calls: ParsedProviderCall[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const source of await provider.discoverSessions()) {
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('reads each inference as a call with cached and reasoning split out', async () => {
|
||||
await writeLog([
|
||||
logLine('session created', SID_A, '2026-07-28T10:00:00.000Z', { cwd: '/Users/test/grok-cwd' }),
|
||||
logLine('model changed', SID_A, '2026-07-28T10:00:00.100Z', { model: 'grok-4.5' }),
|
||||
logLine('shell.handle_prompt.start', SID_A, '2026-07-28T10:00:01.000Z', { prompt_id: 'p1', block_count: 1 }),
|
||||
inferenceDone(SID_A, '2026-07-28T10:00:18.000Z', 1, 36828, 21888, 1522, 903),
|
||||
inferenceDone(SID_A, '2026-07-28T10:00:30.000Z', 2, 40000, 36000, 500, 100),
|
||||
])
|
||||
const calls = await parseAll()
|
||||
expect(calls).toHaveLength(2)
|
||||
const [first] = calls
|
||||
expect(first).toMatchObject({
|
||||
model: 'grok-4.5',
|
||||
inputTokens: 36828 - 21888,
|
||||
cacheReadInputTokens: 21888,
|
||||
cachedInputTokens: 21888,
|
||||
outputTokens: 1522 - 903,
|
||||
reasoningTokens: 903,
|
||||
cacheCreationInputTokens: 0,
|
||||
costIsEstimated: false,
|
||||
timestamp: '2026-07-28T10:00:18.000Z',
|
||||
sessionId: SID_A,
|
||||
project: 'grok-cwd',
|
||||
projectPath: '/Users/test/grok-cwd',
|
||||
})
|
||||
expect(first!.costUSD).toBeCloseTo((14940 * 2 + 21888 * 0.3 + 1522 * 6) / 1e6, 10)
|
||||
expect(first!.costUSD).toBeCloseTo(calculateCost('grok-4.5', 14940, 1522, 0, 21888, 0), 10)
|
||||
expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('reads a logged session from the log instead of its session dir, and keeps session-only sessions', async () => {
|
||||
const SID_C = '019f0000-0000-7000-8000-00000000000c'
|
||||
const sessionsCwd = join('sessions', '%2FUsers%2Ftest')
|
||||
await writeSession({ cwdEncoded: sessionsCwd, uuid: SID_A, completedTurns: [{ promptId: 'p1', usage: authoritativeUsage() }] })
|
||||
await writeSession({ cwdEncoded: sessionsCwd, uuid: SID_C, completedTurns: [{ promptId: 'p1', usage: authoritativeUsage() }] })
|
||||
await writeLog([
|
||||
logLine('model changed', SID_A, '2026-07-28T10:00:00.000Z', { model: 'grok-4.5' }),
|
||||
inferenceDone(SID_A, '2026-07-28T10:00:18.000Z', 1, 1000, 0, 100, 0),
|
||||
inferenceDone(SID_A, '2026-07-28T10:00:30.000Z', 2, 1200, 1000, 50, 0),
|
||||
logLine('model changed', SID_B, '2026-07-28T11:00:00.000Z', { model: 'grok-4.5' }),
|
||||
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 500, 200, 50),
|
||||
])
|
||||
const calls = await parseAll()
|
||||
const a = calls.filter(c => c.sessionId === SID_A)
|
||||
expect(a).toHaveLength(2)
|
||||
expect(a.every(c => c.deduplicationKey.startsWith('grok:unified:'))).toBe(true)
|
||||
const c = calls.filter(c => c.sessionId === SID_C)
|
||||
expect(c).toHaveLength(1)
|
||||
expect(c[0]!.deduplicationKey).not.toContain('unified')
|
||||
expect(calls.filter(c => c.sessionId === SID_B)).toHaveLength(1)
|
||||
expect(calls.find(c => c.sessionId === SID_B)!.project).toBe('grok')
|
||||
})
|
||||
|
||||
it('prices the long-context tier per request, not on the session total', async () => {
|
||||
await writeLog([
|
||||
logLine('model changed', SID_B, '2026-07-28T11:00:00.000Z', { model: 'grok-4.6' }),
|
||||
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 150000, 0, 1000, 0),
|
||||
inferenceDone(SID_B, '2026-07-28T11:01:18.000Z', 1, 150000, 0, 1000, 0),
|
||||
])
|
||||
const calls = await parseAll()
|
||||
const total = calls.reduce((sum, c) => sum + c.costUSD, 0)
|
||||
expect(total).toBeCloseTo(2 * calculateCost('grok-4.6', 150000, 1000, 0, 0, 0), 10)
|
||||
expect(total).toBeLessThan(calculateCost('grok-4.6', 300000, 2000, 0, 0, 0))
|
||||
})
|
||||
|
||||
it('takes the model from the process when the session never names one', async () => {
|
||||
await writeLog([
|
||||
logLine('model catalog: notifying clients', undefined, '2026-07-28T11:00:00.000Z', { current_model_id: 'grok-4.5', model_count: 3 }),
|
||||
inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 0, 200, 0),
|
||||
])
|
||||
const [call] = await parseAll()
|
||||
expect(call!.model).toBe('grok-4.5')
|
||||
})
|
||||
|
||||
it('falls back to grok-build, marked estimated, when nothing names the model', async () => {
|
||||
await writeLog([inferenceDone(SID_B, '2026-07-28T11:00:18.000Z', 1, 2000, 0, 200, 0)])
|
||||
const [call] = await parseAll()
|
||||
expect(call!.model).toBe('grok-build')
|
||||
expect(call!.costIsEstimated).toBe(true)
|
||||
})
|
||||
|
||||
it('discovers nothing extra when there is no unified log', async () => {
|
||||
expect(await createGrokProvider(join(tmpDir, 'sessions')).discoverSessions()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok provider - display names', () => {
|
||||
const provider = createGrokProvider('/tmp')
|
||||
|
||||
|
|
|
|||
|
|
@ -125,6 +125,32 @@ describe('Kimi provider', () => {
|
|||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('prices kimi-for-coding by call date', async () => {
|
||||
const wirePath = await writeSession('/Users/test/work/app', 'sess-k28', [
|
||||
record(1789084799, 'StatusUpdate', {
|
||||
message_id: 'before',
|
||||
model_name: 'kimi-for-coding',
|
||||
token_usage: { input_other: 1_000_000, output: 1_000_000 },
|
||||
}),
|
||||
record(1789084800, 'StatusUpdate', {
|
||||
message_id: 'from',
|
||||
model_name: 'kimi-for-coding',
|
||||
token_usage: { input_other: 1_000_000, output: 1_000_000 },
|
||||
}),
|
||||
record(1777593600, 'StatusUpdate', {
|
||||
message_id: 'k26',
|
||||
model_name: 'kimi-for-coding',
|
||||
token_usage: { input_other: 1_000_000, output: 0 },
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collect(createKimiProvider(tmpDir), wirePath)
|
||||
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(4.95)
|
||||
expect(calls[1]!.costUSD).toBeCloseTo(4.95)
|
||||
expect(calls[2]!.costUSD).toBeCloseTo(0.95)
|
||||
})
|
||||
|
||||
it('uses content parts, model payload overrides, and message-id deduplication', async () => {
|
||||
process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking'
|
||||
const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [
|
||||
|
|
|
|||
|
|
@ -107,9 +107,26 @@ describe.each([['CLI', cli], ['Electron', desktop]] as const)('%s Antigravity 2.
|
|||
// The command rounds percentages to one decimal; the decoder test above
|
||||
// separately pins the unrounded provider fraction.
|
||||
expect(provider.windows[0]!.usedPct).toBe(0)
|
||||
expect(request).toHaveBeenCalledExactlyOnceWith(
|
||||
54321, true, '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary', '{}', 'fixture-token',
|
||||
)
|
||||
expect(request.mock.calls.map(call => call.slice(0, 3))).toEqual([
|
||||
[54321, true, '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary'],
|
||||
[54321, true, '/exa.language_server_pb.LanguageServerService/GetUserStatus'],
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the Google tier', { name: 'Google AI Ultra' }, 'Google AI Ultra'],
|
||||
['the free tier, not the planInfo name', { id: 'free-tier', name: 'Antigravity Starter Quota' }, 'Antigravity Starter Quota'],
|
||||
['nothing for a blank tier', { name: ' ' }, null],
|
||||
])('labels the summary windows with %s', async (_case, userTier, label) => {
|
||||
const request = vi.fn<cli.LocalRequestFn>(async (_port, _tls, pathName) => ({
|
||||
status: 200,
|
||||
text: JSON.stringify(pathName.endsWith('/GetUserStatus')
|
||||
? { userStatus: { userTier, planStatus: { planInfo: { planName: 'Pro' } } } }
|
||||
: summary),
|
||||
}))
|
||||
const quota = await reader.fetchAntigravityQuota({ execFile, request, platform: 'darwin' })
|
||||
expect(quota.details).toHaveLength(4)
|
||||
expect(quota.planLabel).toBe(label)
|
||||
})
|
||||
|
||||
it('falls back to the captured label-based status when the summary has no quota', async () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
// Fixture-driven coverage for the ClinePass quota adapter, ported from the
|
||||
// menubar's ClinePassQuotaTests. The key is synthetic and every request goes
|
||||
// through the injected fetch.
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { clinePassApiKey, decodeClinePassUsage, fetchClinePassQuota } from '../src/quota/clinepass.js'
|
||||
import {
|
||||
clinePassApiKey, clineProvidersPath, decodeClinePassUsage, fetchClinePassQuota, parseClineProviders,
|
||||
} from '../src/quota/clinepass.js'
|
||||
|
||||
const neverFetch = (() => { throw new Error('the test must not reach the network') }) as unknown as typeof fetch
|
||||
const SYNTHETIC_KEY = 'synthetic-clinepass-test-key'
|
||||
const noFile = async () => null
|
||||
const NOW = 1_800_000_000_000
|
||||
|
||||
function providersFile(settings: unknown): string {
|
||||
return JSON.stringify({ version: 1, providers: { cline: { settings }, anthropic: { settings: { apiKey: 'other-provider-key' } } } })
|
||||
}
|
||||
|
||||
const successBody = {
|
||||
success: true,
|
||||
|
|
@ -32,9 +42,56 @@ describe('ClinePass credential discovery', () => {
|
|||
})
|
||||
|
||||
it('reports disconnected with no key and never fetches', async () => {
|
||||
const result = await fetchClinePassQuota({ env: {}, fetch: neverFetch })
|
||||
const result = await fetchClinePassQuota({ env: {}, fetch: neverFetch, readFile: noFile })
|
||||
expect(result.quota.connection).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('follows Cline\'s settings path overrides', () => {
|
||||
expect(clineProvidersPath({}, '/home/u')).toBe(join('/home/u', '.cline', 'data', 'settings', 'providers.json'))
|
||||
expect(clineProvidersPath({ CLINE_DIR: '~/c' }, '/home/u')).toBe(join('/home/u', 'c', 'data', 'settings', 'providers.json'))
|
||||
expect(clineProvidersPath({ CLINE_DIR: '/c', CLINE_DATA_DIR: '/d' }, '/home/u')).toBe(join('/d', 'settings', 'providers.json'))
|
||||
expect(clineProvidersPath({ CLINE_DATA_DIR: '/d', CLINE_PROVIDER_SETTINGS_PATH: '/f.json' }, '/home/u')).toBe('/f.json')
|
||||
})
|
||||
|
||||
it('prefers the OAuth access token, then apiKey, then auth.apiKey', () => {
|
||||
expect(parseClineProviders(providersFile({ apiKey: 'k1', auth: { accessToken: 'workos:tok', apiKey: 'k2', expiresAt: NOW } })))
|
||||
.toEqual({ token: 'workos:tok', oauth: true, expiresAt: NOW })
|
||||
expect(parseClineProviders(providersFile({ auth: { accessToken: 'bare' } }))?.token).toBe('workos:bare')
|
||||
expect(parseClineProviders(providersFile({ apiKey: 'k1', auth: { apiKey: 'k2' } }))).toEqual({ token: 'k1', oauth: false, expiresAt: null })
|
||||
expect(parseClineProviders(providersFile({ auth: { apiKey: 'k2' } }))?.token).toBe('k2')
|
||||
expect(parseClineProviders(providersFile({ provider: 'cline' }))).toBeNull()
|
||||
expect(parseClineProviders(JSON.stringify({ providers: {} }))).toBeNull()
|
||||
})
|
||||
|
||||
it('sends the Cline session token when no key is set, and a key wins over it', async () => {
|
||||
const seen: string[] = []
|
||||
const file = providersFile({ auth: { accessToken: 'workos:session-token', expiresAt: NOW + 60_000 } })
|
||||
const fetchSpy = (async (_url: string, init: RequestInit) => {
|
||||
seen.push((init.headers as Record<string, string>)['Authorization']!)
|
||||
return jsonResponse(successBody)
|
||||
}) as unknown as typeof fetch
|
||||
const viaFile = await fetchClinePassQuota({ env: {}, fetch: fetchSpy, readFile: async () => file, now: () => NOW })
|
||||
expect(viaFile.quota.connection).toBe('connected')
|
||||
await fetchClinePassQuota({ env: { CLINE_API_KEY: SYNTHETIC_KEY }, fetch: fetchSpy, readFile: async () => file, now: () => NOW })
|
||||
expect(seen).toEqual(['Bearer workos:session-token', `Bearer ${SYNTHETIC_KEY}`])
|
||||
})
|
||||
|
||||
it('stops at an expired Cline session without fetching, as a retryable failure', async () => {
|
||||
const file = providersFile({ auth: { accessToken: 'workos:old', expiresAt: NOW - 1 } })
|
||||
const result = await fetchClinePassQuota({ env: {}, fetch: neverFetch, readFile: async () => file, now: () => NOW })
|
||||
expect(result.quota.connection).toBe('transientFailure')
|
||||
expect(result.quota.footerLines).toEqual(['Cline sign-in expired. Run cline to refresh it.'])
|
||||
})
|
||||
|
||||
it('reads a rejected session token as an expired sign-in', async () => {
|
||||
const file = providersFile({ auth: { accessToken: 'workos:revoked' } })
|
||||
const result = await fetchClinePassQuota({
|
||||
env: {}, readFile: async () => file, now: () => NOW,
|
||||
fetch: (async () => jsonResponse({}, 401)) as unknown as typeof fetch,
|
||||
})
|
||||
expect(result.quota.connection).toBe('transientFailure')
|
||||
expect(result.quota.footerLines).toEqual(['Cline sign-in expired. Run cline to refresh it.'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClinePass quota decoding', () => {
|
||||
|
|
|
|||
112
tests/quota-commandcode.test.ts
Normal file
112
tests/quota-commandcode.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// Fixture-driven coverage for the Command Code quota adapter. The key is
|
||||
// synthetic and every request goes through the injected fetch.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { commandCodePlanLabel, decodeCommandCodeCredits, fetchCommandCodeQuota } from '../src/quota/commandcode.js'
|
||||
|
||||
const neverFetch = (() => { throw new Error('the test must not reach the network') }) as unknown as typeof fetch
|
||||
const AUTH = JSON.stringify({ apiKey: 'synthetic-commandcode-key', userName: 'someone' })
|
||||
const RESET_MS = 1_791_653_049_551
|
||||
|
||||
const creditsBody = {
|
||||
credits: { belowThreshold: false, creditThreshold: 0, monthlyCredits: 3.9706645399, purchasedCredits: 0, freeCredits: 0 },
|
||||
windowLimits: {
|
||||
limited: true,
|
||||
exceeded: 'weekly',
|
||||
fiveHour: { used: 0.75, cap: 3, exceeded: false, resetAt: 0 },
|
||||
weekly: { used: 6.0293354601, cap: 6, exceeded: true, resetAt: RESET_MS },
|
||||
},
|
||||
}
|
||||
const subscriptionBody = { success: true, data: { status: 'active', planId: 'individual-go-v1', currentPeriodEnd: '2026-11-01T10:38:29.000Z' } }
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
describe('Command Code quota decoding', () => {
|
||||
it('maps the 5-hour and weekly windows as used of cap, clamped', () => {
|
||||
const quota = decodeCommandCodeCredits(creditsBody, 'individual-go-v1')
|
||||
expect(quota?.connection).toBe('connected')
|
||||
expect(quota?.details).toEqual([
|
||||
{ label: '5-hour', percent: 0.25, resetsAt: null },
|
||||
{ label: 'Weekly', percent: 1, resetsAt: new Date(RESET_MS).toISOString() },
|
||||
])
|
||||
expect(quota?.primary?.label).toBe('Weekly')
|
||||
expect(quota?.planLabel).toBe('Go')
|
||||
expect(quota?.notes).toEqual(['Credits left: $3.97 monthly'])
|
||||
})
|
||||
|
||||
it('skips an unreadable window and rejects a body with none', () => {
|
||||
const quota = decodeCommandCodeCredits({ windowLimits: { fiveHour: { used: 1, cap: 0 }, weekly: { used: 3, cap: 6, resetAt: 0 } } })
|
||||
expect(quota?.details).toEqual([{ label: 'Weekly', percent: 0.5, resetsAt: null }])
|
||||
expect(quota?.notes).toBeUndefined()
|
||||
expect(decodeCommandCodeCredits({ windowLimits: {} })).toBeNull()
|
||||
expect(decodeCommandCodeCredits({ credits: {} })).toBeNull()
|
||||
expect(decodeCommandCodeCredits(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('labels plans from their id', () => {
|
||||
expect(commandCodePlanLabel('individual-pro-v1')).toBe('Pro')
|
||||
expect(commandCodePlanLabel('team-max')).toBe('Team-max')
|
||||
expect(commandCodePlanLabel('')).toBeNull()
|
||||
expect(commandCodePlanLabel(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Command Code quota fetch', () => {
|
||||
it('is disconnected without an auth file and never fetches', async () => {
|
||||
const result = await fetchCommandCodeQuota({ authPath: '/nope', readFile: async () => null, fetch: neverFetch })
|
||||
expect(result.quota.connection).toBe('disconnected')
|
||||
})
|
||||
|
||||
it('sends the key as a bearer with a User-Agent to both billing endpoints', async () => {
|
||||
const seen: { url: string; headers: Record<string, string> }[] = []
|
||||
const result = await fetchCommandCodeQuota({
|
||||
authPath: '/auth.json', readFile: async () => AUTH,
|
||||
fetch: (async (url: string, init: RequestInit) => {
|
||||
seen.push({ url, headers: init.headers as Record<string, string> })
|
||||
return jsonResponse(url.endsWith('/credits') ? creditsBody : subscriptionBody)
|
||||
}) as unknown as typeof fetch,
|
||||
})
|
||||
expect(result.quota.connection).toBe('connected')
|
||||
expect(result.quota.planLabel).toBe('Go')
|
||||
expect(seen.map(row => row.url).sort()).toEqual([
|
||||
'https://api.commandcode.ai/alpha/billing/credits',
|
||||
'https://api.commandcode.ai/alpha/billing/subscriptions',
|
||||
])
|
||||
for (const row of seen) {
|
||||
expect(row.headers['Authorization']).toBe('Bearer synthetic-commandcode-key')
|
||||
expect(row.headers['User-Agent']).toBe('CodeBurn')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the windows when only the subscription call fails', async () => {
|
||||
const result = await fetchCommandCodeQuota({
|
||||
authPath: '/auth.json', readFile: async () => AUTH,
|
||||
fetch: (async (url: string) => url.endsWith('/credits') ? jsonResponse(creditsBody) : jsonResponse({}, 500)) as unknown as typeof fetch,
|
||||
})
|
||||
expect(result.quota.connection).toBe('connected')
|
||||
expect(result.quota.planLabel).toBeNull()
|
||||
})
|
||||
|
||||
it('maps HTTP failures', async () => {
|
||||
const respond = (status: number) => fetchCommandCodeQuota({
|
||||
authPath: '/auth.json', readFile: async () => AUTH,
|
||||
fetch: (async () => jsonResponse({}, status)) as unknown as typeof fetch,
|
||||
})
|
||||
const expired = await respond(401)
|
||||
expect(expired.quota.connection).toBe('terminalFailure')
|
||||
expect(expired.quota.footerLines).toEqual(['Command Code session expired. Sign in with the Command Code CLI again.'])
|
||||
expect((await respond(429)).quota.rateLimited).toBe(true)
|
||||
expect((await respond(503)).quota.connection).toBe('transientFailure')
|
||||
expect((await respond(200)).quota.footerLines).toEqual(['Command Code quota response was malformed.'])
|
||||
})
|
||||
|
||||
it('treats a network error as transient', async () => {
|
||||
const result = await fetchCommandCodeQuota({
|
||||
authPath: '/auth.json', readFile: async () => AUTH,
|
||||
fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch,
|
||||
})
|
||||
expect(result.quota.connection).toBe('transientFailure')
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
|
|||
import { getDateRange } from '../src/cli-date.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { setIncludeGatewayInTotals } from '../src/config.js'
|
||||
import { emptyCache, type DailyCache } from '../src/daily-cache.js'
|
||||
import { emptyCache, projectDayKey, type DailyCache } from '../src/daily-cache.js'
|
||||
import { renderOverview } from '../src/overview.js'
|
||||
import { aggregateSessions } from '../src/sessions-report.js'
|
||||
import { aggregateModels } from '../src/models-report.js'
|
||||
|
|
@ -417,7 +417,7 @@ describe('vercel-gateway: daily aggregates are shown but not totalled', () => {
|
|||
expect(left.providers['vercel-gateway']!.cost).toBeCloseTo(GATEWAY_COST, 10)
|
||||
expect(left.providers['vercel-gateway']!.calls).toBe(3)
|
||||
// The gateway's own synthetic project leaves the day's project split.
|
||||
expect(Object.keys(left.projects ?? {})).toEqual(['local-repo'])
|
||||
expect(Object.keys(left.projects ?? {})).toEqual([projectDayKey('local-repo', 'local-repo')])
|
||||
// A day with no slice for the provider is returned untouched.
|
||||
expect(excludeProviderFromDay(day!, 'nope')).toBe(day)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { discoverClaudeConfigSources, getClaudeConfigDirs, claude } from '../src
|
|||
import { createCodexProvider } from '../src/providers/codex.js'
|
||||
import { reconcileFile } from '../src/session-cache.js'
|
||||
import { parseAllSessions } from '../src/parser.js'
|
||||
import { createMistralVibeProvider } from '../src/providers/mistral-vibe.js'
|
||||
import type { DateRange } from '../src/types.js'
|
||||
|
||||
/** wsl.exe --list --quiet writes UTF-16LE with CRLF ends. */
|
||||
|
|
@ -15,7 +16,7 @@ function wslOutput(lines: string[], withBom = false): Buffer {
|
|||
return Buffer.from((withBom ? '\uFEFF' : '') + lines.join('\r\n') + '\r\n', 'utf16le')
|
||||
}
|
||||
|
||||
const ENV_KEYS = ['HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEX_HOME', 'CODEBURN_CACHE_DIR', 'CODEBURN_WSL'] as const
|
||||
const ENV_KEYS = ['HOME', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEBURN_DESKTOP_SESSIONS_DIR', 'CODEX_HOME', 'VIBE_HOME', 'CODEBURN_CACHE_DIR', 'CODEBURN_WSL'] as const
|
||||
let saved: Record<string, string | undefined>
|
||||
let tmpDir: string
|
||||
|
||||
|
|
@ -212,6 +213,32 @@ describe('WSL homes as extra provider roots', () => {
|
|||
.toEqual(expect.arrayContaining([join(winCodex, 'sessions'), join(wslHome, '.codex', 'sessions')]))
|
||||
})
|
||||
|
||||
it('discovers Vibe sessions in both layouts under a WSL home and reads its config.toml', async () => {
|
||||
const wslHome = join(tmpDir, 'wsl', 'ubuntu-me')
|
||||
process.env['VIBE_HOME'] = join(tmpDir, 'win', '.vibe')
|
||||
const root = join(wslHome, '.vibe', 'logs', 'session')
|
||||
const legacy = join(root, 'session_20990501_100000_abc')
|
||||
const unified = join(root, 'unified', 'sess-wsl')
|
||||
await mkdir(legacy, { recursive: true })
|
||||
await mkdir(unified, { recursive: true })
|
||||
await writeFile(join(legacy, 'meta.json'), JSON.stringify({ environment: { working_directory: '/home/me/legacy' } }))
|
||||
await writeFile(join(legacy, 'messages.jsonl'), '')
|
||||
await writeFile(join(unified, 'CURRENT'), '')
|
||||
setWslHomes([wslHome])
|
||||
|
||||
const provider = createMistralVibeProvider()
|
||||
expect((await provider.discoverSessions()).map(s => s.path))
|
||||
.toEqual(expect.arrayContaining([legacy, join(unified, 'CURRENT')]))
|
||||
expect((await provider.probeRoots!()).map(r => r.path))
|
||||
.toEqual([join(tmpDir, 'win', '.vibe', 'logs', 'session'), root])
|
||||
})
|
||||
|
||||
it('leaves an explicitly constructed Vibe provider scanning exactly its dir', async () => {
|
||||
setWslHomes([join(tmpDir, 'wsl', 'ubuntu-me')])
|
||||
const roots = await createMistralVibeProvider(join(tmpDir, 'fixture')).probeRoots!()
|
||||
expect(roots.map(r => r.path)).toEqual([join(tmpDir, 'fixture')])
|
||||
})
|
||||
|
||||
it('leaves an explicitly constructed Codex provider scanning exactly its dir', async () => {
|
||||
setWslHomes([join(tmpDir, 'wsl', 'ubuntu-me')])
|
||||
const roots = await createCodexProvider(join(tmpDir, 'fixture')).probeRoots!()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { AboutPane } from './settings/AboutPane'
|
|||
/// provider search, General and About, then one row per provider that has a live quota
|
||||
/// adapter, driving a detail pane on the right.
|
||||
|
||||
/// The twelve readers `codeburn quota` registers, in the CLI's own order. Only a fallback: the
|
||||
/// The fourteen readers `codeburn quota` registers, in the CLI's own order. Only a fallback: the
|
||||
/// quota store's answer is what the sidebar normally lists, so a provider the CLI grows
|
||||
/// appears here without a code change.
|
||||
const KNOWN_PROVIDERS: Array<{ id: string; name: string }> = [
|
||||
|
|
@ -31,6 +31,7 @@ const KNOWN_PROVIDERS: Array<{ id: string; name: string }> = [
|
|||
{ id: 'grok', name: 'Grok' },
|
||||
{ id: 'clinepass', name: 'ClinePass' },
|
||||
{ id: 'devin', name: 'Devin' },
|
||||
{ id: 'commandcode', name: 'Command Code' },
|
||||
]
|
||||
|
||||
const MAIN_PANES = ['general', 'about']
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type Glyph = {
|
|||
d: string | string[]
|
||||
transform?: string
|
||||
fillRule?: 'evenodd' | 'nonzero'
|
||||
/** Stroke width for a mark drawn as an outline rather than a fill. */
|
||||
stroke?: number
|
||||
}
|
||||
|
||||
const GLYPHS: Record<string, Glyph> = {
|
||||
|
|
@ -62,6 +64,11 @@ const GLYPHS: Record<string, Glyph> = {
|
|||
viewBox: '0 0 466.73 487.04',
|
||||
d: 'M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z',
|
||||
},
|
||||
commandcode: {
|
||||
viewBox: '0 0 100 100',
|
||||
d: 'M38 38H27C20.925 38 16 33.075 16 27C16 20.925 20.925 16 27 16C33.075 16 38 20.925 38 27V73C38 79.075 33.075 84 27 84C20.925 84 16 79.075 16 73C16 66.925 20.925 62 27 62H73C79.075 62 84 66.925 84 73C84 79.075 79.075 84 73 84C66.925 84 62 79.075 62 73V27C62 20.925 66.925 16 73 16C79.075 16 84 20.925 84 27C84 33.075 79.075 38 73 38H38V62',
|
||||
stroke: 9,
|
||||
},
|
||||
devin: {
|
||||
viewBox: "0 0 24 24",
|
||||
d: "M12 2.25c1.18 0 2.18.76 2.54 1.82a8.1 8.1 0 0 1 2.04.85 2.68 2.68 0 0 1 3.08.5 2.68 2.68 0 0 1 .5 3.08c.4.64.69 1.33.85 2.04A2.68 2.68 0 0 1 22.75 13c0 1.18-.76 2.18-1.82 2.54a8.1 8.1 0 0 1-.85 2.04 2.68 2.68 0 0 1-.5 3.08 2.68 2.68 0 0 1-3.08.5 8.1 8.1 0 0 1-2.04.85A2.68 2.68 0 0 1 12 23.75a2.68 2.68 0 0 1-2.54-1.82 8.1 8.1 0 0 1-2.04-.85 2.68 2.68 0 0 1-3.08-.5 2.68 2.68 0 0 1-.5-3.08 8.1 8.1 0 0 1-.85-2.04A2.68 2.68 0 0 1 1.25 13c0-1.18.76-2.18 1.82-2.54.16-.71.45-1.4.85-2.04a2.68 2.68 0 0 1 .5-3.08 2.68 2.68 0 0 1 3.08-.5c.64-.4 1.33-.69 2.04-.85A2.68 2.68 0 0 1 12 2.25Zm0 3.5a6.25 6.25 0 1 0 0 12.5 6.25 6.25 0 0 0 0-12.5Zm0 3.25a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z",
|
||||
|
|
@ -110,6 +117,7 @@ const ALIASES: Record<string, string> = {
|
|||
|
||||
'kilo-code': 'kilo',
|
||||
'ibm-bob': 'ibmbob',
|
||||
'command-code': 'commandcode',
|
||||
|
||||
kimicode: 'kimi',
|
||||
'cursor-agent': 'cursor',
|
||||
|
|
@ -197,8 +205,9 @@ export function ProviderGlyph({ id, size }: { id: string; size: number }) {
|
|||
key={d.slice(0, 24)}
|
||||
d={d}
|
||||
transform={glyph.transform}
|
||||
fill="currentColor"
|
||||
fillRule={glyph.fillRule ?? 'evenodd'}
|
||||
{...(glyph.stroke
|
||||
? { fill: 'none', stroke: 'currentColor', strokeWidth: glyph.stroke, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const }
|
||||
: { fill: 'currentColor', fillRule: glyph.fillRule ?? 'evenodd' })}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ const GUIDANCE: Record<string, string> = {
|
|||
cursor: 'Sign in to the Cursor app, then click Retry.',
|
||||
zai: 'Sign in with the Pi CLI, or set ZAI_API_KEY, then click Retry.',
|
||||
grok: 'Sign in with the Grok CLI, then click Retry.',
|
||||
clinepass: 'Set CLINEPASS_API_KEY, then click Retry.',
|
||||
clinepass: 'Sign in with Cline (run `cline auth`), or set CLINEPASS_API_KEY, then click Retry.',
|
||||
devin: 'Run the Devin CLI once to sign in, then click Retry.',
|
||||
commandcode: 'Sign in with the Command Code CLI, then click Retry.',
|
||||
}
|
||||
|
||||
/// The mac's "How it works" sections, with the Windows paths. Every one of these is
|
||||
|
|
@ -43,8 +44,9 @@ const HOW_IT_WORKS: Record<string, string> = {
|
|||
cursor: 'Cursor quota opens the Cursor editor state database read-only for its access token, then asks cursor.com. Nothing is written back, so an expired token can only be refreshed by signing in to Cursor again.',
|
||||
zai: 'Z.ai quota uses a supplied API key if there is one, and otherwise the Z.ai login the Pi CLI keeps in %USERPROFILE%\\.pi\\agent\\auth.json.',
|
||||
grok: 'Grok Build quota reads %USERPROFILE%\\.grok\\auth.json, preferring the current OIDC scope over an older sign-in entry.',
|
||||
clinepass: 'ClinePass has no local login file, so the only credential is an API key.',
|
||||
clinepass: 'ClinePass quota uses CLINEPASS_API_KEY or CLINE_API_KEY if set, and otherwise the Cline sign-in in %USERPROFILE%\\.cline\\data\\settings\\providers.json, read-only. Only Cline refreshes that sign-in, so if it shows as expired, run cline once and click Retry.',
|
||||
devin: 'Devin quota reads the plan status the Devin CLI caches in %USERPROFILE%\\.cache\\devin\\cli, read-only. Nothing is sent anywhere and no API key is used, so the numbers are as fresh as the CLI\'s last run.',
|
||||
commandcode: 'Command Code quota reads the API key the Command Code CLI keeps in %USERPROFILE%\\.commandcode\\auth.json, read-only, and asks Command Code for the 5-hour and weekly windows and the credits left.',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue