diff --git a/packages/types/src/__tests__/history.test.ts b/packages/types/src/__tests__/history.test.ts index 883bb138d2..991a991b33 100644 --- a/packages/types/src/__tests__/history.test.ts +++ b/packages/types/src/__tests__/history.test.ts @@ -64,3 +64,38 @@ describe("historyItemSchema", () => { expect(historyItemSchema.parse(baseItem)).not.toHaveProperty("title") }) }) + +describe("usageByModel on history items", () => { + const base = { id: "task-1", number: 1, ts: 1, task: "t", tokensIn: 1, tokensOut: 1, totalCost: 0 } + + it("preserves every field of a per-model entry", () => { + const entry = { + apiProvider: "provider-a", + modelId: "model-a", + requests: 3, + tokensIn: 100, + tokensOut: 50, + cacheReads: 40, + cacheWrites: 10, + cost: 0.25, + } + expect(historyItemSchema.parse({ ...base, usageByModel: [entry, { ...entry, modelId: "model-b" }] })).toEqual({ + ...base, + usageByModel: [entry, { ...entry, modelId: "model-b" }], + }) + }) + + it("accepts an entry with no provider, model or cache counts, and an item with no breakdown", () => { + const bare = { requests: 1, tokensIn: 2, tokensOut: 3, cost: 0 } + expect(historyItemSchema.parse({ ...base, usageByModel: [bare] }).usageByModel).toEqual([bare]) + expect(historyItemSchema.parse(base).usageByModel).toBeUndefined() + }) + + it.each([ + ["a missing request count", { tokensIn: 1, tokensOut: 1, cost: 0 }], + ["a non-numeric cost", { requests: 1, tokensIn: 1, tokensOut: 1, cost: "free" }], + ["a non-numeric token count", { requests: 1, tokensIn: "1", tokensOut: 1, cost: 0 }], + ])("rejects %s", (_name, entry) => { + expect(historyItemSchema.safeParse({ ...base, usageByModel: [entry] }).success).toBe(false) + }) +}) diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 131194212e..21cef89e47 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -26,6 +26,23 @@ export const pendingTaskActionSchema = z.discriminatedUnion("kind", [ export type PendingTaskAction = z.infer +/** + * Token/cost totals for one provider+model within a task. A task can span + * several (the user may switch profiles mid-task), so history keeps a list. + */ +export const usageByModelEntrySchema = z.object({ + apiProvider: z.string().optional(), + modelId: z.string().optional(), + requests: z.number(), + tokensIn: z.number(), + tokensOut: z.number(), + cacheReads: z.number().optional(), + cacheWrites: z.number().optional(), + cost: z.number(), +}) + +export type UsageByModelEntry = z.infer + export const historyItemSchema = z.object({ id: z.string(), rootTaskId: z.string().optional(), @@ -39,6 +56,8 @@ export const historyItemSchema = z.object({ cacheWrites: z.number().optional(), cacheReads: z.number().optional(), totalCost: z.number(), + /** Per provider+model breakdown, recorded as requests happen. Absent on tasks created before this was tracked. */ + usageByModel: z.array(usageByModelEntrySchema).optional(), size: z.number().optional(), workspace: z.string().optional(), mode: z.string().optional(), diff --git a/packages/types/src/providers/vscode-llm.ts b/packages/types/src/providers/vscode-llm.ts index c082b4fbfe..3ccb610f76 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -11,8 +11,71 @@ export const vsCodeLmModelsMessageTypes = [ "githubCopilotManageAccount", "githubCopilotSignInResult", "githubCopilotModels", + "requestProviderQuotas", + "cancelProviderQuotas", + "providerQuotas", ] as const +/** + * One limit/balance a provider reports (e.g. Copilot premium credits for the + * month, an OpenRouter key limit, a DeepSeek prepaid balance). + */ +export interface ProviderQuotaWindow { + /** Stable key, e.g. "premium", "key_limit", "balance". */ + id: string + /** Pre-formatted primary figure, e.g. "39,389.3 / 140,000 used", "$12.40 left". */ + valueLabel: string + /** + * Set when the label is just the name of a fixed window ("Weekly", "5-hour", "Unlimited"), so the webview can + * show it in the user's language. `valueLabel` stays as the English fallback. + */ + name?: { key: "weekly" | "daily" | "monthly" | "unlimited" | "hours" | "days"; count?: number } + /** 0–100 when the provider reports a bounded allowance. */ + usedPercent?: number + /** Epoch ms. */ + resetAt?: number +} + +export type ProviderQuota = + | { + providerId: string + ok: true + plan?: string + windows: ProviderQuotaWindow[] + fetchedAt: number + } + | { + providerId: string + ok: false + reason: "signedOut" | "noCopilot" | "noKey" | "unsupported" | "error" + message?: string + fetchedAt: number + } + +/** + * The signed-in account's GitHub Copilot premium-request allowance, as reported by + * `GET /copilot_internal/user` (the same source Copilot Chat's own usage panel reads). + */ +export interface GitHubCopilotQuota { + /** e.g. "business", "individual". */ + plan?: string + unlimited: boolean + /** Total credits for the period; undefined when unlimited or not reported. */ + entitlement?: number + /** Credits used this period, `entitlement - quota_remaining` (one decimal, like GitHub's panel). */ + used?: number + /** 0–100. */ + usedPercent?: number + /** Epoch ms when the allowance resets. */ + resetAt?: number + /** Epoch ms when this snapshot was fetched. */ + fetchedAt: number +} + +export type GitHubCopilotQuotaResult = + | { ok: true; quota: GitHubCopilotQuota } + | { ok: false; reason: "signedOut" | "noCopilot" | "error"; message?: string } + export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes) export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index dc407325d9..e2808ebce0 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -16,7 +16,7 @@ import { RouterModelsMessageType, type ModelInfo, type ModelRecord, type RouterM import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" -import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js" +import { VsCodeLmModelsMessageType, type ProviderQuota } from "./providers/vscode-llm.js" import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js" import type { SkillMetadata } from "./skills.js" import type { RuleMetadata } from "./rules.js" @@ -50,6 +50,7 @@ export interface ExtensionMessage { | typeof VsCodeLmModelsMessageType.vsCodeLmModels | typeof VsCodeLmModelsMessageType.githubCopilotModels | typeof VsCodeLmModelsMessageType.githubCopilotSignInResult + | typeof VsCodeLmModelsMessageType.providerQuotas | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -158,6 +159,9 @@ export interface ExtensionMessage { modelInfo?: ModelInfo }[] githubCopilotAccount?: string | null + providerQuotas?: ProviderQuota[] + /** True when the quotas could not be loaded at all (as opposed to "no provider has a usage API"). */ + providerQuotasFailed?: boolean mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ProviderSettingsEntry[] @@ -516,6 +520,8 @@ export interface WebviewMessage { | typeof VsCodeLmModelsMessageType.githubCopilotSignIn | typeof VsCodeLmModelsMessageType.githubCopilotReconnect | typeof VsCodeLmModelsMessageType.githubCopilotManageAccount + | typeof VsCodeLmModelsMessageType.requestProviderQuotas + | typeof VsCodeLmModelsMessageType.cancelProviderQuotas | "openImage" | "saveImage" | "openFile" @@ -951,6 +957,13 @@ export interface ClineApiReqInfo { cancelReason?: ClineApiReqCancelReason streamingFailedMessage?: string apiProtocol?: "anthropic" | "openai" + /** + * The provider and model that actually served this request, recorded at the + * time of the call. Usage reporting reads these instead of the *current* + * profile, so switching providers later doesn't relabel past requests. + */ + apiProvider?: string + modelId?: string } export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts index d35dfc49cf..03b1bd694c 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.realConcurrency.spec.ts @@ -2,7 +2,7 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import type { HistoryItem } from "@roo-code/types" +import { providerIdentifiers, type HistoryItem } from "@roo-code/types" import { TaskHistoryStore } from "../TaskHistoryStore" @@ -119,6 +119,80 @@ describe("TaskHistoryStore real cross-host locking", () => { } }) + it("clears a stored usageByModel when the same task is saved again without one (e.g. after a rewind)", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-usage-clear-")) + const store = new TaskHistoryStore(storagePath) + const usageByModel = [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "m", + requests: 1, + tokensIn: 5, + tokensOut: 5, + cost: 1, + }, + ] + + try { + await store.initialize() + await store.upsert({ ...item("usage-task"), usageByModel }) + expect(store.get("usage-task")?.usageByModel).toEqual(usageByModel) + + // What taskMetadata emits when the replaced messages record no usage. + await store.upsert({ ...item("usage-task"), usageByModel: undefined }) + expect(store.get("usage-task")?.usageByModel).toBeUndefined() + + // ...and the stored file no longer carries the stale breakdown either. + await store.invalidate("usage-task") + expect(store.get("usage-task")).toBeDefined() + expect(store.get("usage-task")?.usageByModel).toBeUndefined() + } finally { + store.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + + it("clears a usageByModel that another host wrote, when this host saves the task without one", async () => { + const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-usage-clear-other-")) + const storeA = new TaskHistoryStore(storagePath) + const storeB = new TaskHistoryStore(storagePath) + const usageByModel = [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "m", + requests: 1, + tokensIn: 5, + tokensOut: 5, + cost: 1, + }, + ] + + try { + await storeA.initialize() + await storeA.upsert(item("shared-usage-task")) // A's cache has no usageByModel + await storeB.initialize() + await storeB.upsert({ ...item("shared-usage-task"), usageByModel }) // another host records usage on disk + + // The setup really happened: the usage is on disk, and this host's cache does not have it yet. + const file = path.join(storagePath, "tasks", "shared-usage-task", "history_item.json") + expect(JSON.parse(await fs.readFile(file, "utf8")).usageByModel).toEqual(usageByModel) + expect(storeA.get("shared-usage-task")?.usageByModel).toBeUndefined() + + // This host saves the task after its messages were replaced: no usage, so none must remain on disk. + await storeA.upsert({ ...item("shared-usage-task"), usageByModel: undefined }) + + expect(JSON.parse(await fs.readFile(file, "utf8")).usageByModel).toBeUndefined() + await storeA.invalidate("shared-usage-task") + expect(storeA.get("shared-usage-task")?.usageByModel).toBeUndefined() + await storeB.invalidate("shared-usage-task") + expect(storeB.get("shared-usage-task")?.usageByModel).toBeUndefined() + } finally { + storeA.dispose() + storeB.dispose() + await fs.rm(storagePath, { recursive: true, force: true }) + } + }) + it("reports a bounded error when one store never reaches the write barrier", async () => { const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missed-barrier-")) const storeA = new TaskHistoryStore(storagePath) diff --git a/src/core/task-persistence/__tests__/taskMetadata.usageByModel.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.usageByModel.spec.ts new file mode 100644 index 0000000000..dc98a88536 --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.usageByModel.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest" + +import { providerIdentifiers, type ClineMessage } from "@roo-code/types" + +vi.mock("get-folder-size", () => ({ default: { loose: vi.fn(async () => 1234) } })) +vi.mock("../../../utils/storage", () => ({ getTaskDirectoryPath: vi.fn(async () => "/tmp/task") })) +vi.mock("../../../i18n", () => ({ t: (k: string) => k })) + +import { taskMetadata } from "../taskMetadata" + +const say = (say: ClineMessage["say"], text: string, ts: number): ClineMessage => ({ ts, type: "say", say, text }) + +const base = { + taskId: "t1", + taskNumber: 1, + globalStoragePath: "/tmp", + workspace: "/ws", +} + +describe("taskMetadata – usageByModel", () => { + it("records per provider+model usage on the history item", async () => { + const messages = [ + say("text", "do the thing", 1), + say( + "api_req_started", + JSON.stringify({ + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + tokensIn: 900, + tokensOut: 90, + cost: 0, + }), + 2, + ), + say( + "api_req_started", + JSON.stringify({ + apiProvider: providerIdentifiers.gemini, + modelId: "gemini-pro", + tokensIn: 100, + tokensOut: 10, + cost: 0.2, + }), + 3, + ), + ] + const { historyItem } = await taskMetadata({ ...base, messages }) + + expect(historyItem.usageByModel).toEqual([ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + requests: 1, + tokensIn: 900, + tokensOut: 90, + cost: 0, + }, + { + apiProvider: providerIdentifiers.gemini, + modelId: "gemini-pro", + requests: 1, + tokensIn: 100, + tokensOut: 10, + cost: 0.2, + }, + ]) + // Existing totals are unchanged by the new breakdown. + expect(historyItem.tokensIn).toBe(1000) + expect(historyItem.totalCost).toBeCloseTo(0.2) + }) + + it("leaves usageByModel undefined when nothing was recorded, so a stored breakdown is cleared", async () => { + const { historyItem } = await taskMetadata({ ...base, messages: [say("text", "hello", 1)] }) + expect(historyItem).toHaveProperty("usageByModel", undefined) + // Nothing is written for it: the stored shape of older tasks is unchanged. + expect(JSON.parse(JSON.stringify(historyItem))).not.toHaveProperty("usageByModel") + }) + + it("leaves usageByModel undefined for a task with no messages", async () => { + const { historyItem } = await taskMetadata({ ...base, messages: [] }) + expect(historyItem).toHaveProperty("usageByModel", undefined) + expect(historyItem.tokensIn).toBe(0) + }) +}) diff --git a/src/core/task-persistence/__tests__/taskStoreConcurrency.spec.ts b/src/core/task-persistence/__tests__/taskStoreConcurrency.spec.ts index 419ba03cb4..7966bb8e63 100644 --- a/src/core/task-persistence/__tests__/taskStoreConcurrency.spec.ts +++ b/src/core/task-persistence/__tests__/taskStoreConcurrency.spec.ts @@ -1,6 +1,6 @@ import type { HistoryItem } from "@roo-code/types" -import { DeltaRejectedError, mergeHistoryDelta } from "../taskStoreConcurrency" +import { computeHistoryDelta, DeltaRejectedError, mergeHistoryDelta } from "../taskStoreConcurrency" function item(status: HistoryItem["status"]): HistoryItem { return { @@ -34,3 +34,50 @@ describe("mergeHistoryDelta", () => { expect(mergeHistoryDelta(disk, incoming, { id: disk.id, status: undefined }).status).toBe("active") }) }) + +describe("computeHistoryDelta", () => { + it("leaves out a field that equals the cached one", () => { + expect(computeHistoryDelta(item("active"), { mode: "code", tokensIn: 0 } as Partial)).toEqual({ + mode: "code", + }) + }) + + it("always carries an explicit undefined usageByModel, even when the cache has none either", () => { + const delta = computeHistoryDelta(item("active"), { usageByModel: undefined, tokensIn: 0 }) + + expect(Object.keys(delta)).toEqual(["usageByModel"]) + }) + + it("still carries a usageByModel that changed, and leaves out one that did not", () => { + const usage = [{ apiProvider: "p", modelId: "m", requests: 1, tokensIn: 1, tokensOut: 1, cost: 0 }] + const cached = { ...item("active"), usageByModel: usage } + + expect(computeHistoryDelta(cached, { usageByModel: usage })).toEqual({}) + expect(computeHistoryDelta(cached, { usageByModel: [] })).toEqual({ usageByModel: [] }) + }) + + it("omits an undefined other field when the cache has no value for it either", () => { + expect(computeHistoryDelta(item("active"), { mode: undefined })).toEqual({}) + }) + + it("carries an undefined other field as an ordinary change when the cache has a value for it", () => { + const cached = { ...item("active"), mode: "code" } + + expect(computeHistoryDelta(cached, { mode: undefined })).toEqual({ mode: undefined }) + }) + + it("does not keep an undefined other field on every save: only usageByModel is a standing decision", () => { + const delta = computeHistoryDelta(item("active"), { mode: undefined, usageByModel: undefined }) + + expect(Object.keys(delta)).toEqual(["usageByModel"]) + }) + + it("lets mergeHistoryDelta clear the usage another host wrote", () => { + const onDisk = { ...item("active"), usageByModel: [{ requests: 1 }] } + const delta = computeHistoryDelta(item("active"), { usageByModel: undefined }) + + const merged = mergeHistoryDelta(onDisk, item("active"), delta) + + expect(JSON.parse(JSON.stringify(merged)).usageByModel).toBeUndefined() + }) +}) diff --git a/src/core/task-persistence/__tests__/usageByModel.spec.ts b/src/core/task-persistence/__tests__/usageByModel.spec.ts new file mode 100644 index 0000000000..a75b761d9f --- /dev/null +++ b/src/core/task-persistence/__tests__/usageByModel.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest" + +import { providerIdentifiers, type ClineMessage } from "@roo-code/types" + +import { bindRequestToHandler, resolveRecordedModelId, summarizeUsageByModel } from "../usageByModel" + +const req = (data: Record, ts = 1): ClineMessage => ({ + ts, + type: "say", + say: "api_req_started", + text: JSON.stringify(data), +}) + +describe("summarizeUsageByModel", () => { + it("groups requests by the provider+model recorded on each request", () => { + const result = summarizeUsageByModel([ + req({ + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + tokensIn: 1000, + tokensOut: 100, + cost: 0, + }), + req({ + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + tokensIn: 500, + tokensOut: 50, + cost: 0, + }), + req({ + apiProvider: providerIdentifiers.gemini, + modelId: "gemini-3.1-pro-preview", + tokensIn: 200, + tokensOut: 20, + cost: 0.4, + }), + ]) + + expect(result).toEqual([ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + requests: 2, + tokensIn: 1500, + tokensOut: 150, + cost: 0, + }, + { + apiProvider: providerIdentifiers.gemini, + modelId: "gemini-3.1-pro-preview", + requests: 1, + tokensIn: 200, + tokensOut: 20, + cost: 0.4, + }, + ]) + }) + + it("keeps a task that switched providers mid-way split by what actually served each request", () => { + const result = summarizeUsageByModel([ + req({ + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + tokensIn: 100, + tokensOut: 10, + }), + req({ apiProvider: providerIdentifiers.gemini, modelId: "gemini-pro", tokensIn: 300, tokensOut: 30 }), + ]) + expect(result.map((r) => r.apiProvider)).toEqual([ + providerIdentifiers.githubCopilot, + providerIdentifiers.gemini, + ]) + }) + + it("sums cache counters only when reported", () => { + const [entry] = summarizeUsageByModel([ + req({ + apiProvider: providerIdentifiers.anthropic, + modelId: "m", + tokensIn: 1, + tokensOut: 1, + cacheReads: 5, + cacheWrites: 2, + }), + req({ apiProvider: providerIdentifiers.anthropic, modelId: "m", tokensIn: 1, tokensOut: 1, cacheReads: 3 }), + ]) + expect(entry.cacheReads).toBe(8) + expect(entry.cacheWrites).toBe(2) + + const [bare] = summarizeUsageByModel([req({ apiProvider: "a", modelId: "m", tokensIn: 1, tokensOut: 1 })]) + expect(bare).not.toHaveProperty("cacheReads") + }) + + it("puts requests recorded before this was tracked in an unrecorded entry rather than guessing", () => { + const [entry] = summarizeUsageByModel([req({ apiProtocol: "openai", tokensIn: 10, tokensOut: 5, cost: 0 })]) + expect(entry).toMatchObject({ apiProvider: undefined, modelId: undefined, requests: 1, tokensIn: 10 }) + }) + + it("ignores in-flight placeholders and non-request messages", () => { + const result = summarizeUsageByModel([ + req({ apiProtocol: "openai", apiProvider: providerIdentifiers.gemini, modelId: "m" }), // no counts yet + { ts: 2, type: "say", say: "text", text: "hello" }, + { ts: 3, type: "say", say: "api_req_started", text: "not json" }, + { ts: 4, type: "say", say: "api_req_started" }, + ]) + expect(result).toEqual([]) + }) + + it("returns an empty list for no messages", () => { + expect(summarizeUsageByModel([])).toEqual([]) + }) +}) + +describe("summarizeUsageByModel with stored text that is not a request object", () => { + const msg = (text: string) => + ({ ts: 1, type: "say", say: "api_req_started", text }) as unknown as Parameters< + typeof summarizeUsageByModel + >[0][number] + + it.each(["null", "42", '"text"', "true", "[]"])("skips %s and keeps reading the other messages", (text) => { + const summary = summarizeUsageByModel([ + msg(text), + msg(JSON.stringify({ apiProvider: "p", modelId: "m", tokensIn: 5, tokensOut: 2, cost: 0.5 })), + ]) + + expect(summary).toEqual([{ apiProvider: "p", modelId: "m", requests: 1, tokensIn: 5, tokensOut: 2, cost: 0.5 }]) + }) +}) + +describe("resolveRecordedModelId", () => { + it("keeps a model that was recorded when the request started", () => { + expect(resolveRecordedModelId("gpt-5", "gpt-5-mini")).toBe("gpt-5") + }) + + it("uses the model that served the request when the profile named none", () => { + expect(resolveRecordedModelId(undefined, "claude-sonnet-5")).toBe("claude-sonnet-5") + expect(resolveRecordedModelId("", "claude-sonnet-5")).toBe("claude-sonnet-5") + expect(resolveRecordedModelId(null, "claude-sonnet-5")).toBe("claude-sonnet-5") + }) + + it("stays unrecorded when neither is known", () => { + expect(resolveRecordedModelId(undefined, undefined)).toBeUndefined() + }) + + it("ignores a recorded value that is not a string", () => { + expect(resolveRecordedModelId(42, "claude-sonnet-5")).toBe("claude-sonnet-5") + }) +}) + +describe("bindRequestToHandler", () => { + const placeholder = JSON.stringify({ apiProtocol: "openai", apiProvider: "old-provider", modelId: "old-model" }) + + it("records the provider of the handler that serves the request, over the one the placeholder was written with", () => { + const bound = JSON.parse( + bindRequestToHandler(placeholder, { + apiProvider: "new-provider", + configuredModelId: "new-model", + servedModelId: "served-model", + }), + ) + expect(bound).toEqual({ apiProtocol: "openai", apiProvider: "new-provider", modelId: "new-model" }) + }) + + it("records the model the handler reports when the profile names none", () => { + const bound = JSON.parse( + bindRequestToHandler(placeholder, { + apiProvider: "p", + configuredModelId: undefined, + servedModelId: "served-model", + }), + ) + expect(bound.modelId).toBe("served-model") + }) + + it.each([[undefined], [""], ["not json"]])("starts from nothing when the placeholder is %j", (text) => { + const bound = JSON.parse( + bindRequestToHandler(text as string | undefined, { + apiProvider: "p", + configuredModelId: "m", + servedModelId: undefined, + }), + ) + expect(bound).toEqual({ apiProvider: "p", modelId: "m" }) + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index c52b0bdc33..c5a6af102f 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -5,6 +5,7 @@ import type { ClineMessage, HistoryItem } from "@roo-code/types" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" +import { summarizeUsageByModel } from "./usageByModel" import { getApiMetrics } from "../../shared/getApiMetrics" import { findLastIndex } from "../../shared/array" import { getTaskDirectoryPath } from "../../utils/storage" @@ -89,6 +90,8 @@ export async function taskMetadata({ } } + const usageByModel = hasMessages ? summarizeUsageByModel(messages) : [] + // Create historyItem once with pre-calculated values. // initialStatus is included when provided (e.g., "active" for child tasks) // to ensure the status is set from the very first save, avoiding race conditions @@ -107,6 +110,11 @@ export async function taskMetadata({ cacheWrites: tokenUsage.totalCacheWrites, cacheReads: tokenUsage.totalCacheReads, totalCost: tokenUsage.totalCost, + // Always present, as `undefined` when nothing was recorded: the history store merges onto the stored + // item, so omitting the key would keep a stale breakdown after the messages were replaced (e.g. a + // rewind). `undefined` overrides it and is dropped when the item is written, so the stored shape of + // older and empty tasks is unchanged. + usageByModel: usageByModel.length > 0 ? usageByModel : undefined, size: taskDirSize, workspace, mode, diff --git a/src/core/task-persistence/taskStoreConcurrency.ts b/src/core/task-persistence/taskStoreConcurrency.ts index 0f747c4cb2..49ce70efc4 100644 --- a/src/core/task-persistence/taskStoreConcurrency.ts +++ b/src/core/task-persistence/taskStoreConcurrency.ts @@ -14,9 +14,21 @@ export class DeltaRejectedError extends Error { } } +/** + * Fields whose `undefined` is a decision, not an absence: "this task has none". The delta normally leaves out a field + * that equals the cached one, but the cache can lack a value that another host wrote to disk, so an unchanged + * `undefined` would leave that stale value in place. For these fields an explicit `undefined` is always part of the + * delta, and `mergeHistoryDelta` applies it under the file lock (the key is dropped when the item is written). + */ +const EXPLICIT_CLEAR_KEYS: ReadonlySet = new Set(["usageByModel"]) + export function computeHistoryDelta(cached: HistoryItem, incoming: Partial): Partial { return Object.fromEntries( - Object.entries(incoming).filter(([key, value]) => !deepEqual(value, (cached as Record)[key])), + Object.entries(incoming).filter( + ([key, value]) => + (value === undefined && EXPLICIT_CLEAR_KEYS.has(key)) || + !deepEqual(value, (cached as Record)[key]), + ), ) as Partial } diff --git a/src/core/task-persistence/usageByModel.ts b/src/core/task-persistence/usageByModel.ts new file mode 100644 index 0000000000..a747e806ab --- /dev/null +++ b/src/core/task-persistence/usageByModel.ts @@ -0,0 +1,93 @@ +import type { ClineMessage, UsageByModelEntry } from "@roo-code/types" + +/** + * Totals per provider+model from a task's `api_req_started` messages. + * + * Provider and model are read from the message itself (recorded when the + * request was made), never from the *current* profile, so a task that ran on + * Copilot keeps saying so after the user switches providers. A task can span + * several models, hence a list. + * + * Messages written before this was recorded carry no provider/model; their + * tokens are grouped under an entry with both fields undefined ("not + * recorded") rather than guessed. + */ +export function summarizeUsageByModel(messages: ClineMessage[]): UsageByModelEntry[] { + const byKey = new Map() + + for (const message of messages) { + if (message.type !== "say" || message.say !== "api_req_started" || !message.text) continue + + let parsed: Record + try { + parsed = JSON.parse(message.text) + } catch { + continue + } + + // Valid JSON that is not an object ("null", a number, a string) has no fields to read. + if (typeof parsed !== "object" || parsed === null) continue + + // A placeholder (request not finished) has no token counts yet. + if (typeof parsed.tokensIn !== "number" && typeof parsed.tokensOut !== "number") continue + + const apiProvider = typeof parsed.apiProvider === "string" ? parsed.apiProvider : undefined + const modelId = typeof parsed.modelId === "string" ? parsed.modelId : undefined + const key = `${apiProvider ?? ""}\u0000${modelId ?? ""}` + + let entry = byKey.get(key) + if (!entry) { + entry = { apiProvider, modelId, requests: 0, tokensIn: 0, tokensOut: 0, cost: 0 } + byKey.set(key, entry) + } + + const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0) + entry.requests += 1 + entry.tokensIn += num(parsed.tokensIn) + entry.tokensOut += num(parsed.tokensOut) + entry.cost += num(parsed.cost) + if (typeof parsed.cacheReads === "number") entry.cacheReads = (entry.cacheReads ?? 0) + parsed.cacheReads + if (typeof parsed.cacheWrites === "number") entry.cacheWrites = (entry.cacheWrites ?? 0) + parsed.cacheWrites + } + + return [...byKey.values()] +} + +/** + * The model to record for a finished request. The first write (before the request starts) only knows the + * model the profile names; a profile that names none leaves it empty. By the time the request is done the + * model that actually served it is known, so use that rather than leave the usage unattributed. A model + * recorded earlier is never overwritten. + */ +export function resolveRecordedModelId(recorded: unknown, served: string | undefined): string | undefined { + return typeof recorded === "string" && recorded.length > 0 ? recorded : served +} + +/** + * Binds a request's placeholder `api_req_started` to the handler that serves it. The placeholder is written before + * the rate-limit wait and the environment details, and a profile switch in that window replaces the handler, so the + * provider and model written then can differ from the handler whose tokens and cost are recorded at the end. Called + * once the serving handler is known. The configured model is kept when the profile names one; otherwise the model the + * handler reports is recorded. + */ +export function bindRequestToHandler( + text: string | undefined, + handler: { + apiProvider: string | undefined + configuredModelId: string | undefined + servedModelId: string | undefined + }, +): string { + let existing: Record = {} + try { + existing = JSON.parse(text || "{}") + } catch { + // An unreadable placeholder is replaced, not kept. + } + + return JSON.stringify({ + ...existing, + apiProvider: handler.apiProvider, + modelId: resolveRecordedModelId(handler.configuredModelId, handler.servedModelId), + }) +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 3202358c09..a2149d0003 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -124,6 +124,7 @@ import { saveTaskMessages, taskMetadata, } from "../task-persistence" +import { bindRequestToHandler, resolveRecordedModelId } from "../task-persistence/usageByModel" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -1768,8 +1769,12 @@ export class Task extends EventEmitter implements TaskLike { * to replace the stored messages outright. */ private async saveClineMessages(merge = true): Promise { + // What was actually persisted: with `merge`, newer messages another host wrote are folded in. The metadata + // (including the usage breakdown) is computed from it, so a stale local snapshot cannot clear usage that + // the stored messages still contain. + let persistedMessages: ClineMessage[] | undefined try { - await saveTaskMessages({ + persistedMessages = await saveTaskMessages({ messages: structuredClone(this.clineMessages), taskId: this.taskId, globalStoragePath: this.globalStoragePath, @@ -1790,7 +1795,7 @@ export class Task extends EventEmitter implements TaskLike { rootTaskId: this.rootTaskId, parentTaskId: this.parentTaskId, taskNumber: this.taskNumber, - messages: this.clineMessages, + messages: persistedMessages ?? this.clineMessages, globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. @@ -3937,6 +3942,9 @@ export class Task extends EventEmitter implements TaskLike { "api_req_started", JSON.stringify({ apiProtocol, + // Recorded now so Usage reflects what served this request, not the profile selected later. + apiProvider, + modelId, }), ) @@ -4014,6 +4022,8 @@ export class Task extends EventEmitter implements TaskLike { this.clineMessages[lastApiReqIndex].text = JSON.stringify({ apiProtocol, + apiProvider, + modelId, } satisfies ClineApiReqInfo) await this.saveClineMessages() @@ -4025,6 +4035,9 @@ export class Task extends EventEmitter implements TaskLike { let inputTokens = 0 let outputTokens = 0 let totalCost: number | undefined + // The model that served this request, captured when it was chosen: a later request replaces + // `cachedStreamingModel` while this one's usage is still being collected in the background. + const served: { modelId?: string } = {} // We can't use `api_req_finished` anymore since it's a unique case // where it could come after a streaming message (i.e. in the middle @@ -4067,6 +4080,7 @@ export class Task extends EventEmitter implements TaskLike { this.clineMessages[lastApiReqIndex].text = JSON.stringify({ ...existingData, + modelId: resolveRecordedModelId(existingData.modelId, served.modelId), tokensIn: costResult.totalInputTokens, tokensOut: costResult.totalOutputTokens, cacheWrites: cacheWriteTokens, @@ -4136,6 +4150,18 @@ export class Task extends EventEmitter implements TaskLike { // This is especially important for tools and background usage collection this.cachedStreamingModel = this.api.getModel() const streamModelInfo = this.cachedStreamingModel.info + served.modelId = this.cachedStreamingModel.id + + // The handler that serves this request is known now. The placeholder was written before the waits + // above, so bring its provider and model in line with this handler. + this.clineMessages[lastApiReqIndex].text = bindRequestToHandler( + this.clineMessages[lastApiReqIndex].text, + { + apiProvider: this.apiConfiguration.apiProvider, + configuredModelId: getModelId(this.apiConfiguration), + servedModelId: served.modelId, + }, + ) // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index bbf77a85e9..53bfdb7db8 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2113,6 +2113,43 @@ describe("Cline", () => { await loop await vi.waitFor(() => expect(oldDispose).toHaveBeenCalledOnce()) }) + + it.each([ + { + name: "fills in the serving model when the profile named none", + profile: undefined, + expected: "served-model", + }, + { name: "keeps the model the profile named", profile: "profile-model", expected: "profile-model" }, + ])("finalized api_req_started $name", async ({ profile, expected }) => { + const task = await createTaskWithManualRetries() + task.apiConfiguration = { + ...task.apiConfiguration, + apiProvider: providerIdentifiers.anthropic, + apiModelId: profile, + } + disposableApi(task) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: "served-model", + info: { supportsPromptCache: true, contextWindow: 200_000, maxTokens: 4096 } as ModelInfo, + }) + vi.spyOn(getTaskTestAccess(task), "attemptApiRequestWithActiveApi").mockImplementation( + async function* () { + yield { type: "text", text: "a" } + yield { type: "usage", inputTokens: 1, outputTokens: 1 } + }, + ) + realRequestsThenStop(task) + vi.spyOn(console, "error").mockImplementation(() => {}) + + await task.recursivelyMakeClineRequests([{ type: "text", text: "go" }]) + + const started = task.clineMessages.find((m) => m.say === "api_req_started") + const payload = JSON.parse(started?.text ?? "{}") + expect(payload.apiProvider).toBe(providerIdentifiers.anthropic) + expect(payload.modelId).toBe(expected) + expect(payload.tokensIn).toBe(1) + }) }) it("disposes the current handler exactly once even when dispose is called twice", async () => { @@ -3489,6 +3526,76 @@ describe("Cline", () => { }) }) + describe("api_req_started usage attribution", () => { + /** Runs the request setup and returns the JSON the first api_req_started message was written with. */ + const firstApiReqPayload = async ( + apiConfiguration: ProviderSettings, + servedModelId?: string, + ): Promise> => { + const task = new Task({ + provider: mockProvider, + apiConfiguration, + task: "test task", + startTask: false, + }) + + Object.assign(task.api, { ensureModelFetched: vi.fn().mockResolvedValue(undefined) }) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: servedModelId ?? "served-model", + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.mocked(processUserContentMentions).mockResolvedValueOnce({ + content: [{ type: "text", text: "hello" }], + mode: undefined, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + vi.spyOn(getTaskTestAccess(task), "addToApiConversationHistory").mockResolvedValue(undefined) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + throw new Error("stop after the request message is written") + }) + + const payloads: string[] = [] + vi.spyOn(task, "say").mockImplementation(async (type, text) => { + if (type === "api_req_started") payloads.push(String(text)) + return undefined as never + }) + task.clineMessages = [{ ts: Date.now(), type: "say", say: "api_req_started", text: "{}" }] + + await task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }]).catch(() => {}) + return JSON.parse(payloads[0] ?? "{}") + } + + it("records the provider and model that serve the request", async () => { + const payload = await firstApiReqPayload({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "k", + apiModelId: "claude-sonnet-5", + }) + expect(payload).toMatchObject({ + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + }) + }) + + it("leaves the model unset when the profile names none, and records the provider", async () => { + const payload = await firstApiReqPayload({ apiProvider: providerIdentifiers.anthropic, apiKey: "k" }) + expect(payload.apiProvider).toBe(providerIdentifiers.anthropic) + // The first write cannot know the served model; the finalized message fills it in (below). + expect(payload.modelId).toBeUndefined() + }) + + it("records neither provider nor model when the profile sets no provider", async () => { + const payload = await firstApiReqPayload({} as ProviderSettings) + expect(payload.apiProvider).toBeUndefined() + }) + }) + describe("Subtask Rate Limiting", () => { let mockProvider: MockedClineProvider let mockApiConfig: RateLimitedProviderSettings @@ -7498,6 +7605,104 @@ describe("Cline", () => { } }) + it("overwriteClineMessages sends an explicit empty usageByModel when the replacement records no usage", async () => { + // The history store merges onto the stored item, so a replacement with no recorded usage (for example + // after a rewind) must say so explicitly, or the earlier breakdown would stay on the stored item. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory") + try { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const say = (kind: ClineMessage["say"], text: string, ts: number): ClineMessage => ({ + ts, + type: "say", + say: kind, + text, + }) + const withUsage = [ + say("text", "do it", 1), + say( + "api_req_started", + JSON.stringify({ + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + tokensIn: 10, + tokensOut: 5, + cost: 0.5, + }), + 2, + ), + ] + + await task.overwriteClineMessages(withUsage) + const first = historySpy.mock.calls.at(-1)?.[0] + expect(first?.usageByModel).toEqual([ + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic, requests: 1, cost: 0.5 }), + ]) + + // A rewind to before the request: the replacement records nothing. + await task.overwriteClineMessages([say("text", "do it", 1)]) + const second = historySpy.mock.calls.at(-1)?.[0] + expect(second).toBeDefined() + expect(second).toHaveProperty("usageByModel", undefined) + expect(second?.totalCost).toBe(0) + } finally { + fsReal.rmSync(path.join(taskDir, "ui_messages.json"), { force: true }) + historySpy.mockRestore() + } + }) + + it("computes the usage breakdown from the merged persisted messages, not a stale local snapshot", async () => { + // Another host appended a request that records usage. This host's snapshot does not have that message; + // saving merges the stored one in, and the metadata must follow what was persisted. + const taskDir = path.join(os.tmpdir(), "test-storage", "tasks", "00000000-0000-7000-8000-000000000000") + fsReal.mkdirSync(taskDir, { recursive: true }) + // The mocked fs.readFile serves `mockMessages` as the stored ui_messages.json. + const originalStored = mockMessages.splice(0) + const historySpy = vi.spyOn(mockProvider, "updateTaskHistory") + try { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const recorded = JSON.stringify({ + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + tokensIn: 10, + tokensOut: 5, + cost: 0.5, + }) + const message = (text: string, messageId: string) => ({ + ts: 2, + type: "say" as const, + say: "api_req_started" as const, + text, + messageId, + }) + const first = { ts: 1, type: "say" as const, say: "text" as const, text: "do it", messageId: "m1" } + mockMessages.push(first, message(recorded, "m2")) + + task.clineMessages = [first] + await getTaskTestAccess(task).saveClineMessages() + + const saved = historySpy.mock.calls.at(-1)?.[0] + expect(saved?.usageByModel).toEqual([ + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic, requests: 1, cost: 0.5 }), + ]) + } finally { + mockMessages.splice(0, mockMessages.length, ...originalStored) + fsReal.rmSync(path.join(taskDir, "ui_messages.json"), { force: true }) + historySpy.mockRestore() + } + }) + it("finalizePartialToolAsk skips the webview update when the message write itself fails", async () => { // Complements the later-stage-failure test above by failing the first save // stage: with the real task directory removed, safeWriteJson's fs.access diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 39c6184d92..4230f47329 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -116,7 +116,7 @@ import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" -import { webviewMessageHandler } from "./webviewMessageHandler" +import { abortProviderQuotaRequests, webviewMessageHandler } from "./webviewMessageHandler" import type { WebviewFocusTracker } from "./WebviewFocusTracker" import type { ClineMessage, TodoItem } from "@roo-code/types" import { @@ -869,6 +869,9 @@ export class ClineProvider // hold the event loop after the provider is torn down. this.taskScheduler.cancelQueued() + // Provider-limit requests still running would keep fetching for a view that is gone. + abortProviderQuotaRequests(this) + // Clear all tasks from the stack. The first pop goes through evictCurrentTask() // so an active delegated child is marked interrupted before the extension shuts down, // rather than being left persisted as "active" across the reload. @@ -1055,6 +1058,8 @@ export class ClineProvider } else { this.log("Clearing webview resources for sidebar view") this.view = undefined + // The view that asked for provider limits is gone: stop fetching them. + abortProviderQuotaRequests(this) this.clearWebviewResources() } }, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 779313d385..4397b869c7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2119,6 +2119,17 @@ describe("ClineProvider", () => { expect(state.allowedReadFiles).toEqual([]) }) + test("dispose stops the provider-limit requests still in flight for this provider", async () => { + const abort = vi.spyOn(await import("../webviewMessageHandler"), "abortProviderQuotaRequests") + try { + await provider.dispose() + + expect(abort).toHaveBeenCalledWith(provider) + } finally { + abort.mockRestore() + } + }) + test("getState returns the saved allowed write files", async () => { await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"]) diff --git a/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts b/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts index b570ceb4bf..04c46232bd 100644 --- a/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.stale-view.spec.ts @@ -9,7 +9,7 @@ import type { ExtensionMessage, WebviewMessage } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import { ClineProvider } from "../ClineProvider" -import { webviewMessageHandler } from "../webviewMessageHandler" +import { abortProviderQuotaRequests, webviewMessageHandler } from "../webviewMessageHandler" // Mock setup vi.mock("fs/promises", () => ({ @@ -132,6 +132,7 @@ vi.mock("../../../integrations/theme/getTheme", () => ({ vi.mock("../webviewMessageHandler", () => ({ webviewMessageHandler: vi.fn().mockResolvedValue(undefined), + abortProviderQuotaRequests: vi.fn(), })) type ProviderState = Awaited> @@ -414,9 +415,11 @@ describe("ClineProvider - stale view lifecycle", () => { expect(first.postMessage).not.toHaveBeenCalled() expect(second.postMessage).not.toHaveBeenCalled() - // A stale dispose must not clear the current view's listeners. + // A stale dispose must not clear the current view's listeners, nor stop the requests of the current view. + vi.mocked(abortProviderQuotaRequests).mockClear() first.disposed.fire() expect(provider["view"]).toBe(second.view) + expect(abortProviderQuotaRequests).not.toHaveBeenCalled() second.visibility.fire() await flushAsync() expect(second.postMessage).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible" }) @@ -509,6 +512,16 @@ describe("ClineProvider - stale view lifecycle", () => { expect(ClineProvider.getAllInstances()).toContain(provider) }) + test("disposing the sidebar view stops the provider-limit requests it started", async () => { + const first = createSidebarView() + await provider.resolveWebviewView(first.view) + vi.mocked(abortProviderQuotaRequests).mockClear() + + first.disposed.fire() + + expect(abortProviderQuotaRequests).toHaveBeenCalledWith(provider) + }) + test("the current view keeps dispatching messages, visibility, and theme refreshes", async () => { const first = createSidebarView() await provider.resolveWebviewView(first.view) diff --git a/src/core/webview/__tests__/webviewMessageHandler.providerQuotas.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.providerQuotas.spec.ts new file mode 100644 index 0000000000..78777b269c --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.providerQuotas.spec.ts @@ -0,0 +1,421 @@ +// npx vitest core/webview/__tests__/webviewMessageHandler.providerQuotas.spec.ts + +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +const quotas = vi.hoisted(() => ({ + getProviderQuotas: vi.fn(), +})) + +// Keep the real `loadProfiles` (it is the code under test alongside the handler) and stub only the network part. +vi.mock("../../../services/language-models/providerQuotas", async (importOriginal) => ({ + ...(await importOriginal()), + getProviderQuotas: quotas.getProviderQuotas, +})) + +import { abortProviderQuotaRequests, webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const SECRET = "sk-super-secret" + +const makeProvider = (overrides: Record = {}) => + ({ + postMessageToWebview: vi.fn(), + log: vi.fn(), + contextProxy: { getValue: vi.fn((key: string) => (key === "currentApiConfigName" ? "default" : undefined)) }, + providerSettingsManager: { + listConfig: vi.fn(async () => [{ id: "a" }, { id: "b" }]), + getProfile: vi.fn(async ({ id }: { id: string }) => ({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: SECRET, + id, + })), + }, + ...overrides, + }) as unknown as ClineProvider & { postMessageToWebview: ReturnType } + +describe("webviewMessageHandler – requestProviderQuotas", () => { + beforeEach(() => { + // mockReset clears an earlier test's mockImplementation too, so a pending-promise stub cannot leak. + quotas.getProviderQuotas.mockReset() + quotas.getProviderQuotas.mockResolvedValue([ + { + providerId: providerIdentifiers.openrouter, + ok: true, + fetchedAt: 1, + windows: [{ id: "key_limit", valueLabel: "$5.00 / $10.00 used", usedPercent: 50 }], + }, + ]) + }) + + it("puts the active profile first, so the quota row is the account the requests use", async () => { + const provider = makeProvider({ + contextProxy: { getValue: vi.fn(() => "personal") }, + providerSettingsManager: { + listConfig: vi.fn(async () => [ + { id: "a", name: "work" }, + { id: "b", name: "personal" }, + ]), + getProfile: vi.fn(async ({ id }: { id: string }) => ({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: `key-${id}`, + })), + }, + }) + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect( + quotas.getProviderQuotas.mock.calls[0][0].map((p: { openRouterApiKey: string }) => p.openRouterApiKey), + ).toEqual(["key-b", "key-a"]) + }) + + it("passes the active profile's id on, so its own credentials are the ones queried", async () => { + const provider = makeProvider({ + contextProxy: { getValue: vi.fn(() => "personal") }, + providerSettingsManager: { + listConfig: vi.fn(async () => [ + { id: "a", name: "work" }, + { id: "b", name: "personal" }, + ]), + getProfile: vi.fn(async ({ id }: { id: string }) => ({ + apiProvider: providerIdentifiers.openrouter, + id, + })), + }, + }) + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect(quotas.getProviderQuotas).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ activeProfileId: "b" }), + ) + }) + + it("reports a failure, not an empty list, when no saved profile can be read", async () => { + const provider = makeProvider({ + providerSettingsManager: { + listConfig: vi.fn(async () => [{ id: "a" }]), + getProfile: vi.fn(async () => { + throw new Error("corrupt secret storage") + }), + }, + }) + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "providerQuotas", providerQuotas: [], providerQuotasFailed: true }), + ) + }) + + describe("when the provider is disposed", () => { + it("aborts the requests in flight, and posts nothing for them", async () => { + let signal: AbortSignal | undefined + quotas.getProviderQuotas.mockImplementation( + (_profiles: unknown, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal = options.signal + options.signal.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + const provider = makeProvider() + const handling = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "q1" }) + await vi.waitFor(() => expect(signal).toBeDefined()) + + abortProviderQuotaRequests(provider) + await handling + + expect(signal?.aborted).toBe(true) + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("does nothing for a provider with no requests in flight", () => { + expect(() => abortProviderQuotaRequests(makeProvider())).not.toThrow() + }) + }) + + it("loads every saved profile and posts the formatted quotas back", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect(provider.providerSettingsManager.getProfile).toHaveBeenCalledTimes(2) + expect(quotas.getProviderQuotas.mock.calls[0][0]).toHaveLength(2) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerQuotas", + providerQuotas: expect.arrayContaining([ + expect.objectContaining({ providerId: providerIdentifiers.openrouter }), + ]), + providerQuotasFailed: false, + requestId: undefined, + }) + }) + + it("does not force a refresh by default, and forces when the user presses refresh", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + expect(quotas.getProviderQuotas).toHaveBeenLastCalledWith(expect.any(Array), { + force: false, + signal: expect.any(AbortSignal), + }) + + await webviewMessageHandler(provider, { type: "requestProviderQuotas", bool: true }) + expect(quotas.getProviderQuotas).toHaveBeenLastCalledWith(expect.any(Array), { + force: true, + signal: expect.any(AbortSignal), + }) + }) + + it("passes API keys to the host service but never posts them to the webview", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + // The service needs the key... + expect(JSON.stringify(quotas.getProviderQuotas.mock.calls[0][0])).toContain(SECRET) + // ...but nothing posted to the webview may contain it. + expect(JSON.stringify(provider.postMessageToWebview.mock.calls)).not.toContain(SECRET) + }) + + it("still answers when one profile cannot be loaded", async () => { + const provider = makeProvider() + provider.providerSettingsManager.getProfile = vi.fn(async ({ id }: { id: string }) => { + if (id === "a") throw new Error("corrupt secret storage") + return { apiProvider: providerIdentifiers.openrouter, openRouterApiKey: SECRET } as never + }) as never + + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + expect(quotas.getProviderQuotas.mock.calls[0][0]).toHaveLength(1) + expect(provider.postMessageToWebview).toHaveBeenCalled() + }) + + it("still answers, and logs, when the profile list cannot be read", async () => { + const log = vi.fn() + const provider = makeProvider({ log }) + provider.providerSettingsManager.listConfig = vi.fn(async () => { + throw new Error("secret storage unavailable") + }) as never + + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerQuotas", + providerQuotas: [], + providerQuotasFailed: true, + requestId: undefined, + }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("secret storage unavailable")) + }) + + it("still answers when the quota service throws something that is not an Error", async () => { + const log = vi.fn() + const provider = makeProvider({ log }) + quotas.getProviderQuotas.mockRejectedValue("nope") + + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerQuotas", + providerQuotas: [], + providerQuotasFailed: true, + requestId: undefined, + }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("Failed to load provider limits")) + }) + + it("answers with an empty list when there are no profiles", async () => { + quotas.getProviderQuotas.mockResolvedValue([]) + const provider = makeProvider() + provider.providerSettingsManager.listConfig = vi.fn(async () => []) as never + + await webviewMessageHandler(provider, { type: "requestProviderQuotas" }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerQuotas", + providerQuotas: [], + providerQuotasFailed: false, + requestId: undefined, + }) + }) + + describe("request lifecycle", () => { + it("echoes the request id on the answer", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-1" }) + expect(provider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "providerQuotas", requestId: "req-1" }), + ) + }) + + it("passes an abort signal to the quota service", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-2" }) + expect(quotas.getProviderQuotas).toHaveBeenLastCalledWith(expect.any(Array), { + force: false, + signal: expect.any(AbortSignal), + }) + }) + + it("aborts the work and posts nothing when the request is cancelled while it is running", async () => { + let signal: AbortSignal | undefined + let finish: (value: never[]) => void = () => {} + quotas.getProviderQuotas.mockImplementation((_profiles, options: { signal: AbortSignal }) => { + signal = options.signal + return new Promise((resolve) => (finish = resolve)) + }) + const provider = makeProvider() + + const running = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-3" }) + await vi.waitFor(() => expect(signal).toBeDefined()) + await webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "req-3" }) + + expect(signal!.aborted).toBe(true) + finish([]) + await running + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("does not post when the request was cancelled before the profiles finished loading", async () => { + const provider = makeProvider() + let release: (entries: { id: string }[]) => void = () => {} + provider.providerSettingsManager.listConfig = vi.fn( + () => new Promise((resolve) => (release = resolve)), + ) as never + + const running = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-4" }) + await vi.waitFor(() => expect(provider.providerSettingsManager.listConfig).toHaveBeenCalled()) + await webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "req-4" }) + release([{ id: "a" }]) + await running + + expect(quotas.getProviderQuotas).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("releases the handler on cancel without waiting for a profile read that never settles", async () => { + const provider = makeProvider() + provider.providerSettingsManager.listConfig = vi.fn(() => new Promise(() => {})) as never + + const running = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-stuck" }) + await vi.waitFor(() => expect(provider.providerSettingsManager.listConfig).toHaveBeenCalled()) + await webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "req-stuck" }) + + // Resolves although listConfig never does. + await running + expect(provider.providerSettingsManager.getProfile).not.toHaveBeenCalled() + expect(quotas.getProviderQuotas).not.toHaveBeenCalled() + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + expect(provider.log).not.toHaveBeenCalled() + }) + + it("keeps two webviews apart: a cancel from one never aborts the other's request with the same id", async () => { + // Each webview has its own provider, and both number their requests from 1. Their loads are held open + // at the profile list, so both are pending at once when the cancel arrives. + quotas.getProviderQuotas.mockResolvedValue([]) + const releases: Array<(entries: { id: string }[]) => void> = [] + const pendingProvider = () => { + const provider = makeProvider() + provider.providerSettingsManager.listConfig = vi.fn( + () => new Promise((resolve) => releases.push(resolve)), + ) as never + return provider + } + const sidebar = pendingProvider() + const editorTab = pendingProvider() + + const first = webviewMessageHandler(sidebar, { + type: "requestProviderQuotas", + requestId: "provider-quotas-1", + }) + // Start them one after the other: two overlapping dynamic imports of the handler's service module can + // resolve to the real module instead of the mock, which would make a real network call. + await vi.waitFor(() => expect(releases).toHaveLength(1)) + const second = webviewMessageHandler(editorTab, { + type: "requestProviderQuotas", + requestId: "provider-quotas-1", + }) + await vi.waitFor(() => expect(releases).toHaveLength(2)) + + // Closing Settings in the sidebar cancels the sidebar's request only. + await webviewMessageHandler(sidebar, { type: "cancelProviderQuotas", requestId: "provider-quotas-1" }) + releases.forEach((release) => release([{ id: "a" }])) + await Promise.all([first, second]) + + // The sidebar was cancelled and is not answered. The editor tab is still answered. With one shared + // map keyed by id alone, the cancel would have aborted the editor tab's request instead. + expect(sidebar.postMessageToWebview).not.toHaveBeenCalled() + expect(editorTab.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "providerQuotas", requestId: "provider-quotas-1" }), + ) + // Only the editor tab's request went on to the service; the sidebar's was stopped while loading. + expect(quotas.getProviderQuotas).toHaveBeenCalledTimes(1) + }) + + it("lets a newer request with the same id keep its slot when the older one finishes first", async () => { + quotas.getProviderQuotas.mockResolvedValue([]) + const releases: Array<(entries: { id: string }[]) => void> = [] + const provider = makeProvider() + provider.providerSettingsManager.listConfig = vi.fn( + () => new Promise((resolve) => releases.push(resolve)), + ) as never + + // Start them one after the other: see the note in the test above. + const older = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "same-id" }) + await vi.waitFor(() => expect(releases).toHaveLength(1)) + const newer = webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "same-id" }) + await vi.waitFor(() => expect(releases).toHaveLength(2)) + + // The older one finishes and cleans up; it must not remove the newer one's entry. + releases[0]([{ id: "a" }]) + await older + // The older request is answered, with a successful (empty) result. + expect(provider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(provider.postMessageToWebview).toHaveBeenLastCalledWith({ + type: "providerQuotas", + providerQuotas: [], + providerQuotasFailed: false, + requestId: "same-id", + }) + + await webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "same-id" }) + releases[1]([{ id: "a" }]) + await newer + + // The cancel reached the newer request, so it posts nothing and the count stays at one. + expect(provider.postMessageToWebview).toHaveBeenCalledTimes(1) + expect(quotas.getProviderQuotas).toHaveBeenCalledTimes(1) + }) + + it("marks the answer as failed when the quotas could not be loaded, and not when there are simply none", async () => { + const failing = makeProvider() + failing.providerSettingsManager.listConfig = vi.fn(async () => { + throw new Error("secret storage unavailable") + }) as never + await webviewMessageHandler(failing, { type: "requestProviderQuotas", requestId: "a" }) + expect(failing.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ providerQuotas: [], providerQuotasFailed: true }), + ) + + quotas.getProviderQuotas.mockResolvedValue([]) + const empty = makeProvider() + await webviewMessageHandler(empty, { type: "requestProviderQuotas", requestId: "b" }) + expect(empty.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ providerQuotas: [], providerQuotasFailed: false }), + ) + }) + + it("ignores a cancel for an unknown or missing id, and for a request that already finished", async () => { + const provider = makeProvider() + await webviewMessageHandler(provider, { type: "requestProviderQuotas", requestId: "req-5" }) + provider.postMessageToWebview.mockClear() + + await expect( + webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "req-5" }), + ).resolves.not.toThrow() + await expect( + webviewMessageHandler(provider, { type: "cancelProviderQuotas", requestId: "nope" }), + ).resolves.not.toThrow() + await expect(webviewMessageHandler(provider, { type: "cancelProviderQuotas" })).resolves.not.toThrow() + expect(provider.postMessageToWebview).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..1d96b8af8d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -30,6 +30,7 @@ import { RouterModelsMessageType, VsCodeLmModelsMessageType, isTelemetryOptedIn, + type ProviderQuota, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -130,6 +131,24 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +/** + * In-flight `requestProviderQuotas` work, so a cancel message can abort it. Each ClineProvider (sidebar, + * editor tab) has its own map: the webviews number their requests independently, so a shared map keyed by + * request id alone would let one webview cancel, or overwrite, another's request. + */ +const pendingProviderQuotaRequests = new WeakMap>() +/** Stops the provider-limit requests still in flight for a provider, e.g. when it is disposed. */ +export const abortProviderQuotaRequests = (provider: ClineProvider) => { + for (const controller of pendingProviderQuotaRequests.get(provider)?.values() ?? []) controller.abort() + pendingProviderQuotaRequests.delete(provider) +} + +const pendingQuotaFor = (provider: ClineProvider) => { + let pending = pendingProviderQuotaRequests.get(provider) + if (!pending) pendingProviderQuotaRequests.set(provider, (pending = new Map())) + return pending +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1570,6 +1589,56 @@ export const webviewMessageHandler = async ( } break } + case VsCodeLmModelsMessageType.cancelProviderQuotas: + // The Usage tab was closed (or the request superseded): stop the profile load and any fetches. + if (message.requestId) pendingQuotaFor(provider).get(message.requestId)?.abort() + break + case VsCodeLmModelsMessageType.requestProviderQuotas: { + // Always answer (unless cancelled): the webview shows a spinner until a response arrives and + // disables refresh while it waits, so a thrown error here would leave it stuck. The answer + // carries the request's id so the webview can ignore one that has been superseded. + const requestId = message.requestId + const controller = new AbortController() + if (requestId) pendingQuotaFor(provider).set(requestId, controller) + let providerQuotas: ProviderQuota[] = [] + let providerQuotasFailed = false + try { + const { getProviderQuotas, loadProfilesWithActive } = + await import("../../services/language-models/providerQuotas") + const { profiles, activeProfileId } = await loadProfilesWithActive( + provider.providerSettingsManager, + controller.signal, + provider.contextProxy.getValue("currentApiConfigName"), + ) + providerQuotas = await getProviderQuotas(profiles, { + force: message.bool === true, + signal: controller.signal, + activeProfileId, + }) + } catch (error) { + providerQuotasFailed = true + if (!controller.signal.aborted) { + provider.log( + `[requestProviderQuotas] ${error instanceof Error ? error.message : "Failed to load provider limits"}`, + ) + } + } finally { + // Only remove our own entry: a newer request with the same id from this webview owns the slot now. + if (requestId && pendingQuotaFor(provider).get(requestId) === controller) { + pendingQuotaFor(provider).delete(requestId) + } + } + // Nobody is waiting for a cancelled request: skip the post. + if (!controller.signal.aborted) { + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.providerQuotas, + providerQuotas, + providerQuotasFailed, + requestId, + }) + } + break + } case VsCodeLmModelsMessageType.githubCopilotManageAccount: try { await openGitHubAccountManagement() diff --git a/src/services/language-models/__tests__/copilotQuota.spec.ts b/src/services/language-models/__tests__/copilotQuota.spec.ts new file mode 100644 index 0000000000..38a9ecf15f --- /dev/null +++ b/src/services/language-models/__tests__/copilotQuota.spec.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("vscode", () => ({ version: "1.0.0", authentication: { getSession: vi.fn() } })) + +import { parseCopilotQuota } from "../copilotQuota" + +const NOW = 1_800_000_000_000 + +// Shape recorded from GET api.github.com/copilot_internal/user (business plan). +const businessPayload = { + copilot_plan: "business", + quota_reset_date: "2026-11-01", + quota_reset_date_utc: "2026-11-01T00:00:00.000Z", + quota_snapshots: { + chat: { unlimited: true, entitlement: 0, remaining: 0 }, + completions: { unlimited: true, entitlement: 0, remaining: 0 }, + premium_interactions: { + entitlement: 140000, + remaining: 100610, + quota_remaining: 100610.7, + unlimited: false, + percent_remaining: 71.8, + }, + }, +} + +describe("parseCopilotQuota", () => { + it("derives used credits from the precise quota_remaining, like GitHub's panel", () => { + const q = parseCopilotQuota(businessPayload, NOW)! + expect(q.plan).toBe("business") + expect(q.unlimited).toBe(false) + expect(q.entitlement).toBe(140000) + expect(q.used).toBe(39389.3) + expect(q.usedPercent).toBeCloseTo(28.135, 2) + expect(q.resetAt).toBe(Date.parse("2026-11-01T00:00:00.000Z")) + expect(q.fetchedAt).toBe(NOW) + }) + + it("falls back to the integer remaining twin", () => { + const payload = structuredClone(businessPayload) + delete (payload.quota_snapshots.premium_interactions as Record).quota_remaining + expect(parseCopilotQuota(payload, NOW)!.used).toBe(39390) + }) + + it("falls back to percent_remaining when counts are missing", () => { + const q = parseCopilotQuota( + { quota_snapshots: { premium_interactions: { unlimited: false, percent_remaining: 40 } } }, + NOW, + )! + expect(q.used).toBeUndefined() + expect(q.entitlement).toBeUndefined() + expect(q.usedPercent).toBe(60) + }) + + it("leaves usage unset when neither counts nor percent_remaining are reported", () => { + const q = parseCopilotQuota({ quota_snapshots: { premium_interactions: { unlimited: false } } }, NOW)! + expect(q.used).toBeUndefined() + expect(q.usedPercent).toBeUndefined() + }) + + it("reports unlimited plans without numbers", () => { + const q = parseCopilotQuota( + { copilot_plan: "enterprise", quota_snapshots: { premium_interactions: { unlimited: true } } }, + NOW, + )! + expect(q).toMatchObject({ plan: "enterprise", unlimited: true }) + expect(q.usedPercent).toBeUndefined() + }) + + it("clamps over-consumption to 100%", () => { + const q = parseCopilotQuota( + { quota_snapshots: { premium_interactions: { entitlement: 300, quota_remaining: -12 } } }, + NOW, + )! + expect(q.used).toBe(300) + expect(q.usedPercent).toBe(100) + }) + + it("uses the date-only reset field when the UTC variant is absent", () => { + const payload = { ...businessPayload, quota_reset_date_utc: undefined } + expect(parseCopilotQuota(payload, NOW)!.resetAt).toBe(Date.parse("2026-11-01")) + }) + + it.each([null, undefined, "nope", [], {}, { quota_snapshots: {} }])("returns undefined for %j", (payload) => { + expect(parseCopilotQuota(payload, NOW)).toBeUndefined() + }) +}) + +// --- getGitHubCopilotQuota (network + auth paths) ---------------------------------------------- + +import * as vscodeApi from "vscode" +import { getGitHubCopilotQuota, resetGitHubCopilotQuotaCache } from "../copilotQuota" + +const getSession = vscodeApi.authentication.getSession as unknown as ReturnType + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) + +describe("getGitHubCopilotQuota", () => { + beforeEach(() => { + resetGitHubCopilotQuotaCache() + getSession.mockReset() + getSession.mockResolvedValue({ accessToken: "gho_token" }) + }) + + it("returns the parsed quota using the existing VS Code GitHub session, silently", async () => { + const fetchSpy = vi.fn(async () => json(businessPayload)) + const result = await getGitHubCopilotQuota({}, fetchSpy as never) + + expect(result).toMatchObject({ ok: true, quota: { plan: "business", entitlement: 140000 } }) + // Silent: must never raise a sign-in prompt from a background usage refresh. + expect(getSession).toHaveBeenCalledWith("github", expect.any(Array), { silent: true }) + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.github.com/copilot_internal/user", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "token gho_token" }), + }), + ) + }) + + it("falls back to the second scope set when the first has no session", async () => { + getSession.mockResolvedValueOnce(undefined).mockResolvedValueOnce({ accessToken: "second" }) + const fetchSpy = vi.fn(async () => json(businessPayload)) + await getGitHubCopilotQuota({}, fetchSpy as never) + expect(getSession).toHaveBeenCalledTimes(2) + expect(fetchSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "token second" }) }), + ) + }) + + it("reports signedOut without calling GitHub when there is no session", async () => { + getSession.mockResolvedValue(undefined) + const fetchSpy = vi.fn() + expect(await getGitHubCopilotQuota({}, fetchSpy as never)).toEqual({ ok: false, reason: "signedOut" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it.each([ + [401, "signedOut"], + [403, "noCopilot"], + [404, "noCopilot"], + ] as const)("maps HTTP %s to %s", async (status, reason) => { + const result = await getGitHubCopilotQuota({}, (async () => json({}, status)) as never) + expect(result).toMatchObject({ ok: false, reason }) + }) + + it("reports other HTTP failures as an error with the status", async () => { + const result = await getGitHubCopilotQuota({}, (async () => json({}, 500)) as never) + expect(result).toEqual({ ok: false, reason: "error", message: "GitHub API error 500" }) + }) + + it.each([ + ["an empty quota_snapshots", { quota_snapshots: {} }], + ["a renamed premium field", { copilot_plan: "pro", quota_snapshots: { premium_requests: {} } }], + ["no quota_snapshots", { copilot_plan: "pro" }], + ])( + "reports an error, not noCopilot, when a successful payload has no premium snapshot (%s)", + async (_name, body) => { + const result = await getGitHubCopilotQuota({}, (async () => json(body)) as never) + expect(result).toEqual({ ok: false, reason: "error", message: "GitHub returned no premium request quota" }) + }, + ) + + it("turns network errors into a generic error result instead of throwing", async () => { + const result = await getGitHubCopilotQuota({}, (async () => { + throw new Error("offline") + }) as never) + // Arbitrary error text is never forwarded: it can embed URLs, headers or tokens. + expect(result).toEqual({ ok: false, reason: "error", message: "Request failed" }) + }) + + it("never leaks the access token through an error message", async () => { + const result = await getGitHubCopilotQuota({}, (async () => { + throw new Error("request to https://api.github.com failed: Authorization: token gho_token") + }) as never) + expect(JSON.stringify(result)).not.toContain("gho_token") + }) + + it("reports a timeout plainly", async () => { + const result = await getGitHubCopilotQuota({}, (async () => { + throw Object.assign(new Error("The operation timed out"), { name: "TimeoutError" }) + }) as never) + expect(result).toEqual({ ok: false, reason: "error", message: "Request timed out" }) + }) + + it("caches successful results and bypasses the cache when forced", async () => { + const fetchSpy = vi.fn(async () => json(businessPayload)) + await getGitHubCopilotQuota({}, fetchSpy as never) + await getGitHubCopilotQuota({}, fetchSpy as never) + expect(fetchSpy).toHaveBeenCalledTimes(1) + await getGitHubCopilotQuota({ force: true }, fetchSpy as never) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + describe("the cache lifetime", () => { + afterEach(() => vi.useRealTimers()) + + it.each([ + [59_999, 1], + [60_000, 2], + ])("serves a cached result %i ms after it was fetched with %i request(s) in total", async (age, requests) => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const fetchSpy = vi.fn(async () => json(businessPayload)) + await getGitHubCopilotQuota({}, fetchSpy as never) + + vi.setSystemTime(1_000_000 + age) + await getGitHubCopilotQuota({}, fetchSpy as never) + + expect(fetchSpy).toHaveBeenCalledTimes(requests) + }) + }) + + it("does not cache failures", async () => { + const fail = vi.fn(async () => json({}, 500)) + await getGitHubCopilotQuota({}, fail as never) + await getGitHubCopilotQuota({}, fail as never) + expect(fail).toHaveBeenCalledTimes(2) + }) + + it.each([ + ["an HTTP error", async () => json({}, 500), { ok: false, reason: "error" }], + ["a signed-out answer", async () => json({}, 401), { ok: false, reason: "signedOut" }], + ["a missing Copilot plan", async () => json({}, 403), { ok: false, reason: "noCopilot" }], + [ + "a thrown error", + async () => { + throw new Error("network down") + }, + { ok: false, reason: "error" }, + ], + ])("drops an older success when a forced refresh fails with %s", async (_label, failure, expected) => { + const good = vi.fn(async () => json(businessPayload)) + await getGitHubCopilotQuota({}, good as never) + + const failed = await getGitHubCopilotQuota({ force: true }, failure as never) + expect(failed).toMatchObject(expected) + + // The old success must not be served as if it were current: the next normal call fetches again. + const retry = vi.fn(async () => json(businessPayload)) + await getGitHubCopilotQuota({}, retry as never) + expect(retry).toHaveBeenCalledTimes(1) + }) + + describe("cancellation", () => { + /** A fetch that never answers on its own and records the signal it was given. */ + const hangingFetch = () => { + let seen: AbortSignal | undefined + const fetchSpy = vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_resolve, reject) => { + seen = init?.signal ?? undefined + seen?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))) + }), + ) + return { fetchSpy, signal: () => seen } + } + + it("stops the GitHub request when the only caller goes away", async () => { + const { fetchSpy, signal } = hangingFetch() + const controller = new AbortController() + const result = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + await vi.waitFor(() => expect(signal()).toBeDefined()) + expect(signal()!.aborted).toBe(false) + + controller.abort() + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + expect(signal()!.aborted).toBe(true) + }) + + it("keeps the shared request running until its last caller leaves", async () => { + const { fetchSpy, signal } = hangingFetch() + const leaving = new AbortController() + const staying = new AbortController() + + const a = getGitHubCopilotQuota({ signal: staying.signal }, fetchSpy as never) + const b = getGitHubCopilotQuota({ signal: leaving.signal }, fetchSpy as never) + await vi.waitFor(() => expect(signal()).toBeDefined()) + expect(fetchSpy).toHaveBeenCalledTimes(1) + + leaving.abort() + await expect(b).rejects.toMatchObject({ name: "AbortError" }) + expect(signal()!.aborted).toBe(false) + + staying.abort() + await expect(a).rejects.toMatchObject({ name: "AbortError" }) + expect(signal()!.aborted).toBe(true) + }) + + it("does not look up the second scope once the caller has gone, after the first scope found no session", async () => { + const fetchSpy = vi.fn() + const controller = new AbortController() + getSession.mockReset() + // The first scope answers "no session", and the caller goes away just after that answer, before the + // lookup moves on to the second scope. + getSession.mockImplementationOnce(() => ({ + then(resolve: (value: undefined) => void) { + resolve(undefined) + queueMicrotask(() => controller.abort()) + }, + })) + + const result = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + await new Promise((resolve) => setTimeout(resolve, 0)) // let the lookup loop run on, if it is going to + expect(getSession).toHaveBeenCalledTimes(1) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects a lookup that was cancelled before its session answered, with the cancellation", async () => { + const fetchSpy = vi.fn() + const controller = new AbortController() + getSession.mockReset() + getSession.mockImplementationOnce(() => new Promise(() => {})) // never answers + + const result = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + await vi.waitFor(() => expect(getSession).toHaveBeenCalledTimes(1)) + controller.abort() + + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("rejects at once with a signal that is already aborted, and makes no request", async () => { + const fetchSpy = vi.fn() + const controller = new AbortController() + controller.abort() + await expect(getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never)).rejects.toMatchObject( + { + name: "AbortError", + }, + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("does not cache a cancelled request, so the next call fetches again", async () => { + const { fetchSpy } = hangingFetch() + const controller = new AbortController() + const cancelled = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()) + controller.abort() + await expect(cancelled).rejects.toMatchObject({ name: "AbortError" }) + + const good = vi.fn(async () => json(businessPayload)) + await expect(getGitHubCopilotQuota({}, good as never)).resolves.toMatchObject({ ok: true }) + expect(good).toHaveBeenCalledTimes(1) + }) + + it("starts a new request instead of joining one whose last caller already left", async () => { + let release: (session: { accessToken: string }) => void = () => {} + getSession.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))) + const controller = new AbortController() + const first = getGitHubCopilotQuota({ signal: controller.signal }, vi.fn() as never) + await vi.waitFor(() => expect(getSession).toHaveBeenCalled()) + controller.abort() + await expect(first).rejects.toMatchObject({ name: "AbortError" }) + + // The cancelled request is still waiting on the token; a new caller must not be handed its result. + const good = vi.fn(async () => json(businessPayload)) + const second = getGitHubCopilotQuota({}, good as never) + release({ accessToken: "gho_token" }) + + await expect(second).resolves.toMatchObject({ ok: true }) + expect(good).toHaveBeenCalledTimes(1) + }) + + it("stops waiting for a session request that never settles when the caller goes away", async () => { + getSession.mockImplementationOnce(() => new Promise(() => {})) + const fetchSpy = vi.fn() + const controller = new AbortController() + + const result = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + await vi.waitFor(() => expect(getSession).toHaveBeenCalled()) + controller.abort() + + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + expect(fetchSpy).not.toHaveBeenCalled() + // The stuck lookup is not left holding the slot: the next call starts over. + const good = vi.fn(async () => json(businessPayload)) + await expect(getGitHubCopilotQuota({}, good as never)).resolves.toMatchObject({ ok: true }) + }) + + it("does not start the GitHub request when it was cancelled while the token was being fetched", async () => { + let release: (session: { accessToken: string }) => void = () => {} + getSession.mockImplementationOnce(() => new Promise((resolve) => (release = resolve))) + const fetchSpy = vi.fn() + const controller = new AbortController() + + const result = getGitHubCopilotQuota({ signal: controller.signal }, fetchSpy as never) + await vi.waitFor(() => expect(getSession).toHaveBeenCalled()) + controller.abort() + release({ accessToken: "gho_token" }) + + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + }) + + it("shares one in-flight request between concurrent callers", async () => { + const fetchSpy = vi.fn(async () => json(businessPayload)) + const [a, b] = await Promise.all([ + getGitHubCopilotQuota({}, fetchSpy as never), + getGitHubCopilotQuota({}, fetchSpy as never), + ]) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(a).toEqual(b) + }) +}) diff --git a/src/services/language-models/__tests__/providerQuotas.spec.ts b/src/services/language-models/__tests__/providerQuotas.spec.ts new file mode 100644 index 0000000000..65cbdf4d3c --- /dev/null +++ b/src/services/language-models/__tests__/providerQuotas.spec.ts @@ -0,0 +1,1497 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +vi.mock("vscode", () => ({ version: "1.0.0", authentication: { getSession: vi.fn() } })) +vi.mock("../copilotQuota", () => ({ + getGitHubCopilotQuota: vi.fn(async () => ({ + ok: true, + quota: { + plan: "business", + unlimited: false, + entitlement: 140000, + used: 39389.3, + usedPercent: 28.1, + resetAt: 1, + fetchedAt: 2, + }, + })), +})) + +import { + getProviderQuotas, + loadProfilesWithActive, + parseDeepSeekBalance, + parseKimiCodeUsage, + parseMiniMaxRemains, + parseMoonshotBalance, + parseNanoGptUsage, + parseOpenCodeGoUsage, + parseOpenRouterKey, + parseZaiQuota, + resetProviderQuotaCache, + windowLabel, +} from "../providerQuotas" + +describe("parseOpenRouterKey", () => { + it("reports a bounded key limit as used / limit with a percent", () => { + expect(parseOpenRouterKey({ data: { limit: 50, limit_remaining: 37.6 } })).toEqual([ + { id: "key_limit", valueLabel: "$12.40 / $50.00 used", usedPercent: expect.closeTo(24.8, 5) }, + ]) + }) + + it("falls back to monthly spend for unlimited keys", () => { + expect(parseOpenRouterKey({ data: { limit: null, usage_monthly: 3.2 } })).toEqual([ + { id: "spend", valueLabel: "$3.20 spent this month" }, + ]) + }) + + it("labels the all-time usage as total spend, never as this month's", () => { + expect(parseOpenRouterKey({ data: { limit: null, usage: 42 } })).toEqual([ + { id: "spend", valueLabel: "$42.00 spent in total" }, + ]) + }) + + it("prefers the monthly figure when both are reported", () => { + expect(parseOpenRouterKey({ data: { usage: 42, usage_monthly: 3.2 } })).toEqual([ + { id: "spend", valueLabel: "$3.20 spent this month" }, + ]) + }) + + it("returns undefined without data", () => { + expect(parseOpenRouterKey({})).toBeUndefined() + expect(parseOpenRouterKey({ data: {} })).toBeUndefined() + }) +}) + +describe("parseDeepSeekBalance", () => { + it("prefers a positive USD balance", () => { + expect( + parseDeepSeekBalance({ + balance_infos: [ + { currency: "CNY", total_balance: "10.00" }, + { currency: "USD", total_balance: "4.25" }, + ], + }), + ).toEqual([{ id: "balance", valueLabel: "$4.25 left" }]) + }) + + it("uses ¥ for a CNY-only balance", () => { + expect(parseDeepSeekBalance({ balance_infos: [{ currency: "CNY", total_balance: "8" }] })).toEqual([ + { id: "balance", valueLabel: "¥8.00 left" }, + ]) + }) + + it("returns undefined for empty or malformed payloads", () => { + expect(parseDeepSeekBalance({ balance_infos: [] })).toBeUndefined() + expect(parseDeepSeekBalance(null)).toBeUndefined() + }) +}) + +describe("parseDeepSeekBalance – fallbacks", () => { + it("treats a USD or CNY row without a balance as zero when choosing a row", () => { + const result = parseDeepSeekBalance({ + balance_infos: [{ currency: "USD" }, { currency: "CNY" }, { currency: "CNY", total_balance: "5" }], + }) + expect(result).toEqual([{ id: "balance", valueLabel: "¥5.00 left" }]) + }) + + it("falls back to a zero USD balance, then to the first row", () => { + expect( + parseDeepSeekBalance({ + balance_infos: [ + { currency: "EUR", total_balance: "0" }, + { currency: "USD", total_balance: "0" }, + ], + }), + ).toEqual([{ id: "balance", valueLabel: "$0.00 left" }]) + expect(parseDeepSeekBalance({ balance_infos: [{ currency: "EUR", total_balance: "3" }] })).toEqual([ + { id: "balance", valueLabel: "$3.00 left" }, + ]) + }) +}) + +describe("parseMoonshotBalance", () => { + it("reads available_balance", () => { + expect(parseMoonshotBalance({ data: { available_balance: 19.5 } })).toEqual([ + { id: "balance", valueLabel: "$19.50 left" }, + ]) + }) +}) + +describe("windowLabel", () => { + it("names rolling windows by length", () => { + expect(windowLabel(5 * 3600, "x")).toBe("5-hour") + expect(windowLabel(7 * 86400, "x")).toBe("Weekly") + expect(windowLabel(3 * 86400, "x")).toBe("3-day") + expect(windowLabel(undefined, "Tokens")).toBe("Tokens") + expect(windowLabel(90, "Tokens")).toBe("Tokens") + }) +}) + +describe("parseKimiCodeUsage", () => { + it("reads the ratio-based usages block in display order", () => { + expect( + parseKimiCodeUsage({ + usages: { + limit_month_total: { used_ratio: 0.1 }, + limit_5h: { used_ratio: 0.25, reset_time: 1_800_000_000 }, + limit_7d: { used_ratio: 0.5 }, + }, + }), + ).toEqual([ + { id: "limit_5h", valueLabel: "5-hour", usedPercent: 25, resetAt: 1_800_000_000_000 }, + { id: "limit_7d", valueLabel: "Weekly", usedPercent: 50, resetAt: undefined }, + { id: "limit_month_total", valueLabel: "Monthly", usedPercent: expect.closeTo(10, 5), resetAt: undefined }, + ]) + }) + + it("skips entries without a finite ratio", () => { + expect(parseKimiCodeUsage({ usages: { limit_5h: {}, limit_7d: { used_ratio: "x" } } })).toBeUndefined() + }) + + it("falls back to the legacy weekly usage block, deriving used from remaining", () => { + expect(parseKimiCodeUsage({ usage: { limit: 200, remaining: 150 } })).toEqual([ + { id: "weekly", valueLabel: "Weekly", usedPercent: 25, resetAt: undefined }, + ]) + }) + + it("returns undefined for junk", () => { + expect(parseKimiCodeUsage(null)).toBeUndefined() + expect(parseKimiCodeUsage({})).toBeUndefined() + }) +}) + +describe("parseZaiQuota", () => { + it("maps 5-hour and weekly limits, including credit counts", () => { + expect( + parseZaiQuota({ + code: 200, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 40, nextResetTime: 1_800_000_000_000 }, + { type: "CREDIT_LIMIT", unit: 6, number: 1, usage: 2000, currentValue: 500 }, + { type: "TIME_LIMIT", percentage: 99 }, + ], + }, + }), + ).toEqual([ + { id: "limit_0", valueLabel: "5-hour", usedPercent: 40, resetAt: 1_800_000_000_000 }, + { id: "limit_1", valueLabel: "Weekly: 500 / 2,000 credits", usedPercent: 25, resetAt: undefined }, + ]) + }) + + it("treats a business failure inside HTTP 200 as no data", () => { + expect(parseZaiQuota({ success: false, code: 1001, msg: "expired", data: { limits: [] } })).toBeUndefined() + expect(parseZaiQuota({ code: 1001, data: { limits: [{ type: "TOKENS_LIMIT" }] } })).toBeUndefined() + }) +}) + +describe("parseMiniMaxRemains", () => { + const row = { + model_name: "MiniMax-M2", + current_interval_total_count: 100, + current_interval_remaining_percent: 80, + end_time: 1_800_000_000_000, + current_weekly_total_count: 1000, + current_weekly_usage_count: 900, + weekly_end_time: 1_900_000_000_000, + } + + it("reports the 5-hour window and a weekly window when the plan has one", () => { + expect(parseMiniMaxRemains({ model_remains: [{ model_name: "other" }, row] })).toEqual([ + { id: "5h", valueLabel: "5-hour", usedPercent: 20, resetAt: 1_800_000_000_000 }, + { id: "weekly", valueLabel: "Weekly", usedPercent: 10, resetAt: 1_900_000_000_000 }, + ]) + }) + + it("reads counters as used (not left) for the legacy coding plan", () => { + const windows = parseMiniMaxRemains( + { + model_remains: [ + { + model_name: "MiniMax-M2", + current_interval_total_count: 100, + current_interval_usage_count: 30, + current_weekly_total_count: 1000, + current_weekly_usage_count: 900, + }, + ], + }, + false, + )! + expect(windows[0].usedPercent).toBe(30) + expect(windows[1].usedPercent).toBe(90) + }) + + it("omits the weekly window when the plan tier does not apply it (status 3)", () => { + const result = parseMiniMaxRemains({ model_remains: [{ ...row, current_weekly_status: 3 }] }) + expect(result?.map((w) => w.id)).toEqual(["5h"]) + }) + + it("rejects an error envelope or an empty list", () => { + expect(parseMiniMaxRemains({ base_resp: { status_code: 1004 }, model_remains: [row] })).toBeUndefined() + expect(parseMiniMaxRemains({ model_remains: [] })).toBeUndefined() + }) +}) + +describe("parseNanoGptUsage", () => { + it("derives percent from percentUsed or used/limit and keeps state in the label", () => { + expect( + parseNanoGptUsage({ + state: "grace", + limits: { dailyInputTokens: 1000 }, + dailyInputTokens: { used: 250 }, + monthly: { percentUsed: 0.4, resetAt: 1_800_000_000 }, + }), + ).toEqual([ + { id: "daily", valueLabel: "Daily (grace)", usedPercent: 25, resetAt: undefined }, + { id: "monthly", valueLabel: "Monthly (grace)", usedPercent: 40, resetAt: 1_800_000_000_000 }, + ]) + }) + + it("shows no percent for a degraded window", () => { + const daily = parseNanoGptUsage({ dailyInputTokens: { percentUsed: 0.9, degraded: true } })![0] + expect(daily.usedPercent).toBeUndefined() + }) +}) + +describe("parseOpenCodeGoUsage", () => { + it("maps rolling, weekly and monthly windows that carry a percent and a reset", () => { + expect( + parseOpenCodeGoUsage({ + usage: { + rolling: { percent: 12, resetsAt: 1_800_000_000 }, + weekly: { percent: 140, resetsAt: 1_800_000_001 }, + monthly: { percent: 5 }, + }, + }), + ).toEqual([ + { id: "5h", valueLabel: "5-hour", usedPercent: 12, resetAt: 1_800_000_000_000 }, + { id: "weekly", valueLabel: "Weekly", usedPercent: 100, resetAt: 1_800_000_001_000 }, + ]) + }) +}) + +describe("balance parser edge cases", () => { + it("DeepSeek: rejects non-object and empty payloads, and a row without a balance", () => { + expect(parseDeepSeekBalance(null)).toBeUndefined() + expect(parseDeepSeekBalance({ balance_infos: [] })).toBeUndefined() + expect(parseDeepSeekBalance({ balance_infos: [{ currency: "USD" }] })).toBeUndefined() + expect(parseDeepSeekBalance({ balance_infos: ["x", null] })).toBeUndefined() + }) + + it("DeepSeek: prefers a positive CNY balance over a zero USD one, then any USD row, then the first row", () => { + expect( + parseDeepSeekBalance({ + balance_infos: [ + { currency: "USD", total_balance: "0" }, + { currency: "CNY", total_balance: "12" }, + ], + }), + ).toEqual([{ id: "balance", valueLabel: "¥12.00 left" }]) + expect( + parseDeepSeekBalance({ + balance_infos: [ + { currency: "EUR", total_balance: "0" }, + { currency: "USD", total_balance: "0" }, + ], + }), + ).toEqual([{ id: "balance", valueLabel: "$0.00 left" }]) + expect(parseDeepSeekBalance({ balance_infos: [{ currency: "EUR", total_balance: "3" }] })).toEqual([ + { id: "balance", valueLabel: "$3.00 left" }, + ]) + }) + + it("Moonshot: returns undefined without an available_balance", () => { + expect(parseMoonshotBalance({ data: {} })).toBeUndefined() + expect(parseMoonshotBalance(null)).toBeUndefined() + }) +}) + +describe("parser edge cases", () => { + it("Kimi Code: skips a legacy usage block with no limit, and derives used from `used`", () => { + expect(parseKimiCodeUsage({ usage: { used: 5 } })).toBeUndefined() + expect(parseKimiCodeUsage({ usage: { limit: 100, used: 40, resetTime: 1_800_000_000 } })).toEqual([ + { id: "weekly", valueLabel: "Weekly", usedPercent: 40, resetAt: 1_800_000_000_000 }, + ]) + expect(parseKimiCodeUsage({ usage: { limit: 100 } })).toBeUndefined() + }) + + it("z.ai: ignores non-object, wrong-type and unit-less limits; falls back to counts for percent", () => { + expect(parseZaiQuota("nope")).toBeUndefined() + expect(parseZaiQuota({ data: {} })).toBeUndefined() + expect(parseZaiQuota({ data: { limits: [null, { type: "TIME_LIMIT" }] } })).toBeUndefined() + expect( + parseZaiQuota({ + data: { limits: [{ type: "TOKENS_LIMIT", unit: 9, number: 5, usage: 200, currentValue: 50 }] }, + }), + ).toEqual([{ id: "limit_0", valueLabel: "Tokens", usedPercent: 25, resetAt: undefined }]) + expect(parseZaiQuota({ data: { limits: [{ type: "CREDIT_LIMIT", usage: 0, currentValue: 0 }] } })).toEqual([ + { id: "limit_0", valueLabel: "Credits: 0 / 0 credits", usedPercent: undefined, resetAt: undefined }, + ]) + }) + + it("MiniMax: picks a chat-named or percent-bearing model when no minimax-m model exists", () => { + expect(parseMiniMaxRemains(null)).toBeUndefined() + expect(parseMiniMaxRemains({ model_remains: ["x", null] })).toBeUndefined() + + const byName = parseMiniMaxRemains({ + model_remains: [ + { model_name: "other", current_interval_remaining_percent: 10 }, + { model_name: "Chat", current_interval_remaining_percent: 60 }, + ], + }) + expect(byName?.[0].usedPercent).toBe(40) + + const byPercent = parseMiniMaxRemains({ + model_remains: [{ model_name: "a" }, { model_name: "b", current_interval_remaining_percent: 75 }], + }) + expect(byPercent?.[0].usedPercent).toBe(25) + + const first = parseMiniMaxRemains({ model_remains: [{ model_name: "only" }] }) + // A row with neither a figure nor a reset time has nothing to show. + expect(first).toBeUndefined() + expect( + parseMiniMaxRemains({ model_remains: [{ current_interval_remaining_percent: 5 }] })?.[0].usedPercent, + ).toBe(95) + }) + + it("MiniMax: prefers a MiniMax-M row that reports a percentage over a chat row", () => { + const windows = parseMiniMaxRemains({ + model_remains: [ + { model_name: "chat", current_interval_remaining_percent: 10 }, + { model_name: "MiniMax-M2", current_interval_remaining_percent: 80 }, + ], + }) + expect(windows?.[0].usedPercent).toBe(20) // the M row's 80% left, not the chat row's 10% left + }) + + it("MiniMax: shows the 5-hour window with only a reset time, and with only a figure", () => { + expect( + parseMiniMaxRemains({ model_remains: [{ model_name: "MiniMax-M2", end_time: 1_800_000_000_000 }] }), + ).toEqual([{ id: "5h", valueLabel: "5-hour", usedPercent: undefined, resetAt: 1_800_000_000_000 }]) + expect( + parseMiniMaxRemains({ + model_remains: [{ model_name: "MiniMax-M2", current_interval_remaining_percent: 70 }], + }), + ).toEqual([{ id: "5h", valueLabel: "5-hour", usedPercent: 30, resetAt: undefined }]) + }) + + it("MiniMax: skips a weekly window with no data and clamps an out-of-range percent", () => { + const [interval, weekly] = parseMiniMaxRemains({ + model_remains: [{ model_name: "MiniMax-M2", current_interval_remaining_percent: 150 }], + })! + expect(interval.usedPercent).toBe(0) + expect(weekly).toBeUndefined() + }) + + it("NanoGPT: handles non-object input, legacy `daily`, nested limits and unusable windows", () => { + expect(parseNanoGptUsage(null)).toBeUndefined() + expect(parseNanoGptUsage({})).toBeUndefined() + expect( + parseNanoGptUsage({ + daily: { used: 30, limit: 120, resetAt: "2026-05-01T00:00:00Z" }, + monthly: { used: 10, limits: { monthly: 100 } }, + period: { currentPeriodEnd: 1_800_000_000 }, + }), + ).toEqual([ + { id: "daily", valueLabel: "Daily", usedPercent: 25, resetAt: Date.parse("2026-05-01T00:00:00Z") }, + { id: "monthly", valueLabel: "Monthly", usedPercent: 10, resetAt: 1_800_000_000_000 }, + ]) + expect(parseNanoGptUsage({ daily: { used: 30, limits: { daily: 60 } } })?.[0].usedPercent).toBe(50) + expect(parseNanoGptUsage({ monthly: { used: 5, limit: 0 } })?.[0].usedPercent).toBeUndefined() + expect(parseNanoGptUsage({ monthly: {} })?.[0].usedPercent).toBeUndefined() + }) + + it("OpenCode Go: returns undefined without a usage block or with no usable window", () => { + expect(parseOpenCodeGoUsage(null)).toBeUndefined() + expect(parseOpenCodeGoUsage({})).toBeUndefined() + expect(parseOpenCodeGoUsage({ usage: { rolling: { percent: 10 } } })).toBeUndefined() + }) +}) + +describe("getProviderQuotas – Copilot window and misc", () => { + beforeEach(() => resetProviderQuotaCache()) + + it("shows a percent-only Copilot window, and 'Unlimited' for unlimited plans", async () => { + const { getGitHubCopilotQuota } = await import("../copilotQuota") + const mocked = vi.mocked(getGitHubCopilotQuota) + + mocked.mockResolvedValueOnce({ ok: true, quota: { unlimited: false, usedPercent: 41.6, fetchedAt: 1 } }) + const [pct] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never]) + expect(pct).toMatchObject({ ok: true, windows: [{ valueLabel: "42% used", usedPercent: 41.6 }] }) + + resetProviderQuotaCache() + mocked.mockResolvedValueOnce({ ok: true, quota: { unlimited: false, fetchedAt: 1 } }) + const [none] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never]) + // Unknown usage is reported as unknown (no bar), never as a measured 0%. + expect(none).toMatchObject({ ok: true, windows: [{ valueLabel: "Usage not reported" }] }) + expect((none as { windows: Array<{ usedPercent?: number }> }).windows[0].usedPercent).toBeUndefined() + + resetProviderQuotaCache() + mocked.mockResolvedValueOnce({ ok: true, quota: { unlimited: true, resetAt: 7, fetchedAt: 1 } }) + const [unl] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never]) + expect(unl).toMatchObject({ ok: true, windows: [{ valueLabel: "Unlimited", resetAt: 7 }] }) + }) + + it("hands the caller's abort signal to the Copilot lookup", async () => { + const { getGitHubCopilotQuota } = await import("../copilotQuota") + const mocked = vi.mocked(getGitHubCopilotQuota) + mocked.mockResolvedValueOnce({ ok: true, quota: { unlimited: true, fetchedAt: 1 } }) + const controller = new AbortController() + await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], { + signal: controller.signal, + }) + expect(mocked).toHaveBeenLastCalledWith({ force: false, signal: controller.signal }) + }) + + it("passes Copilot failures through with their reason and message", async () => { + const { getGitHubCopilotQuota } = await import("../copilotQuota") + vi.mocked(getGitHubCopilotQuota).mockResolvedValueOnce({ ok: false, reason: "error", message: "boom" }) + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never]) + expect(q).toMatchObject({ ok: false, reason: "error", message: "boom" }) + }) + + it("uses the default z.ai line when the profile sets none", async () => { + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response("{}")) + await getProviderQuotas([{ apiProvider: providerIdentifiers.zai, zaiApiKey: "k" } as never], {}, fetchSpy) + expect(fetchSpy.mock.calls[0][0]).toBe("https://api.z.ai/api/monitor/usage/quota/limit") + }) + + it("reports an error when a keyed provider answers with no usable data, and surfaces the last failure", async () => { + const [empty] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.nanogpt, nanoGptApiKey: "k" } as never], + {}, + vi.fn(async () => new Response("{}")), + ) + expect(empty).toMatchObject({ ok: false, reason: "error", message: "No usage data" }) + + resetProviderQuotaCache() + const [failed] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.minimax, minimaxApiKey: "k" } as never], + {}, + vi.fn(async () => new Response("", { status: 500 })), + ) + expect(failed).toMatchObject({ ok: false, reason: "error", message: "HTTP 500" }) + }) + + it("treats an auth failure on the first MiniMax URL as a missing/invalid key without trying the fallback", async () => { + const fetchSpy = vi.fn( + async (_url: RequestInfo | URL, _init?: RequestInit) => new Response("", { status: 401 }), + ) + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.minimax, minimaxApiKey: "k" } as never], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "noKey" }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("Kimi Code authentication", () => { + beforeEach(() => resetProviderQuotaCache()) + + it("does not query an OAuth profile, even when an old API key is still stored in it", async () => { + const fetchSpy = vi.fn() + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.kimiCode, + kimiCodeAuthMethod: "oauth", + kimiCodeApiKey: "stale-key", + } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "noKey" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("queries an API-key profile with its key", async () => { + const fetchSpy = vi.fn( + async (_url: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ usage: { limit: 100, used: 10 } })), + ) + await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.kimiCode, + kimiCodeAuthMethod: "api-key", + kimiCodeApiKey: "real-key", + } as never, + ], + {}, + fetchSpy, + ) + expect((fetchSpy.mock.calls[0][1]?.headers as Record).Authorization).toBe("Bearer real-key") + }) + + it("treats a profile with no recorded method as an API-key profile", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ usage: { limit: 100, used: 10 } }))) + await getProviderQuotas( + [{ apiProvider: providerIdentifiers.kimiCode, kimiCodeApiKey: "k" } as never], + {}, + fetchSpy, + ) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) +}) + +describe("getProviderQuotas – coding-plan providers", () => { + beforeEach(() => resetProviderQuotaCache()) + + it("queries z.ai on the host matching the profile's API line", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ code: 200, data: { limits: [] } }))) + await getProviderQuotas( + [{ apiProvider: providerIdentifiers.zai, zaiApiKey: "k", zaiApiLine: "china_coding" } as never], + {}, + fetchSpy, + ) + expect(fetchSpy).toHaveBeenCalledWith( + "https://open.bigmodel.cn/api/monitor/usage/quota/limit", + expect.anything(), + ) + }) + + it("falls back to the coding-plan endpoint for MiniMax and reads its counters as used", async () => { + const body = { + model_remains: [ + { + model_name: "MiniMax-M2", + current_interval_total_count: 100, + current_interval_usage_count: 30, + }, + ], + } + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 404 })) + .mockResolvedValueOnce(new Response(JSON.stringify(body))) + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.minimax, minimaxApiKey: "k" } as never], + {}, + fetchSpy, + ) + expect(fetchSpy.mock.calls.map((c) => c[0])).toEqual([ + "https://api.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ]) + expect(q).toMatchObject({ ok: true, windows: [{ id: "5h", usedPercent: 30 }] }) + }) + + it("uses the China MiniMax hosts for a minimaxi.com base URL", async () => { + const fetchSpy = vi.fn( + async (_url: RequestInfo | URL, _init?: RequestInit) => new Response("", { status: 404 }), + ) + await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.minimax, + minimaxApiKey: "k", + minimaxBaseUrl: "https://api.minimaxi.com/anthropic", + } as never, + ], + {}, + fetchSpy, + ) + expect(fetchSpy.mock.calls.map((c) => c[0])).toEqual([ + "https://api.minimaxi.com/v1/token_plan/remains", + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ]) + }) + + it("never sends a MiniMax key to an unknown base URL", async () => { + const fetchSpy = vi.fn() + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.minimax, + minimaxApiKey: "k", + minimaxBaseUrl: "https://gateway.example/anthropic", + } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("reports noKey for a Kimi Code profile signed in with OAuth (no API key)", async () => { + const fetchSpy = vi.fn() + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.kimiCode } as never], {}, fetchSpy) + expect(q).toMatchObject({ ok: false, reason: "noKey" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("calls NanoGPT, Kimi Code and OpenCode Go on their own hosts only", async () => { + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => new Response(JSON.stringify({}))) + await getProviderQuotas( + [ + { apiProvider: providerIdentifiers.nanogpt, nanoGptApiKey: "k" } as never, + { apiProvider: providerIdentifiers.kimiCode, kimiCodeApiKey: "k" } as never, + { apiProvider: providerIdentifiers.opencodeGo, opencodeGoApiKey: "k" } as never, + ], + {}, + fetchSpy, + ) + expect(fetchSpy.mock.calls.map((c) => c[0]).sort()).toEqual([ + "https://api.kimi.com/coding/v1/usages", + "https://nano-gpt.com/api/subscription/v1/usage", + "https://opencode.ai/zen/go/v1/usage", + ]) + }) +}) + +describe("getProviderQuotas", () => { + beforeEach(() => resetProviderQuotaCache()) + + const okFetch = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + + it("only queries providers that are connected", async () => { + const result = await getProviderQuotas([{ apiProvider: providerIdentifiers.anthropic } as never], {}, okFetch) + expect(result).toEqual([]) + expect(okFetch).not.toHaveBeenCalled() + }) + + it("returns Copilot credits formatted for display", async () => { + const [copilot] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never]) + expect(copilot).toMatchObject({ + providerId: providerIdentifiers.githubCopilot, + ok: true, + plan: "business", + windows: [{ id: "premium", valueLabel: "39,389.3 / 140,000 used", usedPercent: 28.1, resetAt: 1 }], + }) + }) + + it("reports noKey when the profile has no API key", async () => { + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.openrouter } as never], {}, okFetch) + expect(q).toMatchObject({ providerId: providerIdentifiers.openrouter, ok: false, reason: "noKey" }) + expect(okFetch).not.toHaveBeenCalled() + }) + + it("treats a malformed custom base URL as a different host and never sends the key", async () => { + const fetchSpy = vi.fn(okFetch) + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sk", + openRouterBaseUrl: "not a url", + } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("accepts the provider's own host written with a trailing path or slash", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sk", + openRouterBaseUrl: "https://openrouter.ai/api/v1/", + } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: true }) + }) + + describe("never sends a key to a base URL that is not the provider's own host", () => { + const providers = [ + { + id: providerIdentifiers.openrouter, + host: "openrouter.ai", + own: "https://openrouter.ai/api/v1", + settings: { openRouterApiKey: "sk" }, + urlKey: "openRouterBaseUrl", + }, + { + id: providerIdentifiers.deepseek, + host: "api.deepseek.com", + own: "https://api.deepseek.com", + settings: { deepSeekApiKey: "sk" }, + urlKey: "deepSeekBaseUrl", + }, + { + id: providerIdentifiers.moonshot, + host: "api.moonshot.ai", + own: "https://api.moonshot.ai/v1", + settings: { moonshotApiKey: "sk" }, + urlKey: "moonshotBaseUrl", + }, + ] as const + + const hostile = (host: string) => [ + "https://evil.example/api/v1", + `https://${host}.evil.com`, + `https://user@${host}.evil.com`, + `http://${host}`, + `https://${host}:8443`, + ] + + const cases = providers.flatMap((p) => hostile(p.host).map((url) => [p.id, url, p] as const)) + + it.each(cases)("%s: %s", async (_id, url, p) => { + const fetchSpy = vi.fn(okFetch) + const [q] = await getProviderQuotas( + [{ apiProvider: p.id, ...p.settings, [p.urlKey]: url } as never], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it.each(providers.map((p) => [p.id, p] as const))("%s: still queries its own host", async (_id, p) => { + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => okFetch()) + await getProviderQuotas([{ apiProvider: p.id, ...p.settings, [p.urlKey]: p.own } as never], {}, fetchSpy) + expect(fetchSpy).toHaveBeenCalled() + for (const [url] of fetchSpy.mock.calls) expect(String(url).startsWith(p.own)).toBe(true) + }) + }) + + it("calls the provider's own host with the saved key", async () => { + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => okFetch()) + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-test" } as never], + {}, + fetchSpy, + ) + expect(fetchSpy).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/key", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer sk-test" }) }), + ) + expect(q).toMatchObject({ ok: true, windows: [{ valueLabel: "$5.00 / $10.00 used" }] }) + }) + + it("maps 401 to noKey", async () => { + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.deepseek, deepSeekApiKey: "bad" } as never], + {}, + vi.fn(async () => new Response("", { status: 401 })), + ) + expect(q).toMatchObject({ providerId: providerIdentifiers.deepseek, ok: false, reason: "noKey" }) + }) + + it("queries a profile on the provider's own host even when an earlier profile uses a custom gateway", async () => { + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, _init?: RequestInit) => { + return new Response(JSON.stringify({ balance_infos: [{ currency: "USD", total_balance: "7.5" }] })) + }) + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.deepseek, + deepSeekApiKey: "gateway-key", + deepSeekBaseUrl: "https://gateway.example.com/v1", + } as never, + { apiProvider: providerIdentifiers.deepseek, deepSeekApiKey: "real-key" } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: true, windows: [{ valueLabel: "$7.50 left" }] }) + expect(String(fetchSpy.mock.calls[0][0])).toContain("api.deepseek.com") + // The gateway profile's key is never sent anywhere. + expect(JSON.stringify(fetchSpy.mock.calls)).not.toContain("gateway-key") + }) + + it("reports unsupported only when every keyed profile uses a custom gateway", async () => { + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.deepseek, + deepSeekApiKey: "k", + deepSeekBaseUrl: "https://gateway.example.com/v1", + } as never, + ], + {}, + vi.fn(), + ) + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + }) + + it("does not cache a missing key: adding one shows up on the next call", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const [before] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter } as never], + {}, + fetchSpy, + ) + expect(before).toMatchObject({ ok: false, reason: "noKey" }) + + const [after] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never], + {}, + fetchSpy, + ) + expect(after).toMatchObject({ ok: true }) + }) + + it("does not cache a failure: a retry within the TTL is fetched again", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + + const [first] = await getProviderQuotas(profiles, {}, fetchSpy) + expect(first).toMatchObject({ ok: false, reason: "error" }) + const [second] = await getProviderQuotas(profiles, {}, fetchSpy) + expect(second).toMatchObject({ ok: true }) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it("refetches when the API key changes", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + await getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-one" } as never], + {}, + fetchSpy, + ) + await getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-two" } as never], + {}, + fetchSpy, + ) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) + + it("does not hand a result fetched with one key to a caller using another, even while the first is in flight", async () => { + const pending = new Map void>() + const fetchSpy = vi.fn( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + pending.set(String((init?.headers as Record).Authorization), resolve) + }), + ) + const body = (remaining: number) => + new Response(JSON.stringify({ data: { limit: 10, limit_remaining: remaining } })) + + const first = getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-one" } as never], + {}, + fetchSpy, + ) + const second = getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-two" } as never], + {}, + fetchSpy, + ) + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2)) + // Each request went out with its own key, and nothing else. + expect([...pending.keys()].sort()).toEqual(["Bearer sk-one", "Bearer sk-two"]) + + // Answer them with different data, in the opposite order, so a mix-up would show. + pending.get("Bearer sk-two")!(body(2)) + pending.get("Bearer sk-one")!(body(9)) + + const [[a], [b]] = await Promise.all([first, second]) + expect(a).toMatchObject({ ok: true, windows: [{ usedPercent: 10 }] }) + expect(b).toMatchObject({ ok: true, windows: [{ usedPercent: 80 }] }) + }) + + it("drops a cached success when a forced refresh fails, so the next normal call retries", async () => { + const ok = () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } })) + const fetchSpy = vi + .fn() + .mockResolvedValueOnce(ok()) + .mockResolvedValueOnce(new Response("", { status: 500 })) + .mockResolvedValueOnce(ok()) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + + await getProviderQuotas(profiles, {}, fetchSpy) + const [failed] = await getProviderQuotas(profiles, { force: true }, fetchSpy) + expect(failed).toMatchObject({ ok: false, reason: "error" }) + + // The old success must not be served as if it were current. + const [retry] = await getProviderQuotas(profiles, {}, fetchSpy) + expect(retry).toMatchObject({ ok: true }) + expect(fetchSpy).toHaveBeenCalledTimes(3) + }) + + it("aborts the network request when the only caller goes away", async () => { + let seen: AbortSignal | undefined + const fetchSpy = vi.fn( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + seen = init?.signal ?? undefined + seen?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))) + }), + ) + const controller = new AbortController() + const result = getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never], + { signal: controller.signal }, + fetchSpy, + ) + await vi.waitFor(() => expect(seen).toBeDefined()) + expect(seen!.aborted).toBe(false) + + controller.abort() + await expect(result).rejects.toMatchObject({ name: "AbortError" }) + expect(seen!.aborted).toBe(true) + }) + + it("keeps the shared request running until its last caller leaves", async () => { + let seen: AbortSignal | undefined + let release: (r: Response) => void = () => {} + const fetchSpy = vi.fn( + (_url: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve) => { + seen = init?.signal ?? undefined + release = resolve + }), + ) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + const leaver = new AbortController() + + const staying = getProviderQuotas(profiles, {}, fetchSpy) + const leaving = getProviderQuotas(profiles, { signal: leaver.signal }, fetchSpy) + await vi.waitFor(() => expect(seen).toBeDefined()) + + leaver.abort() + await expect(leaving).rejects.toMatchObject({ name: "AbortError" }) + // Someone is still waiting, so the shared fetch must keep going. + expect(seen!.aborted).toBe(false) + + release(new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const [quota] = await staying + expect(quota).toMatchObject({ ok: true }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it("rejects at once with an already-aborted signal, without keeping a request alive", async () => { + const fetchSpy = vi.fn(() => new Promise(() => {})) + const controller = new AbortController() + controller.abort() + await expect( + getProviderQuotas( + [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never], + { signal: controller.signal }, + fetchSpy, + ), + ).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("shares one request between concurrent callers for the same credentials", async () => { + let release: (r: Response) => void = () => {} + const fetchSpy = vi.fn(() => new Promise((resolve) => (release = resolve))) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + + const a = getProviderQuotas(profiles, {}, fetchSpy) + const b = getProviderQuotas(profiles, { force: true }, fetchSpy) + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalled()) + release(new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + + const [ra, rb] = await Promise.all([a, b]) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(ra).toEqual(rb) + }) + + it("starts a new request instead of joining one whose last caller already left", async () => { + const body = () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } })) + // A host that ignores cancellation: the first request is still pending after its caller left. + let finishFirst: () => void = () => {} + const fetchSpy = vi + .fn() + .mockImplementationOnce(() => new Promise((resolve) => (finishFirst = () => resolve(body())))) + .mockImplementation(async () => body()) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + const controller = new AbortController() + + const first = getProviderQuotas(profiles, { signal: controller.signal }, fetchSpy) + await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)) + controller.abort() + await expect(first).rejects.toMatchObject({ name: "AbortError" }) + + // A new caller must get its own fetch, not join the cancelled one. + const [q] = await getProviderQuotas(profiles, {}, fetchSpy) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(q).toMatchObject({ providerId: providerIdentifiers.openrouter, ok: true }) + finishFirst() + }) + + it("does not let a cancelled request wipe a cached success", async () => { + const ok = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + await getProviderQuotas(profiles, {}, ok) + + const hanging = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => + (init?.signal as AbortSignal).addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ), + ), + ) + const controller = new AbortController() + const refresh = getProviderQuotas(profiles, { force: true, signal: controller.signal }, hanging) + await vi.waitFor(() => expect(hanging).toHaveBeenCalled()) + controller.abort() + await expect(refresh).rejects.toMatchObject({ name: "AbortError" }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The earlier success is still served from the cache. + await getProviderQuotas(profiles, {}, ok) + expect(ok).toHaveBeenCalledTimes(1) + }) + + describe("the cache lifetime", () => { + afterEach(() => vi.useRealTimers()) + + it.each([ + [59_999, 1], + [60_000, 2], + ])("a result %i ms old gives %i request(s) in total", async (age, requests) => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const fetchSpy = vi.fn( + async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } })), + ) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + await getProviderQuotas(profiles, {}, fetchSpy) + + vi.setSystemTime(1_000_000 + age) + await getProviderQuotas(profiles, {}, fetchSpy) + + expect(fetchSpy).toHaveBeenCalledTimes(requests) + }) + }) + + it("a failed request for one key does not remove the cached success of another key", async () => { + const ok = () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } })) + const fetchSpy = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => + String((init?.headers as Record)?.Authorization).includes("sk-two") + ? new Response("", { status: 500 }) + : ok(), + ) + const one = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-one" } as never] + const two = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk-two" } as never] + + await getProviderQuotas(one, {}, fetchSpy) // cached for sk-one + const [failed] = await getProviderQuotas(two, {}, fetchSpy) // fails for sk-two + expect(failed).toMatchObject({ ok: false }) + const [again] = await getProviderQuotas(one, {}, fetchSpy) + + expect(again).toMatchObject({ ok: true }) + expect(fetchSpy).toHaveBeenCalledTimes(2) // the third call came from the cache + }) + + it("caches results for the same set of providers unless forced", async () => { + const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + const profiles = [{ apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sk" } as never] + await getProviderQuotas(profiles, {}, fetchSpy) + await getProviderQuotas(profiles, {}, fetchSpy) + expect(fetchSpy).toHaveBeenCalledTimes(1) + await getProviderQuotas(profiles, { force: true }, fetchSpy) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) +}) + +describe("the active profile decides which account is queried", () => { + beforeEach(() => resetProviderQuotaCache()) + + const okSpend = () => vi.fn(async () => new Response(JSON.stringify({ data: { limit: 10, limit_remaining: 5 } }))) + + it("reports no key for an active profile without one, instead of using a sibling's key", async () => { + const fetchSpy = okSpend() + const [q] = await getProviderQuotas( + [ + { id: "active", apiProvider: providerIdentifiers.openrouter } as never, + { id: "other", apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sibling-key" } as never, + ], + { activeProfileId: "active" }, + fetchSpy, + ) + + expect(q).toMatchObject({ ok: false, reason: "noKey" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("reports a custom gateway as unsupported when the active profile uses one, not a sibling on the default host", async () => { + const fetchSpy = okSpend() + const [q] = await getProviderQuotas( + [ + { + id: "active", + apiProvider: providerIdentifiers.deepseek, + deepSeekApiKey: "k1", + deepSeekBaseUrl: "https://gateway.example/v1", + } as never, + { id: "other", apiProvider: providerIdentifiers.deepseek, deepSeekApiKey: "k2" } as never, + ], + { activeProfileId: "active" }, + fetchSpy, + ) + + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("queries with the active profile's key when a sibling also has one", async () => { + const fetchSpy = okSpend() + await getProviderQuotas( + [ + { id: "other", apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "sibling-key" } as never, + { id: "active", apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "active-key" } as never, + ], + { activeProfileId: "active" }, + fetchSpy, + ) + + expect(fetchSpy).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/key", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer active-key" }) }), + ) + }) + + it.each([ + ["there is no active profile", undefined], + ["the active profile is for another provider", "elsewhere"], + ["the active profile is not among the loaded ones", "gone"], + ])("keeps the usual choice of profile when %s", async (_name, activeProfileId) => { + const fetchSpy = okSpend() + await getProviderQuotas( + [ + { id: "elsewhere", apiProvider: providerIdentifiers.anthropic } as never, + { id: "keyless", apiProvider: providerIdentifiers.openrouter } as never, + { id: "keyed", apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "the-key" } as never, + ], + { activeProfileId }, + fetchSpy, + ) + + expect(fetchSpy).toHaveBeenCalledWith( + "https://openrouter.ai/api/v1/key", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer the-key" }) }), + ) + }) +}) + +describe("window names", () => { + beforeEach(() => resetProviderQuotaCache()) + + const quotaFrom = async (profile: Record, body: unknown) => { + const [q] = await getProviderQuotas( + [profile as never], + {}, + vi.fn(async () => new Response(JSON.stringify(body))), + ) + return q + } + + it("gives the fixed windows of OpenCode Go a structured name next to the English text", async () => { + const q = await quotaFrom( + { apiProvider: providerIdentifiers.opencodeGo, opencodeGoApiKey: "sk" }, + { + usage: { + rolling: { percent: 10, resetsAt: 1_800_000_000 }, + weekly: { percent: 20, resetsAt: 1_800_000_000 }, + monthly: { percent: 30, resetsAt: 1_800_000_000 }, + }, + }, + ) + expect(q).toMatchObject({ + ok: true, + windows: [ + { valueLabel: "5-hour", name: { key: "hours", count: 5 } }, + { valueLabel: "Weekly", name: { key: "weekly" } }, + { valueLabel: "Monthly", name: { key: "monthly" } }, + ], + }) + }) + + it("names a rolling window of whole days by its count", async () => { + const q = await quotaFrom( + { apiProvider: providerIdentifiers.zai, zaiApiKey: "sk" }, + { code: 200, data: { limits: [{ type: "TOKENS_LIMIT", unit: 6, number: 2, percentage: 10 }] } }, + ) + expect(q).toMatchObject({ ok: true, windows: [{ valueLabel: "14-day", name: { key: "days", count: 14 } }] }) + }) + + it("names a Copilot unlimited plan, and leaves a figure without a name", async () => { + const copilot = await import("../copilotQuota") + vi.mocked(copilot.getGitHubCopilotQuota).mockResolvedValueOnce({ + ok: true, + quota: { unlimited: true, resetAt: 7, fetchedAt: 1 }, + }) + const [unlimited] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], {}) + expect(unlimited).toMatchObject({ windows: [{ valueLabel: "Unlimited", name: { key: "unlimited" } }] }) + + resetProviderQuotaCache() + const [counted] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], {}) + expect(counted).toMatchObject({ ok: true, windows: [{ valueLabel: "39,389.3 / 140,000 used" }] }) + expect(counted.ok && counted.windows[0]).not.toHaveProperty("name") + }) +}) + +/** The profiles only: most of these tests do not look at the active profile's id. */ +const loadProfiles = async (...args: Parameters) => + (await loadProfilesWithActive(...args)).profiles + +describe("loadProfiles", () => { + it("loads every saved profile through the manager, with keys", async () => { + const source = { + listConfig: vi.fn(async () => [{ id: "a" }, { id: "b" }]), + getProfile: vi.fn( + async ({ id }: { id: string }) => ({ apiProvider: "x", openRouterApiKey: `key-${id}` }) as never, + ), + } + const profiles = await loadProfiles(source) + expect(profiles).toEqual([ + { apiProvider: "x", openRouterApiKey: "key-a" }, + { apiProvider: "x", openRouterApiKey: "key-b" }, + ]) + expect(source.getProfile).toHaveBeenCalledWith({ id: "a" }) + }) + + it("skips a profile that fails to load instead of failing the whole refresh", async () => { + const source = { + listConfig: vi.fn(async () => [{ id: "ok" }, { id: "broken" }]), + getProfile: vi.fn(async ({ id }: { id: string }) => { + if (id === "broken") throw new Error("corrupt secret storage") + return { apiProvider: "x" } as never + }), + } + expect(await loadProfiles(source)).toEqual([{ apiProvider: "x" }]) + }) + + it("fails when none of the saved profiles can be read, instead of reporting no providers", async () => { + const source = { + listConfig: vi.fn(async () => [{ id: "a" }, { id: "b" }]), + getProfile: vi.fn(async () => { + throw new Error("corrupt secret storage") + }), + } + await expect(loadProfiles(source)).rejects.toThrow("None of the saved profiles could be read") + }) + + it("returns an empty list when there are no saved profiles", async () => { + const source = { listConfig: vi.fn(async () => []), getProfile: vi.fn() } + expect(await loadProfiles(source)).toEqual([]) + }) + + it("puts the active profile first, so a provider with several profiles shows the account in use", async () => { + const source = { + listConfig: vi.fn(async () => [ + { id: "a", name: "work" }, + { id: "b", name: "personal" }, + { id: "c", name: "spare" }, + ]), + getProfile: vi.fn(async ({ id }: { id: string }) => ({ apiProvider: "x", id }) as never), + } + expect((await loadProfiles(source, undefined, "personal")).map((p) => (p as { id: string }).id)).toEqual([ + "b", + "a", + "c", + ]) + expect((await loadProfiles(source)).map((p) => (p as { id: string }).id)).toEqual(["a", "b", "c"]) + }) + + it("keeps the list order when the active profile is not among the saved ones", async () => { + const source = { + listConfig: vi.fn(async () => [{ id: "a", name: "work" }]), + getProfile: vi.fn(async () => ({ apiProvider: "x" }) as never), + } + expect(await loadProfiles(source, undefined, "gone")).toEqual([{ apiProvider: "x" }]) + }) + + it("returns the id of the active profile with the profiles", async () => { + const source = { + listConfig: vi.fn(async () => [ + { id: "a", name: "work" }, + { id: "b", name: "personal" }, + ]), + getProfile: vi.fn(async ({ id }: { id: string }) => ({ apiProvider: "x", id }) as never), + } + + expect(await loadProfilesWithActive(source, undefined, "personal")).toMatchObject({ activeProfileId: "b" }) + expect((await loadProfilesWithActive(source, undefined, "gone")).activeProfileId).toBeUndefined() + expect((await loadProfilesWithActive(source)).activeProfileId).toBeUndefined() + }) + + describe("a profile read that never settles", () => { + afterEach(() => vi.useRealTimers()) + + it("is skipped after the deadline, so the profiles that were read still produce a response", async () => { + vi.useFakeTimers() + const source = { + listConfig: vi.fn(async () => [{ id: "ok" }, { id: "stuck" }]), + getProfile: vi.fn(({ id }: { id: string }) => + id === "stuck" ? new Promise(() => {}) : Promise.resolve({ apiProvider: "x" } as never), + ), + } + + const loading = loadProfiles(source) + await vi.advanceTimersByTimeAsync(10_000) + + expect(await loading).toEqual([{ apiProvider: "x" }]) + }) + + it("fails when every read is stuck, instead of waiting for ever", async () => { + vi.useFakeTimers() + const source = { + listConfig: vi.fn(async () => [{ id: "a" }, { id: "b" }]), + getProfile: vi.fn(() => new Promise(() => {})), + } + + const loading = loadProfiles(source) + const settled = expect(loading).rejects.toThrow("None of the saved profiles could be read") + await vi.advanceTimersByTimeAsync(10_000) + + await settled + }) + + it("fails when the list of saved profiles never arrives, instead of waiting for ever", async () => { + vi.useFakeTimers() + const source = { listConfig: vi.fn(() => new Promise(() => {})), getProfile: vi.fn() } + + const loading = loadProfiles(source) + const settled = expect(loading).rejects.toThrow("Reading the saved profiles timed out") + await vi.advanceTimersByTimeAsync(10_000) + + await settled + expect(source.getProfile).not.toHaveBeenCalled() + }) + + it("passes a failure of the list read on as it is, and clears its timer", async () => { + vi.useFakeTimers() + const source = { + listConfig: vi.fn(async () => { + throw new Error("storage unavailable") + }), + getProfile: vi.fn(), + } + + await expect(loadProfiles(source)).rejects.toThrow("storage unavailable") + expect(vi.getTimerCount()).toBe(0) + }) + + it("does not wait for the deadline when the reads finish", async () => { + vi.useFakeTimers() + const source = { + listConfig: vi.fn(async () => [{ id: "a" }]), + getProfile: vi.fn(async () => ({ apiProvider: "x" }) as never), + } + + expect(await loadProfiles(source)).toEqual([{ apiProvider: "x" }]) + expect(vi.getTimerCount()).toBe(0) + }) + }) + + it("stops waiting for the profile list when the signal aborts, and reads no profile", async () => { + const source = { listConfig: vi.fn(() => new Promise(() => {})), getProfile: vi.fn() } + const controller = new AbortController() + const loading = loadProfiles(source, controller.signal) + controller.abort() + await expect(loading).rejects.toMatchObject({ name: "AbortError" }) + expect(source.getProfile).not.toHaveBeenCalled() + }) + + it("stops waiting for profile reads when the signal aborts", async () => { + const source = { + listConfig: vi.fn(async () => [{ id: "a" }]), + getProfile: vi.fn(() => new Promise(() => {})), + } + const controller = new AbortController() + const loading = loadProfiles(source, controller.signal) + await vi.waitFor(() => expect(source.getProfile).toHaveBeenCalled()) + controller.abort() + await expect(loading).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("requests no profile once the signal has aborted while the list was loading", async () => { + let release: (entries: { id: string }[]) => void = () => {} + const source = { + listConfig: vi.fn(() => new Promise<{ id: string }[]>((resolve) => (release = resolve))), + getProfile: vi.fn(), + } + const controller = new AbortController() + const loading = loadProfiles(source, controller.signal) + controller.abort() + release([{ id: "a" }]) + await expect(loading).rejects.toMatchObject({ name: "AbortError" }) + expect(source.getProfile).not.toHaveBeenCalled() + }) + + it("returns an empty list when there are no profiles", async () => { + expect(await loadProfiles({ listConfig: async () => [], getProfile: vi.fn() })).toEqual([]) + }) +}) + +describe("error handling never leaks credentials", () => { + beforeEach(() => resetProviderQuotaCache()) + + it("reports a generic message for network errors that embed the key", async () => { + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.deepseek, deepSeekApiKey: "sk-secret" } as never], + {}, + vi.fn(async () => { + throw new Error("connect ECONNREFUSED https://api.deepseek.com Authorization: Bearer sk-secret") + }), + ) + expect(q).toMatchObject({ ok: false, reason: "error", message: "Request failed" }) + expect(JSON.stringify(q)).not.toContain("sk-secret") + }) + + it("reports only the status for non-auth HTTP failures", async () => { + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.moonshot, moonshotApiKey: "sk" } as never], + {}, + vi.fn(async () => new Response("secret internal page", { status: 502 })), + ) + expect(q).toMatchObject({ ok: false, reason: "error", message: "HTTP 502" }) + }) + + it("treats an empty payload as an error", async () => { + const [q] = await getProviderQuotas( + [{ apiProvider: providerIdentifiers.deepseek, deepSeekApiKey: "sk" } as never], + {}, + vi.fn(async () => new Response(JSON.stringify({ balance_infos: [] }))), + ) + expect(q).toMatchObject({ ok: false, reason: "error", message: "No usage data" }) + }) + + it("surfaces Copilot failures with their reason", async () => { + const copilotMod = await import("../copilotQuota") + vi.mocked(copilotMod.getGitHubCopilotQuota).mockResolvedValueOnce({ ok: false, reason: "signedOut" }) + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], { + force: true, + }) + expect(q).toMatchObject({ ok: false, reason: "signedOut" }) + }) + + it("shows Copilot unlimited plans without numbers", async () => { + const copilotMod = await import("../copilotQuota") + vi.mocked(copilotMod.getGitHubCopilotQuota).mockResolvedValueOnce({ + ok: true, + quota: { plan: "enterprise", unlimited: true, fetchedAt: 1, resetAt: 2 }, + }) + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], { + force: true, + }) + expect(q).toMatchObject({ ok: true, windows: [{ id: "premium", valueLabel: "Unlimited", resetAt: 2 }] }) + }) + + it("falls back to a percent label when counts are missing", async () => { + const copilotMod = await import("../copilotQuota") + vi.mocked(copilotMod.getGitHubCopilotQuota).mockResolvedValueOnce({ + ok: true, + quota: { unlimited: false, usedPercent: 60, fetchedAt: 1 }, + }) + const [q] = await getProviderQuotas([{ apiProvider: providerIdentifiers.githubCopilot } as never], { + force: true, + }) + expect(q).toMatchObject({ ok: true, windows: [{ valueLabel: "60% used", usedPercent: 60 }] }) + }) +}) diff --git a/src/services/language-models/__tests__/quotaUtils.spec.ts b/src/services/language-models/__tests__/quotaUtils.spec.ts new file mode 100644 index 0000000000..30f5185140 --- /dev/null +++ b/src/services/language-models/__tests__/quotaUtils.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest" + +import { + QuotaHttpError, + asRecord, + clampPercent, + formatCredits, + formatMoney, + roundToTenth, + safeErrorMessage, + toEpochMs, + toNumber, +} from "../quotaUtils" + +describe("toEpochMs", () => { + it("treats numbers below 1e12 as epoch seconds and larger ones as milliseconds", () => { + expect(toEpochMs(1_800_000_000)).toBe(1_800_000_000_000) + expect(toEpochMs(1_800_000_000_000)).toBe(1_800_000_000_000) + }) + + it("parses date strings", () => { + expect(toEpochMs("2026-01-02T03:04:05Z")).toBe(Date.parse("2026-01-02T03:04:05Z")) + }) + + it("rejects zero, negatives, non-finite numbers, blank and unparseable strings, and other types", () => { + expect(toEpochMs(0)).toBeUndefined() + expect(toEpochMs(-5)).toBeUndefined() + expect(toEpochMs(Number.NaN)).toBeUndefined() + expect(toEpochMs(Number.POSITIVE_INFINITY)).toBeUndefined() + expect(toEpochMs(" ")).toBeUndefined() + expect(toEpochMs("not a date")).toBeUndefined() + expect(toEpochMs(null)).toBeUndefined() + expect(toEpochMs({})).toBeUndefined() + }) +}) + +describe("toNumber", () => { + it.each([ + [5, 5], + [0, 0], + ["12.5", 12.5], + [" 7 ", 7], + ])("accepts %j", (input, expected) => expect(toNumber(input)).toBe(expected)) + + it.each([[NaN], [Infinity], [""], [" "], ["abc"], [null], [undefined], [{}], [[]]])("rejects %j", (input) => + expect(toNumber(input)).toBeUndefined(), + ) +}) + +describe("asRecord", () => { + it("returns plain objects only", () => { + expect(asRecord({ a: 1 })).toEqual({ a: 1 }) + for (const bad of [null, undefined, [], "x", 1]) expect(asRecord(bad)).toBeUndefined() + }) +}) + +describe("clampPercent", () => { + it.each([ + [-5, 0], + [0, 0], + [42.5, 42.5], + [100, 100], + [250, 100], + ])("%j → %j", (input, expected) => expect(clampPercent(input)).toBe(expected)) +}) + +describe("formatMoney", () => { + it("formats with two decimals and a sign before the symbol", () => { + expect(formatMoney(12.4)).toBe("$12.40") + expect(formatMoney(-0.5)).toBe("-$0.50") + expect(formatMoney(8, "¥")).toBe("¥8.00") + }) +}) + +describe("formatCredits", () => { + it("keeps whole numbers whole and fractions to one decimal", () => { + expect(formatCredits(140000)).toBe("140,000") + expect(formatCredits(39389.34)).toBe("39,389.3") + }) +}) + +describe("roundToTenth", () => { + it("rounds to one decimal", () => { + expect(roundToTenth(39389.26)).toBe(39389.3) + expect(roundToTenth(5)).toBe(5) + }) +}) + +describe("safeErrorMessage", () => { + it("keeps the status for HTTP errors", () => { + expect(safeErrorMessage(new QuotaHttpError(503))).toBe("HTTP 503") + }) + + it("normalises timeouts and aborts", () => { + const timeout = Object.assign(new Error("x"), { name: "TimeoutError" }) + const abort = Object.assign(new Error("x"), { name: "AbortError" }) + expect(safeErrorMessage(timeout)).toBe("Request timed out") + expect(safeErrorMessage(abort)).toBe("Request timed out") + }) + + it("never leaks the text of arbitrary errors (could embed URLs, headers or keys)", () => { + const leaky = new Error( + "fetch failed: https://api.example.com/v1?key=sk-secret Authorization: Bearer sk-secret", + ) + expect(safeErrorMessage(leaky)).toBe("Request failed") + expect(safeErrorMessage("sk-secret")).toBe("Request failed") + expect(safeErrorMessage(undefined)).toBe("Request failed") + }) +}) diff --git a/src/services/language-models/copilotQuota.ts b/src/services/language-models/copilotQuota.ts new file mode 100644 index 0000000000..1194baae6e --- /dev/null +++ b/src/services/language-models/copilotQuota.ts @@ -0,0 +1,201 @@ +import * as vscode from "vscode" + +import { githubCopilotLanguageModel, type GitHubCopilotQuota, type GitHubCopilotQuotaResult } from "@roo-code/types" + +import { asRecord, clampPercent, roundToTenth, safeErrorMessage, toNumber } from "./quotaUtils" + +/** + * GitHub Copilot premium-request allowance for the Usage tab. + * + * Source: `GET https://api.github.com/copilot_internal/user`, the endpoint + * Copilot Chat's own usage panel reads. It returns + * `quota_snapshots.premium_interactions` plus `quota_reset_date`. + * + * Auth reuses the GitHub session VS Code already holds for Copilot (silent: + * never prompts). The token is only ever sent to api.github.com. + */ + +const COPILOT_USER_URL = "https://api.github.com/copilot_internal/user" +const REQUEST_TIMEOUT_MS = 15_000 +/** Avoid hammering the endpoint when the Usage tab is reopened repeatedly. */ +const CACHE_TTL_MS = 60_000 + +/** + * Parses the endpoint payload. Pure, so it is unit-tested against recorded + * shapes. Returns undefined when the payload has no premium snapshot at all. + * + * Semantics mirror Copilot Chat (CopilotUserQuotaInfo): + * - `quota_remaining` is the precise (fractional) twin of `remaining`; prefer it. + * - used = entitlement - remaining, so the figure matches GitHub's own panel. + * - `percent_remaining` is a server-computed fallback when counts are missing. + */ +export function parseCopilotQuota(payload: unknown, now = Date.now()): GitHubCopilotQuota | undefined { + const root = asRecord(payload) + const snapshot = asRecord(asRecord(root?.quota_snapshots)?.premium_interactions) + if (!root || !snapshot) return undefined + + const plan = typeof root.copilot_plan === "string" ? root.copilot_plan : undefined + const resetRaw = root.quota_reset_date_utc ?? root.quota_reset_date + const resetAt = + typeof resetRaw === "string" && !Number.isNaN(Date.parse(resetRaw)) ? Date.parse(resetRaw) : undefined + + if (snapshot.unlimited === true) { + return { plan, unlimited: true, resetAt, fetchedAt: now } + } + + const entitlement = toNumber(snapshot.entitlement) + const remaining = toNumber(snapshot.quota_remaining) ?? toNumber(snapshot.remaining) + + let used: number | undefined + let usedPercent: number | undefined + + if (entitlement !== undefined && entitlement > 0 && remaining !== undefined) { + used = roundToTenth(Math.min(entitlement, Math.max(0, entitlement - remaining))) + usedPercent = clampPercent((used / entitlement) * 100) + } else { + const percentRemaining = toNumber(snapshot.percent_remaining) + if (percentRemaining !== undefined) usedPercent = clampPercent(100 - percentRemaining) + } + + return { + plan, + unlimited: false, + entitlement: entitlement !== undefined && entitlement > 0 ? entitlement : undefined, + used, + usedPercent, + resetAt, + fetchedAt: now, + } +} + +/** Rejects as soon as `signal` aborts. The pending work cannot be cancelled, but nobody waits for it any more. */ +function raceAbort(work: Promise, signal: AbortSignal): Promise { + // The caller has checked that `signal` is not aborted yet (an abort event does not fire again for a signal that + // already is), and nothing can abort it between that check and this call. + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason) + signal.addEventListener("abort", abort, { once: true }) + work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +/** Each lookup is bounded by `signal`, so a session request that never settles cannot hold the quota request open. */ +async function getGitHubToken(signal: AbortSignal): Promise { + const { authProviderId, authScopeSets } = githubCopilotLanguageModel + for (const scopes of authScopeSets) { + // A lookup that has been cancelled is not started: the session request is made before it is raced. + signal.throwIfAborted() + const session = await raceAbort( + Promise.resolve(vscode.authentication.getSession(authProviderId, scopes, { silent: true })), + signal, + ) + if (session) return session.accessToken + } + return undefined +} + +let cache: { at: number; result: GitHubCopilotQuotaResult } | undefined +let inFlight: + | { + request: Promise + /** Callers still waiting. The shared fetch is aborted only when the last one leaves. */ + waiters: number + controller: AbortController + } + | undefined + +/** Rejects when `signal` aborts, without cancelling `work` (other callers may still be using it). */ +function untilAborted(work: Promise, signal: AbortSignal | undefined, onAbort: () => void): Promise { + if (!signal) return work + return new Promise((resolve, reject) => { + const abort = () => { + onAbort() + reject(new DOMException("Aborted", "AbortError")) + } + signal.addEventListener("abort", abort, { once: true }) + work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +/** + * Fetches the quota. Concurrent callers share one request; successful results + * are cached briefly unless `force` is set (the Refresh button). `signal` lets a caller stop waiting; the + * network work itself stops when the last waiting caller has gone. + */ +export function getGitHubCopilotQuota( + { force = false, signal }: { force?: boolean; signal?: AbortSignal } = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")) + if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return Promise.resolve(cache.result) + + if (!inFlight) { + const controller = new AbortController() + const fetchQuota = async (): Promise => { + try { + const token = await getGitHubToken( + AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]), + ) + if (!token) return { ok: false, reason: "signedOut" } + + const response = await fetchImpl(COPILOT_USER_URL, { + method: "GET", + headers: { + Authorization: `token ${token}`, + Accept: "application/json", + "Editor-Version": `vscode/${vscode.version}`, + "X-Github-Api-Version": "2025-04-01", + }, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]), + }) + + if (response.status === 401) return { ok: false, reason: "signedOut" } + if (response.status === 403 || response.status === 404) return { ok: false, reason: "noCopilot" } + if (!response.ok) return { ok: false, reason: "error", message: `GitHub API error ${response.status}` } + + const quota = parseCopilotQuota(await response.json()) + // A 403 or 404 is how GitHub says there is no Copilot. A success without a premium snapshot is more likely + // a changed payload on this undocumented endpoint, and must not be shown as "no Copilot". + return quota + ? { ok: true, quota } + : { ok: false, reason: "error", message: "GitHub returned no premium request quota" } + } catch (error) { + return { ok: false, reason: "error", message: safeErrorMessage(error) } + } + } + + const request: Promise = fetchQuota() + .then((result) => { + // One place for every outcome: a success is cached, and anything else (signed out, no Copilot, an + // HTTP error, a thrown or cancelled request) drops an older success so it is not served as current. + // A cancelled request says nothing about the account, so it leaves the cache alone. + if (!controller.signal.aborted) cache = result.ok ? { at: Date.now(), result } : undefined + return result + }) + .finally(() => { + if (inFlight?.request === request) inFlight = undefined + }) + inFlight = { request, waiters: 0, controller } + } + + const shared = inFlight + shared.waiters++ + let left = false + const leave = () => { + if (left) return + left = true + // Last one out cancels the network work; earlier leavers just stop waiting. + if (--shared.waiters === 0) { + shared.controller.abort() + // Do not let a later caller join a request that is already cancelled (it would only get "cancelled"). + if (inFlight === shared) inFlight = undefined + } + } + return untilAborted(shared.request, signal, leave).finally(leave) +} + +/** @internal test hook */ +export function resetGitHubCopilotQuotaCache() { + cache = undefined + inFlight = undefined +} diff --git a/src/services/language-models/providerQuotas.ts b/src/services/language-models/providerQuotas.ts new file mode 100644 index 0000000000..40994378e5 --- /dev/null +++ b/src/services/language-models/providerQuotas.ts @@ -0,0 +1,786 @@ +import { + providerIdentifiers, + type ProviderQuota, + type ProviderQuotaWindow, + type ProviderSettings, + type ProviderSettingsWithId, +} from "@roo-code/types" + +import { getGitHubCopilotQuota } from "./copilotQuota" +import { + QuotaHttpError, + asRecord, + clampPercent, + formatCredits, + formatMoney, + safeErrorMessage, + toEpochMs, + toNumber, +} from "./quotaUtils" + +/** + * Live limits/balances for every connected provider that exposes a usage API, + * for the Usage tab's "Provider limits" section. + * + * Endpoints are each provider's own usage API. Credentials come from the + * user's saved Zoo profiles and are sent only to that provider's own host. + * Providers without a usage API are simply not listed. + */ + +const REQUEST_TIMEOUT_MS = 15_000 +const CACHE_TTL_MS = 60_000 + +// Parsers (pure; unit-tested) ---------------------------------------------------------------------- + +/** OpenRouter `GET /api/v1/key` → key limit, or monthly spend when unlimited. */ +export function parseOpenRouterKey(payload: unknown): ProviderQuotaWindow[] | undefined { + const data = asRecord(asRecord(payload)?.data) + if (!data) return undefined + + const limit = toNumber(data.limit) + const remaining = toNumber(data.limit_remaining) + + if (limit !== undefined && limit > 0 && remaining !== undefined) { + const used = Math.max(0, limit - remaining) + return [ + { + id: "key_limit", + valueLabel: `${formatMoney(used)} / ${formatMoney(limit)} used`, + usedPercent: clampPercent((used / limit) * 100), + }, + ] + } + + const monthly = toNumber(data.usage_monthly) + if (monthly !== undefined) return [{ id: "spend", valueLabel: `${formatMoney(monthly)} spent this month` }] + + // OpenRouter documents `usage` as all-time credit use, so it must not be shown as this month's spend. + const total = toNumber(data.usage) + return total === undefined ? undefined : [{ id: "spend", valueLabel: `${formatMoney(total)} spent in total` }] +} + +/** DeepSeek `GET /user/balance` → prepaid balance (prefers a positive USD balance). */ +export function parseDeepSeekBalance(payload: unknown): ProviderQuotaWindow[] | undefined { + const infos = asRecord(payload)?.balance_infos + if (!Array.isArray(infos) || infos.length === 0) return undefined + + const rows = infos.map(asRecord).filter((r): r is Record => !!r) + const pick = + rows.find((r) => r.currency === "USD" && (toNumber(r.total_balance) ?? 0) > 0) ?? + rows.find((r) => r.currency === "CNY" && (toNumber(r.total_balance) ?? 0) > 0) ?? + rows.find((r) => r.currency === "USD") ?? + rows[0] + + const balance = toNumber(pick?.total_balance) + if (balance === undefined) return undefined + return [{ id: "balance", valueLabel: `${formatMoney(balance, pick?.currency === "CNY" ? "¥" : "$")} left` }] +} + +/** Moonshot `GET /v1/users/me/balance` → available balance. */ +export function parseMoonshotBalance(payload: unknown): ProviderQuotaWindow[] | undefined { + const balance = toNumber(asRecord(asRecord(payload)?.data)?.available_balance) + return balance === undefined ? undefined : [{ id: "balance", valueLabel: `${formatMoney(balance)} left` }] +} + +/** "5-hour", "Weekly", "3d", … for a rolling window of the given length. */ +export function windowLabel(seconds: number | undefined, fallback: string): string { + if (!seconds || seconds <= 0) return fallback + if (seconds === 7 * 86400) return "Weekly" + if (seconds % 86400 === 0) return `${seconds / 86400}-day` + if (seconds % 3600 === 0) return `${seconds / 3600}-hour` + return fallback +} + +const FIXED_WINDOW_NAMES: Record> = { + Weekly: { key: "weekly" }, + Daily: { key: "daily" }, + Monthly: { key: "monthly" }, + Unlimited: { key: "unlimited" }, +} + +/** The structured name of a window whose label is only its name, so it can be translated. Anything else is left as is. */ +function windowName(label: string): ProviderQuotaWindow["name"] { + const fixed = FIXED_WINDOW_NAMES[label] + if (fixed) return fixed + const rolling = /^(\d+)-(hour|day)$/.exec(label) + return rolling ? { key: rolling[2] === "hour" ? "hours" : "days", count: Number(rolling[1]) } : undefined +} + +const withNames = (windows: ProviderQuotaWindow[]): ProviderQuotaWindow[] => + windows.map((w) => { + const name = windowName(w.valueLabel) + return name ? { ...w, name } : w + }) + +/** Kimi Code `GET /coding/v1/usages` → 5-hour / weekly / monthly usage. */ +export function parseKimiCodeUsage(payload: unknown): ProviderQuotaWindow[] | undefined { + const root = asRecord(payload) + if (!root) return undefined + const windows: ProviderQuotaWindow[] = [] + + const usages = asRecord(root.usages) + if (usages) { + // `used_ratio` is a 0-1 fraction, in the order Kimi's own CLI renders it. + for (const [key, label] of [ + ["limit_5h", "5-hour"], + ["limit_7d", "Weekly"], + ["limit_month_total", "Monthly"], + ] as const) { + const entry = asRecord(usages[key]) + const ratio = toNumber(entry?.used_ratio) + if (ratio === undefined) continue + windows.push({ + id: key, + valueLabel: label, + usedPercent: clampPercent(ratio * 100), + resetAt: toEpochMs(entry?.reset_time), + }) + } + return windows.length ? windows : undefined + } + + // Pre-ratio payload: one counted weekly `usage` block. + const usage = asRecord(root.usage) + const limit = toNumber(usage?.limit) + if (usage && limit && limit > 0) { + const used = toNumber(usage.used) + const remaining = toNumber(usage.remaining) + const usedAmount = used ?? (remaining !== undefined ? limit - remaining : undefined) + if (usedAmount !== undefined) { + windows.push({ + id: "weekly", + valueLabel: "Weekly", + usedPercent: clampPercent((usedAmount / limit) * 100), + resetAt: toEpochMs(usage.resetTime), + }) + } + } + return windows.length ? windows : undefined +} + +/** + * z.ai / Zhipu `GET /api/monitor/usage/quota/limit`. Business failures arrive + * as HTTP 200 with `{ code, msg, success: false }`, so the envelope is checked + * first. TOKENS_LIMIT / CREDIT_LIMIT entries are the 5-hour and weekly windows. + */ +export function parseZaiQuota(payload: unknown): ProviderQuotaWindow[] | undefined { + const root = asRecord(payload) + if (!root) return undefined + const code = root.code + if (root.success === false || (code !== undefined && code !== null && code !== 200)) return undefined + + const limits = asRecord(root.data)?.limits + if (!Array.isArray(limits)) return undefined + + const windows: ProviderQuotaWindow[] = [] + for (const raw of limits) { + const limit = asRecord(raw) + if (!limit || (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT")) continue + + // unit 3 = hours, unit 6 = weeks. + const number = toNumber(limit.number) + const unitSeconds = limit.unit === 3 ? 3600 : limit.unit === 6 ? 7 * 86400 : undefined + const seconds = number && unitSeconds ? number * unitSeconds : undefined + + const total = toNumber(limit.usage) + const consumed = toNumber(limit.currentValue) + const percentage = toNumber(limit.percentage) + const usedPercent = + percentage !== undefined + ? clampPercent(percentage) + : total && total > 0 && consumed !== undefined + ? clampPercent((consumed / total) * 100) + : undefined + + windows.push({ + id: `limit_${windows.length}`, + valueLabel: + limit.type === "CREDIT_LIMIT" && total !== undefined && consumed !== undefined + ? `${windowLabel(seconds, "Credits")}: ${formatCredits(consumed)} / ${formatCredits(total)} credits` + : windowLabel(seconds, "Tokens"), + usedPercent, + resetAt: toEpochMs(limit.nextResetTime), + }) + } + return windows.length ? windows : undefined +} + +/** + * MiniMax `GET /v1/token_plan/remains` (or the legacy coding-plan endpoint) → + * 5-hour interval and, when the plan has one, weekly usage for the chat model. + */ +export function parseMiniMaxRemains(payload: unknown, isTokenPlan = true): ProviderQuotaWindow[] | undefined { + const root = asRecord(payload) + const base = asRecord(root?.base_resp) + if (base && base.status_code !== 0) return undefined + const remains = root?.model_remains + if (!Array.isArray(remains) || remains.length === 0) return undefined + + const rows = remains.map(asRecord).filter((r): r is Record => !!r) + const name = (r: Record) => (typeof r.model_name === "string" ? r.model_name : "") + const model = + rows.find( + (r) => + /^minimax-m/i.test(name(r)) && + ((toNumber(r.current_interval_total_count) ?? 0) > 0 || + typeof r.current_interval_remaining_percent === "number"), + ) ?? + rows.find((r) => ["general", "chat", "text"].includes(name(r).toLowerCase())) ?? + rows.find((r) => typeof r.current_interval_remaining_percent === "number") ?? + rows[0] + if (!model) return undefined + + const usedPercentOf = (prefix: "interval" | "weekly"): number | undefined => { + const remainingPercent = toNumber(model[`current_${prefix}_remaining_percent`]) + if (remainingPercent !== undefined) return clampPercent(100 - clampPercent(remainingPercent)) + const total = toNumber(model[`current_${prefix}_total_count`]) + const raw = toNumber(model[`current_${prefix}_usage_count`]) + if (!total || total <= 0 || raw === undefined) return undefined + // Token-plan counters report what is left; coding-plan counters report what is used. + const used = isTokenPlan ? Math.max(0, total - raw) : raw + return clampPercent((used / total) * 100) + } + + const windows: ProviderQuotaWindow[] = [] + + // A window with neither a figure nor a reset time would show a label with no value. + const intervalPercent = usedPercentOf("interval") + const intervalReset = toEpochMs(model.end_time) + if (intervalPercent !== undefined || intervalReset !== undefined) { + windows.push({ id: "5h", valueLabel: "5-hour", usedPercent: intervalPercent, resetAt: intervalReset }) + } + + // Status 3 = the window does not apply to this plan tier. + const weeklyApplies = toNumber(model.current_weekly_status) !== 3 + const weeklyPercent = usedPercentOf("weekly") + if (weeklyApplies && weeklyPercent !== undefined) { + windows.push({ + id: "weekly", + valueLabel: "Weekly", + usedPercent: weeklyPercent, + resetAt: toEpochMs(model.weekly_end_time), + }) + } + return windows.length ? windows : undefined +} + +/** NanoGPT `GET /api/subscription/v1/usage` → daily / weekly / monthly allowance. */ +export function parseNanoGptUsage(payload: unknown): ProviderQuotaWindow[] | undefined { + const root = asRecord(payload) + if (!root) return undefined + + const state = typeof root.state === "string" ? root.state : "active" + const limits = asRecord(root.limits) + const dailyQuota = root.dailyInputTokens !== undefined ? root.dailyInputTokens : root.daily + const monthly = asRecord(root.monthly) + + const entries: { id: string; label: string; quota: unknown; limit: unknown; resetAt: unknown }[] = [ + { + id: "daily", + label: "Daily", + quota: dailyQuota, + limit: + root.dailyInputTokens !== undefined + ? limits?.dailyInputTokens + : (asRecord(dailyQuota)?.limit ?? asRecord(asRecord(dailyQuota)?.limits)?.daily), + resetAt: asRecord(dailyQuota)?.resetAt, + }, + { + id: "weekly", + label: "Weekly", + quota: root.weeklyInputTokens, + limit: limits?.weeklyInputTokens, + resetAt: asRecord(root.weeklyInputTokens)?.resetAt, + }, + { + id: "monthly", + label: "Monthly", + quota: monthly, + limit: monthly?.limit ?? asRecord(monthly?.limits)?.monthly, + resetAt: monthly?.resetAt ?? asRecord(root.period)?.currentPeriodEnd, + }, + ] + + const windows: ProviderQuotaWindow[] = [] + for (const { id, label, quota, limit, resetAt } of entries) { + const q = asRecord(quota) + if (!q) continue + let usedPercent: number | undefined + if (!q.degraded) { + const fraction = toNumber(q.percentUsed) + const used = toNumber(q.used) + const cap = toNumber(limit) + if (fraction !== undefined) usedPercent = clampPercent(fraction * 100) + else if (used !== undefined && cap !== undefined && cap > 0) usedPercent = clampPercent((used / cap) * 100) + } + windows.push({ + id, + valueLabel: state !== "active" ? `${label} (${state})` : label, + usedPercent, + resetAt: toEpochMs(resetAt), + }) + } + return windows.length ? windows : undefined +} + +/** OpenCode Go `GET /zen/go/v1/usage` (API key) → rolling 5-hour, weekly, monthly percent used. */ +export function parseOpenCodeGoUsage(payload: unknown): ProviderQuotaWindow[] | undefined { + const usage = asRecord(asRecord(payload)?.usage) + if (!usage) return undefined + + const windows: ProviderQuotaWindow[] = [] + for (const [id, field, label] of [ + ["5h", "rolling", "5-hour"], + ["weekly", "weekly", "Weekly"], + ["monthly", "monthly", "Monthly"], + ] as const) { + const entry = asRecord(usage[field]) + const percent = toNumber(entry?.percent) + const resetAt = toEpochMs(entry?.resetsAt) + if (percent === undefined || resetAt === undefined) continue + windows.push({ id, valueLabel: label, usedPercent: clampPercent(percent), resetAt }) + } + return windows.length ? windows : undefined +} + +// Fetchers ---------------------------------------------------------------------------------------- + +type Fetch = typeof fetch + +async function getJson(fetchImpl: Fetch, url: string, apiKey: string, signal: AbortSignal): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + const response = await fetchImpl(url, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal: AbortSignal.any([signal, timeout]), + }) + if (response.status === 401 || response.status === 403) throw new AuthError() + if (!response.ok) throw new QuotaHttpError(response.status) + return response.json() +} + +class AuthError extends Error { + constructor() { + super("Invalid or expired API key") + } +} + +interface KeyedProvider { + providerId: string + /** + * Returns the API key and the usage URLs to try, in order. `urls` is + * undefined when the profile targets a host we must not send the key to + * (a custom gateway); the provider is then reported as unsupported. + */ + credentials: (s: ProviderSettings) => { apiKey?: string; urls?: string[] } + parse: (payload: unknown, urlIndex: number) => ProviderQuotaWindow[] | undefined +} + +const sameOrigin = (a: string, b: string) => { + try { + return new URL(a).origin === new URL(b).origin + } catch { + return false + } +} + +/** The provider's own default host, unless the profile points at a different one. */ +const ownHost = (defaultBase: string, path: string, baseUrl?: string): string[] | undefined => + baseUrl && !sameOrigin(baseUrl, defaultBase) ? undefined : [defaultBase + path] + +/** z.ai (international) and Zhipu (China) host the same monitor API on different origins. */ +const ZAI_ORIGINS: Record = { + international_coding: "https://api.z.ai", + international_api: "https://api.z.ai", + china_coding: "https://open.bigmodel.cn", + china_api: "https://open.bigmodel.cn", +} + +const KEYED_PROVIDERS: KeyedProvider[] = [ + { + providerId: providerIdentifiers.openrouter, + credentials: (s) => ({ + apiKey: s.openRouterApiKey, + urls: ownHost("https://openrouter.ai/api/v1", "/key", s.openRouterBaseUrl), + }), + parse: parseOpenRouterKey, + }, + { + providerId: providerIdentifiers.deepseek, + credentials: (s) => ({ + apiKey: s.deepSeekApiKey, + urls: ownHost("https://api.deepseek.com", "/user/balance", s.deepSeekBaseUrl), + }), + parse: parseDeepSeekBalance, + }, + { + providerId: providerIdentifiers.moonshot, + credentials: (s) => ({ + apiKey: s.moonshotApiKey, + urls: ownHost("https://api.moonshot.ai/v1", "/users/me/balance", s.moonshotBaseUrl), + }), + parse: parseMoonshotBalance, + }, + { + // Only API-key profiles are queried; OAuth sign-ins have no key to send. + providerId: providerIdentifiers.kimiCode, + credentials: (s) => ({ + // An OAuth sign-in has no API key to send, even if an old key is still in the profile. + apiKey: s.kimiCodeAuthMethod === "oauth" ? undefined : s.kimiCodeApiKey, + urls: ["https://api.kimi.com/coding/v1/usages"], + }), + parse: parseKimiCodeUsage, + }, + { + providerId: providerIdentifiers.zai, + credentials: (s) => ({ + apiKey: s.zaiApiKey, + urls: [`${ZAI_ORIGINS[s.zaiApiLine ?? "international_coding"]}/api/monitor/usage/quota/limit`], + }), + parse: parseZaiQuota, + }, + { + providerId: providerIdentifiers.minimax, + credentials: (s) => { + const base = s.minimaxBaseUrl || "https://api.minimax.io/anthropic" + if (sameOrigin(base, "https://api.minimax.io")) { + return { + apiKey: s.minimaxApiKey, + urls: [ + "https://api.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ], + } + } + if (sameOrigin(base, "https://api.minimaxi.com")) { + return { + apiKey: s.minimaxApiKey, + urls: [ + "https://api.minimaxi.com/v1/token_plan/remains", + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ], + } + } + return { apiKey: s.minimaxApiKey } + }, + // The first URL is the token plan, whose counters report what is left. + parse: (payload, urlIndex) => parseMiniMaxRemains(payload, urlIndex === 0), + }, + { + providerId: providerIdentifiers.nanogpt, + credentials: (s) => ({ + apiKey: s.nanoGptApiKey, + urls: ["https://nano-gpt.com/api/subscription/v1/usage"], + }), + parse: parseNanoGptUsage, + }, + { + providerId: providerIdentifiers.opencodeGo, + credentials: (s) => ({ + apiKey: s.opencodeGoApiKey, + urls: ["https://opencode.ai/zen/go/v1/usage"], + }), + parse: parseOpenCodeGoUsage, + }, +] + +type KeyedCredentials = ReturnType + +/** + * The credentials to query with: a profile whose host is safe to query wins over + * one pointed at a custom gateway, which is only a fallback (reported as + * unsupported when nothing better exists). + */ +function pickCredentials( + provider: KeyedProvider, + profiles: ProviderSettings[], + activeProfileId?: string, +): KeyedCredentials | undefined { + // The active profile of this provider is the account the requests use: take its credentials as they are, so a + // keyless one is reported as no key and a custom gateway as unsupported, not replaced by a sibling profile. + const active = activeProfileId + ? (profiles as ProviderSettingsWithId[]).find( + (p) => p.id === activeProfileId && p.apiProvider === provider.providerId, + ) + : undefined + if (active) return provider.credentials(active) + + const withKey = profiles + .filter((p) => p.apiProvider === provider.providerId) + .map(provider.credentials) + .filter((c) => !!c.apiKey) + return withKey.find((c) => c.urls?.length) ?? withKey[0] +} + +/** Resolves without any network call when there is nothing to query. */ +function unqueryable(provider: KeyedProvider, creds: KeyedCredentials | undefined): ProviderQuota | undefined { + const base = { providerId: provider.providerId, ok: false, fetchedAt: Date.now() } as const + if (!creds?.apiKey) return { ...base, reason: "noKey" } + // SAFETY: never send a key to a host other than the provider's own API. A + // profile pointed at a custom gateway is reported as unsupported instead. + if (!creds.urls?.length) return { ...base, reason: "unsupported" } + return undefined +} + +async function fetchKeyed( + provider: KeyedProvider, + apiKey: string, + urls: string[], + fetchImpl: Fetch, + signal: AbortSignal, +): Promise { + const now = Date.now() + + try { + // Try each URL in order; a failing fallback only matters if no URL yields data. + let windows: ProviderQuotaWindow[] | undefined + let lastError: unknown + for (const [index, url] of urls.entries()) { + try { + windows = provider.parse(await getJson(fetchImpl, url, apiKey, signal), index) + if (windows?.length) break + } catch (error) { + if (error instanceof AuthError) throw error + lastError = error + } + } + if (!windows?.length && lastError) throw lastError + return windows?.length + ? { providerId: provider.providerId, ok: true, windows: withNames(windows), fetchedAt: now } + : { providerId: provider.providerId, ok: false, reason: "error", message: "No usage data", fetchedAt: now } + } catch (error) { + return { + providerId: provider.providerId, + ok: false, + reason: error instanceof AuthError ? "noKey" : "error", + message: error instanceof AuthError ? undefined : safeErrorMessage(error), + fetchedAt: now, + } + } +} + +async function fetchCopilot(force: boolean, signal?: AbortSignal): Promise { + const result = await getGitHubCopilotQuota({ force, signal }) + const providerId = providerIdentifiers.githubCopilot + if (!result.ok) + return { providerId, ok: false, reason: result.reason, message: result.message, fetchedAt: Date.now() } + + const q = result.quota + const window: ProviderQuotaWindow = q.unlimited + ? { id: "premium", valueLabel: "Unlimited", resetAt: q.resetAt } + : { + id: "premium", + valueLabel: + q.used !== undefined && q.entitlement !== undefined + ? `${formatCredits(q.used)} / ${formatCredits(q.entitlement)} used` + : q.usedPercent !== undefined + ? `${Math.round(q.usedPercent)}% used` + : // Neither counts nor a percentage were reported: that is unknown, not a measured zero. + "Usage not reported", + usedPercent: q.usedPercent, + resetAt: q.resetAt, + } + return { providerId, ok: true, plan: q.plan, windows: withNames([window]), fetchedAt: q.fetchedAt } +} + +// Entry point ------------------------------------------------------------------------------------- + +/** The slice of `ProviderSettingsManager` this service needs (keeps it testable without the manager). */ +export interface ProfileSource { + listConfig(): Promise<{ id: string; name?: string }[]> + getProfile(params: { id: string }): Promise +} + +/** How long one profile read may take. A read that never settles is skipped, so the others still produce a response. */ +const PROFILE_READ_TIMEOUT_MS = 10_000 + +/** Rejects with `message` if `work` has not settled within `ms`. */ +function withDeadline(work: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms) + work.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) +} + +function readProfile(source: ProfileSource, id: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), PROFILE_READ_TIMEOUT_MS) + source.getProfile({ id }).then( + (profile) => { + clearTimeout(timer) + resolve(profile) + }, + () => { + clearTimeout(timer) + resolve(undefined) + }, + ) + }) +} + +/** + * Loads full profiles (including API keys) host-side. A profile that fails to load, or takes too long, is skipped + * rather than failing the whole refresh, but if none of the saved profiles can be read that is a failure, not "no + * connected providers". The active profile (`activeName`) comes first, and its id is returned so that its own + * credentials are the ones queried for its provider. Keys never leave the extension host: only formatted quota rows + * are posted to the webview. + */ +export async function loadProfilesWithActive( + source: ProfileSource, + signal?: AbortSignal, + activeName?: string, +): Promise<{ profiles: ProviderSettings[]; activeProfileId?: string }> { + // Stops waiting as soon as `signal` aborts. The reads themselves cannot be cancelled, but the profile reads + // are not started once the list wait has been abandoned. + const listed = await until( + withDeadline(source.listConfig(), PROFILE_READ_TIMEOUT_MS, "Reading the saved profiles timed out"), + signal, + () => {}, + ) + const entries = activeName + ? [ + ...listed.filter((entry) => entry.name === activeName), + ...listed.filter((entry) => entry.name !== activeName), + ] + : listed + const profiles = await until(Promise.all(entries.map((entry) => readProfile(source, entry.id))), signal, () => {}) + const loaded = profiles.filter((p): p is ProviderSettings => !!p) + if (entries.length > 0 && loaded.length === 0) throw new Error("None of the saved profiles could be read") + + const activeEntry = activeName ? listed.find((entry) => entry.name === activeName) : undefined + return { profiles: loaded, activeProfileId: activeEntry?.id } +} + +// Successful results only, per provider and URL set. A missing key, a sign-out or a +// transient error is never pinned, so fixing it shows up on the next open instead of +// after the TTL. An entry is reused only for the exact key it was fetched with, so +// switching keys refetches. The key is compared, never hashed or used in a map key. +const cache = new Map() +interface Inflight { + apiKey: string + request: Promise + /** Callers still waiting on this request. The shared fetch is aborted only when the last one leaves. */ + waiters: number + controller: AbortController +} +const inflight = new Map() + +/** Identifies a provider and the URLs it is queried at (the credential itself is not part of it). */ +function cacheKey(provider: KeyedProvider, urls: string[]): string { + return `${provider.providerId}|${urls.join(",")}` +} + +/** Rejects as soon as `signal` aborts, without cancelling `work` (which other callers may still be using). */ +function until(work: Promise, signal: AbortSignal | undefined, onAbort: () => void): Promise { + if (!signal) return work + return new Promise((resolve, reject) => { + const abort = () => { + onAbort() + reject(new DOMException("Aborted", "AbortError")) + } + if (signal.aborted) return abort() + signal.addEventListener("abort", abort, { once: true }) + work.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)) + }) +} + +function quotaForKeyedProvider( + provider: KeyedProvider, + profiles: ProviderSettings[], + force: boolean, + fetchImpl: Fetch, + signal?: AbortSignal, + activeProfileId?: string, +): Promise { + const creds = pickCredentials(provider, profiles, activeProfileId) + const skipped = unqueryable(provider, creds) + if (skipped) return Promise.resolve(skipped) + + // `unqueryable` returned early unless there is a key and at least one URL. + const apiKey = creds?.apiKey as string + const urls = creds?.urls as string[] + const key = cacheKey(provider, urls) + const hit = cache.get(key) + if (!force && hit && hit.apiKey === apiKey && Date.now() - hit.at < CACHE_TTL_MS) return Promise.resolve(hit.result) + + // Concurrent callers (e.g. a refresh while the first load is still running) share one request. + let entry = inflight.get(key) + if (!entry || entry.apiKey !== apiKey) { + const controller = new AbortController() + const request: Promise = fetchKeyed(provider, apiKey, urls, fetchImpl, controller.signal) + .then((result) => { + // A cancelled request says nothing about the account, so it leaves the cache as it is. + if (!controller.signal.aborted) { + if (result.ok) { + cache.set(key, { at: Date.now(), apiKey, result }) + } else if (cache.get(key)?.apiKey === apiKey) { + // A failed refresh must not leave an older success of this key to be served as if it were + // current. A success cached for another key is not this request's to remove. + cache.delete(key) + } + } + return result + }) + .finally(() => { + if (inflight.get(key)?.request === request) inflight.delete(key) + }) + entry = { apiKey, request, waiters: 0, controller } + inflight.set(key, entry) + } + + const shared = entry + shared.waiters++ + let left = false + const leave = () => { + if (left) return + left = true + // Last one out cancels the network work; earlier leavers just stop waiting. + if (--shared.waiters === 0) { + shared.controller.abort() + // Do not let a later caller join a request that is already cancelled. + if (inflight.get(key) === shared) inflight.delete(key) + } + } + return until(shared.request, signal, leave).finally(leave) +} + +/** + * Quotas for every *connected* provider with a usage API. `profiles` are the + * user's saved provider settings; only providers present there are queried. + */ +export async function getProviderQuotas( + profiles: ProviderSettings[], + { + force = false, + signal, + activeProfileId, + }: { force?: boolean; signal?: AbortSignal; activeProfileId?: string } = {}, + fetchImpl: Fetch = fetch, +): Promise { + const connected = new Set(profiles.map((p) => p.apiProvider).filter(Boolean) as string[]) + + const tasks: Promise[] = [] + if (connected.has(providerIdentifiers.githubCopilot)) tasks.push(fetchCopilot(force, signal)) + for (const provider of KEYED_PROVIDERS) { + if (connected.has(provider.providerId)) { + tasks.push(quotaForKeyedProvider(provider, profiles, force, fetchImpl, signal, activeProfileId)) + } + } + + return Promise.all(tasks) +} + +/** @internal test hook */ +export function resetProviderQuotaCache() { + cache.clear() + inflight.clear() +} diff --git a/src/services/language-models/quotaUtils.ts b/src/services/language-models/quotaUtils.ts new file mode 100644 index 0000000000..f0e7d68d99 --- /dev/null +++ b/src/services/language-models/quotaUtils.ts @@ -0,0 +1,66 @@ +/** + * Small, pure helpers shared by the quota services (`copilotQuota`, + * `providerQuotas`). Kept in one place so payload parsing behaves identically + * everywhere and is tested once. + */ + +export const toNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + } + return undefined +} + +export const asRecord = (value: unknown): Record | undefined => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined + +export const clampPercent = (value: number): number => Math.min(100, Math.max(0, value)) + +/** + * Epoch milliseconds from a provider timestamp. Numbers below 1e12 are treated + * as epoch seconds; strings are parsed as dates. Anything else is undefined. + */ +export const toEpochMs = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value < 1_000_000_000_000 ? value * 1000 : value + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? undefined : parsed + } + return undefined +} + +/** `$12.40`, `-$0.50`, `¥8.00`. */ +export const formatMoney = (value: number, symbol = "$"): string => + `${value < 0 ? "-" : ""}${symbol}${Math.abs(value).toFixed(2)}` + +/** `140,000` / `39,389.3`: whole numbers stay whole, fractions keep one decimal. */ +export const formatCredits = (value: number): string => + value.toLocaleString("en-US", { maximumFractionDigits: Number.isInteger(value) ? 0 : 1 }) + +/** One decimal, matching GitHub's own panel. */ +export const roundToTenth = (value: number): number => Math.round(value * 10) / 10 + +/** + * Turns an unknown failure into a short, safe string for the UI. Only the + * well-understood cases keep their text; anything else (network stack errors + * can embed URLs or headers) collapses to a generic message so credentials + * can never ride an error back to the webview. + */ +export class QuotaHttpError extends Error { + constructor(readonly status: number) { + super(`HTTP ${status}`) + this.name = "QuotaHttpError" + } +} + +export function safeErrorMessage(error: unknown): string { + if (error instanceof QuotaHttpError) return error.message + if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) { + return "Request timed out" + } + return "Request failed" +} diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..8575d83fbc 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -415,6 +415,18 @@ export const stories: Record = { ) }, + "usage-settings": async () => { + const { UsageSettingsStory } = await import("@/components/settings/__tests__/UsageSettings.visual.fixture") + return + }, + "usage-settings-degraded": async () => { + const { UsageSettingsStory } = await import("@/components/settings/__tests__/UsageSettings.visual.fixture") + return + }, + "usage-settings-empty": async () => { + const { UsageSettingsStory } = await import("@/components/settings/__tests__/UsageSettings.visual.fixture") + return + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/components/settings/ProviderLimits.tsx b/webview-ui/src/components/settings/ProviderLimits.tsx new file mode 100644 index 0000000000..43254ab495 --- /dev/null +++ b/webview-ui/src/components/settings/ProviderLimits.tsx @@ -0,0 +1,214 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { RefreshCw } from "lucide-react" + +import { VsCodeLmModelsMessageType, type ProviderQuota } from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { cn } from "@src/lib/utils" +import { vscode } from "@src/utils/vscode" +import i18next from "i18next" +import { providerLabel } from "@src/utils/providerLabel" + +// Module-level, so a component that is unmounted and mounted again never reuses an id that an older, +// still-settling request carries. The random part separates this webview from any other one (sidebar and +// editor tab each load their own copy of this module), so an id is unique across webviews too. +let quotaRequestCounter = 0 +const quotaRequestSession = Math.random().toString(36).slice(2, 10) +const nextQuotaRequestId = () => `provider-quotas-${quotaRequestSession}-${++quotaRequestCounter}` + +const barColor = (pct: number) => + pct >= 90 ? "bg-vscode-charts-red" : pct >= 75 ? "bg-vscode-charts-yellow" : "bg-vscode-charts-green" + +/** + * "Provider limits": live credits/balances for every connected provider that + * exposes a usage API (GitHub Copilot, OpenRouter, DeepSeek, Moonshot, Kimi Code, + * z.ai, MiniMax, NanoGPT, OpenCode Go), one + * compact row each. Fetched by the extension host; keys never reach the webview. + */ +export const ProviderLimits = () => { + const { t } = useAppTranslation() + const [quotas, setQuotas] = useState() + const [loading, setLoading] = useState(true) + const [failed, setFailed] = useState(false) + + // Only the newest request counts: an answer for an older one (a slow first load that a refresh + // overtook, or one that outlived this component) is ignored. + const latestRequestId = useRef(undefined) + + const cancelLatest = useCallback(() => { + if (latestRequestId.current) { + vscode.postMessage({ + type: VsCodeLmModelsMessageType.cancelProviderQuotas, + requestId: latestRequestId.current, + }) + latestRequestId.current = undefined + } + }, []) + + const request = useCallback( + (force: boolean) => { + cancelLatest() + const requestId = nextQuotaRequestId() + latestRequestId.current = requestId + setLoading(true) + vscode.postMessage({ type: VsCodeLmModelsMessageType.requestProviderQuotas, bool: force, requestId }) + }, + [cancelLatest], + ) + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.data?.type !== VsCodeLmModelsMessageType.providerQuotas) return + if (event.data.requestId !== latestRequestId.current) return + latestRequestId.current = undefined + setQuotas(event.data.providerQuotas ?? []) + setFailed(event.data.providerQuotasFailed === true) + setLoading(false) + } + window.addEventListener("message", onMessage) + // A profile saved in another webview does not reach this one, so look again when it regains focus. + const onFocus = () => request(false) + window.addEventListener("focus", onFocus) + request(false) + return () => { + window.removeEventListener("message", onMessage) + window.removeEventListener("focus", onFocus) + // Tell the host nobody is waiting any more so it can stop the profile load and any fetches. + cancelLatest() + } + }, [request, cancelLatest]) + + // Nothing connected that has a usage API: hide the section entirely. A failed load is different: keep + // the section so the user can press Refresh to try again. + if (quotas && quotas.length === 0 && !failed) return null + + const fetchedAt = quotas?.reduce((max, q) => Math.max(max, q.fetchedAt), 0) + + return ( +
+
+

+ {t("settings:usage.limits.title")} +

+ +
+ +
+ {failed && ( +
+ {t("settings:usage.limits.error")} +
+ )} + {!quotas && ( +
+ {t("settings:usage.limits.loading")} +
+ )} + {quotas?.map((q) => ( + + new Date(ts).toLocaleDateString(i18next.language || "en", { dateStyle: "medium" }) + } + /> + ))} +
+
+ ) +} + +const QuotaRow = ({ + quota, + name, + formatReset, +}: { + quota: ProviderQuota + name: string + formatReset: (ts: number) => string +}) => { + const { t } = useAppTranslation() + + return ( +
+
+ + {name} + {quota.ok && quota.plan && ( + {quota.plan} + )} + {!quota.ok && ( + + {t(`settings:usage.limits.${quota.reason}`)} + + )} +
+ + {quota.ok && + quota.windows.map((w) => { + const windowText = w.name + ? t(`settings:usage.limits.window.${w.name.key}`, { n: w.name.count }) + : w.valueLabel + + return ( +
+
+ {windowText} + {w.usedPercent !== undefined && ( + + {Math.round(w.usedPercent)}% + + )} +
+ {w.usedPercent !== undefined && ( +
+
+
+ )} + {w.resetAt && ( +
+ {t("settings:usage.limits.resets", { date: formatReset(w.resetAt) })} +
+ )} +
+ ) + })} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5f1769a230..adc97f8fc0 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -17,6 +17,7 @@ import { FlaskConical, TriangleAlert, Globe, + ChartColumn, Info, MessageSquare, LucideIcon, @@ -76,6 +77,7 @@ import { TerminalSettings } from "./TerminalSettings" import { ExperimentalSettings } from "./ExperimentalSettings" import { LanguageSettings } from "./LanguageSettings" import { About } from "./About" +import { UsageSettings } from "./UsageSettings" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" import { SlashCommandsSettings } from "./SlashCommandsSettings" @@ -116,6 +118,7 @@ export const sectionNames = [ "ui", "experimental", "language", + "usage", "about", ] as const @@ -558,6 +561,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "ui", icon: Glasses }, { id: "experimental", icon: FlaskConical }, { id: "language", icon: Globe }, + { id: "usage", icon: ChartColumn }, { id: "about", icon: Info }, ], [], // No dependencies needed now @@ -974,6 +978,9 @@ const SettingsView = forwardRef(({ onDone, t )} + {/* Usage Section */} + {renderTab === "usage" && } + {/* About Section */} {renderTab === "about" && ( `$${cost.toFixed(2)}` + +const PALETTE = [ + "var(--vscode-charts-blue)", + "var(--vscode-charts-purple)", + "var(--vscode-charts-green)", + "var(--vscode-charts-orange)", + "var(--vscode-charts-yellow)", + "var(--vscode-charts-red)", +] + +const WEEKDAY_FMT = new Intl.DateTimeFormat(undefined, { weekday: "narrow" }) +const DAY_FMT = new Intl.DateTimeFormat(undefined, { day: "numeric" }) + +/** + * Usage tab. + * + * 1. Provider limits: live credits/balances from each connected provider. + * 2. Usage history: rollup of local task history (tokens, cost when the + * provider reports a price, breakdowns, top tasks). + * + * Subscription providers (e.g. GitHub Copilot) record no per-task price, so + * when a range has tokens but no cost the history switches to tokens and the + * cost stat is hidden rather than shown as a misleading "$0.00". + */ +export const UsageSettings = ({ className, ...props }: HTMLAttributes) => { + const { t } = useAppTranslation() + const { taskHistory, cwd } = useExtensionState() + + const [range, setRange] = useState("7d") + const [scope, setScope] = useState<"workspace" | "all">("workspace") + + const workspace = scope === "workspace" ? cwd : undefined + + // Provider and model come from what each request recorded, never from the + // currently selected profile, so switching providers doesn't relabel history. + const history = useMemo(() => taskHistory ?? [], [taskHistory]) + + // One "now" for the view and for the export, rolled over at the next local midnight. Without it a + // tab left open across midnight keeps yesterday's window while Export (computed at click time) + // would use today's, and the two would disagree. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const nextMidnight = new Date(now) + nextMidnight.setHours(24, 0, 0, 0) + const timer = setTimeout(() => setNow(Date.now()), nextMidnight.getTime() - now + 1000) + return () => clearTimeout(timer) + }, [now]) + + const summary = useMemo(() => summarizeUsage(history, { range, workspace, now }), [history, range, workspace, now]) + + const providerName = (key: string) => (key === UNRECORDED ? t("settings:usage.notRecorded") : providerLabel(key)) + + const metric: UsageMetric = pickMetric(summary) + const value = (cost: number, tokens: number) => (metric === "cost" ? cost : tokens) + const fmt = (cost: number, tokens: number) => (metric === "cost" ? formatCost(cost) : formatLargeNumber(tokens)) + const total = metricTotal(summary, metric) + + // Row view-model: the priced/unpriced rule lives in `bucketView` (pure, tested). + const bucketRow = (b: { cost: number; tokens: number; priced: boolean }) => { + const view = bucketView(b, metric, total) + return { + ...view, + label: view.unpriced + ? `${formatLargeNumber(b.tokens)} ${t("settings:usage.tokensShort")} · ${t("settings:usage.noPrice")}` + : fmt(b.cost, b.tokens), + } + } + + const maxDaily = Math.max(0, ...summary.daily.map((d) => value(d.cost, d.tokens))) + const showDayLabels = summary.daily.length <= 14 + const isEmpty = summary.taskCount === 0 + + const handleExport = () => { + const csv = usageToCsv(history, { range, workspace, now }) + const url = URL.createObjectURL(new Blob([csv], { type: "text/csv" })) + const a = document.createElement("a") + a.href = url + a.download = `zoo-usage-${range}.csv` + a.click() + URL.revokeObjectURL(url) + } + + return ( +
+ {t("settings:sections.usage")} + +
+
+ + +
+
+ {t("settings:usage.history")} + {!isEmpty && ( + + )} +
+ + {/* Filters */} + +
+
+ {USAGE_RANGES.map((r) => ( + + ))} +
+ +
+
+ + {isEmpty ? ( +
+ {t("settings:usage.empty")} +
+ ) : ( + <> + {/* Three compact stat cards, as in the design mock */} +
+ {metric === "cost" ? ( + + ) : ( + + )} + {metric === "cost" ? ( + 0 + ? t("settings:usage.stats.cacheHit", { + percent: Math.round(summary.cacheHitRate * 100), + }) + : undefined + } + /> + ) : ( + m.key !== UNRECORDED).length || "–", + )} + label={t("settings:usage.stats.models")} + /> + )} + +
+ + {metric === "tokens" && ( +

+ {t("settings:usage.noPricingNote")} +

+ )} + + {summary.daily.length > 1 && ( + +
+ {summary.daily.map((d) => { + const v = value(d.cost, d.tokens) + return ( +
0 + ? "bg-vscode-charts-blue" + : "bg-vscode-input-background", + )} + style={{ + height: + maxDaily > 0 && v > 0 + ? `${Math.max(6, (v / maxDaily) * 100)}%` + : "2px", + }} + title={`${new Date(d.day).toLocaleDateString()} · ${fmt(d.cost, d.tokens)}`} + /> + ) + })} +
+ {showDayLabels && ( +
+ {summary.daily.map((d) => ( + + {summary.daily.length <= 7 + ? WEEKDAY_FMT.format(d.day) + : DAY_FMT.format(d.day)} + + ))} +
+ )} + + )} + + ({ + key: b.key, + name: providerName(b.key), + ...bucketRow(b), + }))} + testId="usage-by-provider" + /> + ({ + key: b.key, + name: modelIdFromKey(b.key) ?? t("settings:usage.notRecorded"), + subtitle: b.apiProvider ? providerLabel(b.apiProvider) : undefined, + ...bucketRow(b), + }))} + testId="usage-by-model" + /> +

+ {t("settings:usage.byMode")}:{" "} + {summary.byMode.map((m) => `${m.key} ${bucketRow(m).label}`).join(" · ")} +

+ + +
+ {summary.topTasks.map((task) => ( + + ))} +
+
+ +

+ {t("settings:usage.disclaimer")} +

+ + )} +
+
+
+
+ ) +} + +const SectionTitle = ({ children }: { children: React.ReactNode }) => ( +

{children}

+) + +const Block = ({ title, children, testId }: { title: string; children: React.ReactNode; testId?: string }) => ( +
+
{title}
+ {children} +
+) + +const Stat = ({ value, label, hint }: { value: string; label: string; hint?: string }) => ( +
+
+ {value} +
+
{label}
+ {hint &&
{hint}
} +
+) + +const Breakdown = ({ + title, + rows, + testId, +}: { + title: string + rows: { key: string; name: string; subtitle?: string; label: string; percent: number; unpriced?: boolean }[] + testId: string +}) => ( + +
+ {rows.slice(0, 5).map((row, i) => { + const pct = row.percent + const color = row.unpriced ? "var(--vscode-descriptionForeground)" : PALETTE[i % PALETTE.length] + return ( +
+ + + {row.name} + {row.subtitle && ( + {row.subtitle} + )} + + + {row.label} + + + + + + {row.unpriced ? "–" : `${pct}%`} + +
+ ) + })} +
+
+) diff --git a/webview-ui/src/components/settings/__tests__/ProviderLimits.spec.tsx b/webview-ui/src/components/settings/__tests__/ProviderLimits.spec.tsx new file mode 100644 index 0000000000..5b7157687f --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ProviderLimits.spec.tsx @@ -0,0 +1,425 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers, type ProviderQuota } from "@roo-code/types" + +import { ProviderLimits } from "../ProviderLimits" + +const post = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: post } })) + +// Render the key plus its interpolation values so assertions can see the formatted output. +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, options?: Record) => + options ? `${key} ${Object.values(options).join(" ")}` : key, + }), +})) + +/** The id the component put on its most recent request (the host echoes it back). */ +const lastRequestId = () => { + const requests = post.mock.calls.map((c) => c[0]).filter((m) => m.type === "requestProviderQuotas") + return requests.at(-1)?.requestId as string +} + +/** Answers the most recent request, or the given id. Pass `null` to send an answer with no id at all. */ +const lastRequestIdAfterRender = (Component: typeof ProviderLimits = ProviderLimits) => { + post.mockClear() + const view = render() + const id = lastRequestId() + view.unmount() + return id +} + +const respond = (providerQuotas: ProviderQuota[], id?: string | null) => + act(() => { + const requestId = id === null ? undefined : (id ?? lastRequestId()) + window.dispatchEvent( + new MessageEvent("message", { data: { type: "providerQuotas", providerQuotas, requestId } }), + ) + }) + +describe("ProviderLimits", () => { + beforeEach(() => { + post.mockClear() + }) + + it("asks the extension for quotas on mount without forcing", () => { + render() + expect(post).toHaveBeenCalledWith({ + type: "requestProviderQuotas", + bool: false, + requestId: expect.stringMatching(/^provider-quotas-[a-z0-9]+-\d+$/), + }) + }) + + describe("when the webview regains focus", () => { + const requests = () => post.mock.calls.map((c) => c[0]).filter((m) => m.type === "requestProviderQuotas") + + it("asks again without forcing, so a profile saved elsewhere is picked up", () => { + render() + expect(requests()).toHaveLength(1) + + act(() => { + window.dispatchEvent(new Event("focus")) + }) + + expect(requests()).toHaveLength(2) + expect(requests()[1]).toMatchObject({ bool: false }) + expect(requests()[1].requestId).not.toBe(requests()[0].requestId) + }) + + it("stops listening once the section is gone", () => { + const { unmount } = render() + unmount() + post.mockClear() + + act(() => { + window.dispatchEvent(new Event("focus")) + }) + + expect(requests()).toHaveLength(0) + }) + }) + + describe("progress bars", () => { + it("names each bar with its provider and window, so two windows of one provider can be told apart", () => { + render() + respond([ + { + providerId: providerIdentifiers.zai, + ok: true, + fetchedAt: 1, + windows: [ + { id: "a", valueLabel: "5-hour", usedPercent: 40, name: { key: "hours", count: 5 } }, + { id: "b", valueLabel: "Weekly", usedPercent: 10, name: { key: "weekly" } }, + { id: "c", valueLabel: "$4.25 left" }, + ], + }, + ]) + + const bars = screen.getAllByRole("progressbar") + expect(bars).toHaveLength(2) + const labels = bars.map((bar) => bar.getAttribute("aria-label")) + expect(labels[0]).toContain("settings:usage.limits.window.hours 5") + expect(labels[1]).toContain("settings:usage.limits.window.weekly") + expect(new Set(labels).size).toBe(2) + const shown = screen.getByTestId(`provider-limit-${providerIdentifiers.zai}`).querySelector(".font-medium") + expect(shown?.textContent).toBeTruthy() + for (const label of labels) expect(label).toContain(shown?.textContent as string) + }) + }) + + describe("window names", () => { + const quota = (windows: Extract["windows"]): ProviderQuota => ({ + providerId: providerIdentifiers.zai, + ok: true, + windows, + fetchedAt: 1, + }) + + it.each([ + [{ key: "weekly" }, "settings:usage.limits.window.weekly"], + [{ key: "unlimited" }, "settings:usage.limits.window.unlimited"], + [{ key: "hours", count: 5 }, "settings:usage.limits.window.hours 5"], + [{ key: "days", count: 3 }, "settings:usage.limits.window.days 3"], + ] as const)("translates a window named %j", (name, expected) => { + render() + respond([quota([{ id: "w", valueLabel: "ENGLISH FALLBACK", name }])]) + + const row = screen.getByTestId(`provider-limit-${providerIdentifiers.zai}`) + expect(row.textContent).toContain(expected) + expect(row.textContent).not.toContain("ENGLISH FALLBACK") + }) + + it("shows the host's text for a window without a fixed name", () => { + render() + respond([quota([{ id: "b", valueLabel: "$4.25 left" }])]) + + expect(screen.getByTestId(`provider-limit-${providerIdentifiers.zai}`).textContent).toContain("$4.25 left") + }) + }) + + it("shows a loading row until the first response", () => { + render() + expect(screen.getByTestId("provider-limits")).toBeTruthy() + expect(screen.queryByTestId(`provider-limit-${providerIdentifiers.githubCopilot}`)).toBeNull() + }) + + it("renders credits with a progress bar and reset date", () => { + render() + respond([ + { + providerId: providerIdentifiers.githubCopilot, + ok: true, + plan: "business", + fetchedAt: Date.now(), + windows: [ + { + id: "premium", + valueLabel: "39,389.3 / 140,000 used", + usedPercent: 28.1, + resetAt: Date.parse("2026-11-01T12:00:00Z"), + }, + ], + }, + ]) + + const row = screen.getByTestId(`provider-limit-${providerIdentifiers.githubCopilot}`) + expect(row.textContent).toContain("GitHub Copilot") + expect(row.textContent).toContain("business") + expect(row.textContent).toContain("39,389.3 / 140,000 used") + expect(row.textContent).toContain("28%") + const bar = row.querySelector("[role=progressbar]")! + expect(bar.getAttribute("aria-valuenow")).toBe("28") + // The translation key plus the formatted reset date, not just the key. + expect(row.textContent).toContain( + `settings:usage.limits.resets ${new Date(Date.parse("2026-11-01T12:00:00Z")).toLocaleDateString("en", { dateStyle: "medium" })}`, + ) + }) + + it("omits the bar for balance-only providers", () => { + render() + respond([ + { + providerId: providerIdentifiers.deepseek, + ok: true, + fetchedAt: 1, + windows: [{ id: "balance", valueLabel: "$4.25 left" }], + }, + ]) + const row = screen.getByTestId(`provider-limit-${providerIdentifiers.deepseek}`) + expect(row.textContent).toContain("$4.25 left") + expect(row.querySelector("[role=progressbar]")).toBeNull() + }) + + it("writes the reset date and the updated time in the display language", () => { + render() + const resetAt = new Date(2026, 10, 1, 12, 0).getTime() + const fetchedAt = new Date(2026, 10, 1, 14, 41).getTime() + respond([ + { + providerId: providerIdentifiers.openrouter, + ok: true, + fetchedAt, + windows: [{ id: "key_limit", valueLabel: "x", usedPercent: 10, resetAt }], + }, + ]) + const row = screen.getByTestId(`provider-limit-${providerIdentifiers.openrouter}`) + expect(row.textContent).toContain( + `settings:usage.limits.resets ${new Date(resetAt).toLocaleDateString("en", { dateStyle: "medium" })}`, + ) + expect(screen.getByTestId("provider-limits-refresh").textContent).toContain( + `settings:usage.limits.updated ${new Date(fetchedAt).toLocaleTimeString("en", { hour: "numeric", minute: "2-digit" })}`, + ) + }) + + it.each(["noKey", "noCopilot", "signedOut", "unsupported", "error"] as const)( + "shows the quiet status text for %s", + (reason) => { + render() + respond([{ providerId: providerIdentifiers.openrouter, ok: false, reason, fetchedAt: 1 }]) + const row = screen.getByTestId(`provider-limit-${providerIdentifiers.openrouter}`) + expect(row.textContent).toContain(`settings:usage.limits.${reason}`) + expect(row.querySelector("[role=progressbar]")).toBeNull() + }, + ) + + it.each([ + [50, "bg-vscode-charts-green"], + [80, "bg-vscode-charts-yellow"], + [95, "bg-vscode-charts-red"], + ])("colours the bar by how much of the allowance is used (%i%%)", (usedPercent, expected) => { + render() + respond([ + { + providerId: providerIdentifiers.openrouter, + ok: true, + fetchedAt: 1, + windows: [{ id: "key_limit", valueLabel: "x", usedPercent }], + }, + ]) + const bar = screen + .getByTestId(`provider-limit-${providerIdentifiers.openrouter}`) + .querySelector("[role=progressbar] > div")! + expect(bar.className).toContain(expected) + }) + + it("treats a response without a providerQuotas list as empty", () => { + render() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { data: { type: "providerQuotas", requestId: lastRequestId() } }), + ) + }) + expect(screen.queryByTestId("provider-limits")).toBeNull() + }) + + it("hides the whole section when nothing connected has a usage API", () => { + render() + respond([]) + expect(screen.queryByTestId("provider-limits")).toBeNull() + }) + + it("refresh forces a new request", () => { + render() + respond([ + { + providerId: providerIdentifiers.deepseek, + ok: true, + fetchedAt: 1, + windows: [{ id: "b", valueLabel: "$1.00 left" }], + }, + ]) + post.mockClear() + fireEvent.click(screen.getByTestId("provider-limits-refresh")) + expect(post).toHaveBeenCalledWith({ + type: "requestProviderQuotas", + bool: true, + requestId: expect.stringMatching(/^provider-quotas-[a-z0-9]+-\d+$/), + }) + }) + + it("ignores unrelated messages", () => { + render() + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "somethingElse" } })) + }) + expect(screen.getByTestId("provider-limits")).toBeTruthy() + }) + + describe("request lifecycle", () => { + const ok = (providerId: string = providerIdentifiers.deepseek): ProviderQuota => ({ + providerId, + ok: true, + fetchedAt: 1, + windows: [{ id: "b", valueLabel: "$1.00 left" }], + }) + + it("ignores an answer for a request that a refresh has overtaken", () => { + render() + const first = lastRequestId() + respond([ok(providerIdentifiers.deepseek)]) + fireEvent.click(screen.getByTestId("provider-limits-refresh")) + const second = lastRequestId() + expect(second).not.toBe(first) + + // A late answer to the first request must not replace the newer state or end the spinner. + respond([ok(providerIdentifiers.moonshot)], first) + expect(screen.queryByTestId(`provider-limit-${providerIdentifiers.moonshot}`)).toBeNull() + expect(screen.getByTestId("provider-limits-refresh").hasAttribute("disabled")).toBe(true) + + respond([ok(providerIdentifiers.moonshot)], second) + expect(screen.getByTestId(`provider-limit-${providerIdentifiers.moonshot}`)).toBeTruthy() + expect(screen.getByTestId("provider-limits-refresh").hasAttribute("disabled")).toBe(false) + }) + + it("ignores an answer that carries no request id or an unknown one", () => { + render() + respond([ok()], null) + respond([ok()], "provider-quotas-999") + expect(screen.queryByTestId(`provider-limit-${providerIdentifiers.deepseek}`)).toBeNull() + }) + + it("does not offer a refresh while a request is pending, so a newer one never races an older one", () => { + render() + expect(screen.getByTestId("provider-limits-refresh").hasAttribute("disabled")).toBe(true) + post.mockClear() + fireEvent.click(screen.getByTestId("provider-limits-refresh")) + expect(post).not.toHaveBeenCalled() + }) + + it("cancels the in-flight request on unmount and ignores a late answer", () => { + const { unmount } = render() + const pending = lastRequestId() + post.mockClear() + unmount() + expect(post).toHaveBeenCalledWith({ type: "cancelProviderQuotas", requestId: pending }) + // Must not throw or update state after unmount. + expect(() => respond([ok()], pending)).not.toThrow() + }) + + it("does not cancel a request that has already been answered", () => { + const { unmount } = render() + respond([ok()]) + post.mockClear() + unmount() + expect(post).not.toHaveBeenCalledWith(expect.objectContaining({ type: "cancelProviderQuotas" })) + }) + }) + + describe("a failed load", () => { + const respondFailed = () => + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "providerQuotas", + providerQuotas: [], + providerQuotasFailed: true, + requestId: lastRequestId(), + }, + }), + ) + }) + + it("keeps the section and says so, instead of hiding it like 'no provider has a usage API'", () => { + render() + respondFailed() + expect(screen.getByTestId("provider-limits")).toBeTruthy() + expect(screen.getByTestId("provider-limits-failed").textContent).toContain("settings:usage.limits.error") + }) + + it("lets the user retry, and clears the failure when the retry works", () => { + render() + respondFailed() + + const before = lastRequestId() + post.mockClear() + fireEvent.click(screen.getByTestId("provider-limits-refresh")) + expect(post).toHaveBeenCalledWith(expect.objectContaining({ type: "requestProviderQuotas", bool: true })) + expect(lastRequestId()).not.toBe(before) + + respond([ + { + providerId: providerIdentifiers.deepseek, + ok: true, + fetchedAt: 1, + windows: [{ id: "b", valueLabel: "$1.00 left" }], + }, + ]) + expect(screen.queryByTestId("provider-limits-failed")).toBeNull() + expect(screen.getByTestId(`provider-limit-${providerIdentifiers.deepseek}`)).toBeTruthy() + }) + + it("still hides the section when the load worked and nothing has a usage API", () => { + render() + respond([]) + expect(screen.queryByTestId("provider-limits")).toBeNull() + }) + }) + + it("makes ids that differ between webviews, not just between requests", async () => { + // Each webview loads its own copy of the module and starts counting again from 1. + vi.resetModules() + const { ProviderLimits: First } = await import("../ProviderLimits") + const first = lastRequestIdAfterRender(First) + vi.resetModules() + const { ProviderLimits: Second } = await import("../ProviderLimits") + const second = lastRequestIdAfterRender(Second) + + expect(first).toMatch(/^provider-quotas-[a-z0-9]+-1$/) + expect(second).toMatch(/^provider-quotas-[a-z0-9]+-1$/) + // Same counter value, still different ids: the session part tells the webviews apart. + expect(first).not.toBe(second) + }) + + it("never reuses a request id when the tab is closed and opened again", () => { + const first = render() + const firstId = lastRequestId() + first.unmount() + + render() + expect(lastRequestId()).not.toBe(firstId) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 0835f255f9..95bd82e513 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -627,6 +627,57 @@ describe("SettingsView - Sound Settings", () => { }) }) +describe("SettingsView - Usage tab", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders the Usage tab content when it is the target section", () => { + renderWithExtensionState() + + const content = screen.getByTestId("settings-content") + expect(within(content).getByTestId("usage-empty")).toBeInTheDocument() + }) + + it("lists a Usage tab entry, and shows the usage content when it is clicked", () => { + renderWithExtensionState() + expect(within(screen.getByTestId("settings-content")).queryByTestId("usage-empty")).not.toBeInTheDocument() + + fireEvent.click(screen.getByTestId("tab-usage")) + + expect(within(screen.getByTestId("settings-content")).getByTestId("usage-empty")).toBeInTheDocument() + }) + + it("shows the totals and the filters when there is history", () => { + renderWithExtensionState(, { + state: { + taskHistory: [ + { + id: "t1", + number: 1, + ts: Date.now(), + task: "a task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.5, + }, + ], + }, + }) + + const content = screen.getByTestId("settings-content") + // The totals of the supplied task, not just an element that exists. + const stats = within(content).getByTestId("usage-stats").textContent ?? "" + expect(stats).toContain("$0.50") + expect(stats).toContain("150") // 100 tokens in + 50 out + // The filter controls and the export are there. + expect(within(content).getByTestId("usage-range-7d")).toBeInTheDocument() + expect(within(content).getByTestId("usage-range-all")).toBeInTheDocument() + expect(within(content).getByTestId("usage-export-csv")).toBeInTheDocument() + expect(within(content).queryByTestId("usage-empty")).not.toBeInTheDocument() + }) +}) + describe("SettingsView - API Configuration", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/webview-ui/src/components/settings/__tests__/UsageSettings.branches.spec.tsx b/webview-ui/src/components/settings/__tests__/UsageSettings.branches.spec.tsx new file mode 100644 index 0000000000..f7518755ad --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UsageSettings.branches.spec.tsx @@ -0,0 +1,276 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers, type HistoryItem } from "@roo-code/types" + +import { UsageSettings } from "../UsageSettings" + +const post = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: post } })) + +const state = vi.hoisted(() => ({ taskHistory: [] as HistoryItem[], cwd: "/ws/a" })) +vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => state })) + +vi.mock("../ProviderLimits", () => ({ ProviderLimits: () =>
})) + +// Radix Select does not open in jsdom; a native onValueChange(e.target.value)}> + {children} + + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}, + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( + + ), +})) + +const NOW = Date.now() +const DAY = 24 * 60 * 60 * 1000 + +let n = 0 +const task = (overrides: Partial = {}): HistoryItem => ({ + id: `t${++n}`, + number: n, + ts: NOW - 60_000, + task: `Task ${n}`, + tokensIn: 1000, + tokensOut: 100, + totalCost: 0, + workspace: "/ws/a", + mode: "code", + ...overrides, +}) + +const withUsage = (overrides: Partial & { cost?: number; cacheReads?: number } = {}) => { + const { cost = 1, ...rest } = overrides + return task({ + totalCost: cost, + usageByModel: [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + requests: 1, + tokensIn: 1000, + tokensOut: 100, + cost, + }, + ], + ...rest, + }) +} + +describe("UsageSettings – branches", () => { + beforeEach(() => { + post.mockClear() + state.taskHistory = [] + state.cwd = "/ws/a" + }) + + it("widening the scope to all workspaces includes tasks from other workspaces", () => { + state.taskHistory = [withUsage({ cost: 1 }), withUsage({ cost: 2, workspace: "/ws/other" })] + render() + expect(screen.getByTestId("usage-stats").textContent).not.toContain("$3.00") + fireEvent.change(screen.getByTestId("scope-select"), { target: { value: "all" } }) + expect(screen.getByTestId("usage-stats").textContent).toContain("$3.00") + fireEvent.change(screen.getByTestId("scope-select"), { target: { value: "workspace" } }) + expect(screen.getByTestId("usage-stats").textContent).not.toContain("$3.00") + }) + + it("shows the cache hit rate only when something was read from the cache", () => { + state.taskHistory = [withUsage({ cacheReads: 3000 })] + const { unmount } = render() + expect(screen.getByTestId("usage-stats").textContent).toContain("settings:usage.stats.cacheHit") + unmount() + + state.taskHistory = [withUsage()] + render() + expect(screen.getByTestId("usage-stats").textContent).not.toContain("settings:usage.stats.cacheHit") + }) + + it("labels chart bars by weekday for a week, and by date for a longer range", () => { + state.taskHistory = [withUsage()] + render() + const weekLabels = Array.from(screen.getByTestId("usage-daily-chart").parentElement!.querySelectorAll("span")) + .map((s) => s.textContent) + .filter(Boolean) + fireEvent.click(screen.getByTestId("usage-range-30d")) + const monthChart = screen.getByTestId("usage-daily-chart") + expect(monthChart.children.length).toBeGreaterThan(14) + expect(weekLabels.length).toBeGreaterThan(0) + }) + + it("shows an unpriced top task by tokens and a priced one by cost when metrics are mixed", () => { + state.taskHistory = [ + withUsage({ cost: 2, task: "priced task" }), + task({ + task: "unpriced task", + totalCost: 0, + tokensIn: 5000, + tokensOut: 500, + usageByModel: [ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "gpt-5", + requests: 1, + tokensIn: 5000, + tokensOut: 500, + cost: 0, + }, + ], + }), + ] + render() + // Each task is one button; find it by its title and check that row on its own. + const rowFor = (title: string) => screen.getByText(title).closest("button")!.textContent! + + const priced = rowFor("priced task") + expect(priced).toContain("$2.00") + expect(priced).not.toContain("settings:usage.tokensShort") + + const unpriced = rowFor("unpriced task") + expect(unpriced).toContain("settings:usage.tokensShort") + expect(unpriced).not.toContain("$") + }) + + it("shows only the model when no provider was recorded, and nothing when no usage was recorded", () => { + state.taskHistory = [ + task({ + task: "model only", + totalCost: 1, + usageByModel: [{ modelId: "mystery-model", requests: 1, tokensIn: 10, tokensOut: 1, cost: 1 }], + }), + task({ task: "no usage rows", totalCost: 0.5 }), + ] + render() + const rows = screen.getByTestId("usage-top-tasks").textContent! + expect(rows).toContain("mystery-model") + expect(rows).not.toContain("·") + }) + + it("renders the empty state when the task history is not available yet", () => { + state.taskHistory = undefined as never + render() + expect(screen.getByTestId("usage-empty")).toBeTruthy() + }) + + it("labels chart bars by date when more than a week of days is charted", () => { + state.taskHistory = Array.from({ length: 9 }, (_, i) => withUsage({ ts: NOW - i * DAY })) + render() + fireEvent.click(screen.getByTestId("usage-range-all")) + const chart = screen.getByTestId("usage-daily-chart") + expect(chart.children.length).toBeGreaterThan(7) + expect(chart.children.length).toBeLessThanOrEqual(14) + // Date labels (e.g. "Oct 27") sit under the bars, one per day, each with a day number. + const labelRow = chart.nextElementSibling as HTMLElement + expect(labelRow.children).toHaveLength(chart.children.length) + expect(labelRow.textContent).toMatch(/\d/) + }) + + it("treats tasks with no recorded tokens or cost as zero rather than failing", () => { + // Mixed metrics (one priced task) so unpriced rows are shown by tokens. + state.taskHistory = [ + withUsage({ cost: 2 }), + task({ + task: "bare", + totalCost: undefined as never, + tokensIn: undefined as never, + tokensOut: undefined as never, + }), + ] + render() + expect(screen.getByTestId("usage-top-tasks").textContent).toContain("bare") + }) + + it("treats a task with no cost as zero when ranking by tokens", () => { + state.taskHistory = [ + task({ + task: "tokens only", + totalCost: undefined as never, + usageByModel: [ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "gpt-5", + requests: 1, + tokensIn: 100, + tokensOut: 10, + cost: 0, + }, + ], + }), + ] + render() + expect(screen.getByTestId("usage-top-tasks").textContent).toContain("tokens only") + }) + it("shows the cost for a priced task whose token counters are missing", () => { + state.taskHistory = [ + task({ + task: "priced no counters", + totalCost: 3, + tokensIn: undefined as never, + tokensOut: undefined as never, + usageByModel: [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + requests: 1, + tokensIn: 1, + tokensOut: 1, + cost: 3, + }, + ], + }), + ] + render() + expect(screen.getByTestId("usage-top-tasks").textContent).toContain("$3.00") + }) + + it("hides the export button when the task history is not available", () => { + state.taskHistory = undefined as never + render() + expect(screen.queryByTestId("usage-export-csv")).toBeNull() + }) + + it("shows a token count for an unpriced task whose token counters are missing", () => { + state.taskHistory = [ + withUsage({ cost: 2 }), + task({ + task: "no counters", + totalCost: 0, + tokensIn: undefined as never, + tokensOut: undefined as never, + usageByModel: [ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "gpt-5", + requests: 1, + tokensIn: 1, + tokensOut: 1, + cost: 0, + }, + ], + }), + ] + render() + const text = screen.getByTestId("usage-top-tasks").textContent! + expect(text).toContain("no counters") + expect(text).toContain("settings:usage.tokensShort") + }) + + it("excludes tasks older than the range", () => { + state.taskHistory = [withUsage({ cost: 1 }), withUsage({ cost: 9, ts: NOW - 20 * DAY })] + render() + expect(screen.getByTestId("usage-stats").textContent).not.toContain("$10.00") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/UsageSettings.midnight.spec.tsx b/webview-ui/src/components/settings/__tests__/UsageSettings.midnight.spec.tsx new file mode 100644 index 0000000000..f688f68287 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UsageSettings.midnight.spec.tsx @@ -0,0 +1,103 @@ +import { act, fireEvent, render, screen } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers, type HistoryItem } from "@roo-code/types" + +import { UsageSettings } from "../UsageSettings" + +const post = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: post } })) + +const state = vi.hoisted(() => ({ taskHistory: [] as HistoryItem[], cwd: "/ws/a" })) +vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => state })) + +vi.mock("../ProviderLimits", () => ({ ProviderLimits: () =>
})) + +// Spy on the real export so the test can see which "now" it was given. +const csvSpy = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/usage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + usageToCsv: (...args: Parameters) => { + csvSpy(...args) + return actual.usageToCsv(...args) + }, + } +}) + +const MINUTE = 60_000 +// 23:50 local on 2026-10-27: ten minutes before midnight. +const BEFORE_MIDNIGHT = new Date(2026, 9, 27, 23, 50).getTime() + +const todaysTask: HistoryItem = { + id: "late", + number: 1, + ts: new Date(2026, 9, 27, 23, 0).getTime(), + task: "Late task", + tokensIn: 1000, + tokensOut: 100, + totalCost: 4, + workspace: "/ws/a", + usageByModel: [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + requests: 1, + tokensIn: 1000, + tokensOut: 100, + cost: 4, + }, + ], +} + +describe("UsageSettings – across local midnight", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }) + vi.setSystemTime(BEFORE_MIDNIGHT) + post.mockClear() + csvSpy.mockClear() + state.taskHistory = [todaysTask] + state.cwd = "/ws/a" + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("rolls the Today view over at midnight instead of keeping yesterday's tasks", () => { + render() + fireEvent.click(screen.getByTestId("usage-range-today")) + expect(screen.getByTestId("usage-stats").textContent).toContain("$4.00") + + // Midnight plus the one-second margin: the view must re-evaluate "today" on its own. + act(() => { + vi.advanceTimersByTime(10 * MINUTE + 5_000) + }) + + expect(screen.queryByTestId("usage-stats")).toBeNull() + expect(screen.getByTestId("usage-empty")).toBeTruthy() + }) + + it("keeps the view and the export on the same day, even if the clock moves past midnight first", () => { + render() + fireEvent.click(screen.getByTestId("usage-range-today")) + Object.assign(URL, { createObjectURL: vi.fn(() => "blob:x"), revokeObjectURL: vi.fn() }) + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}) + + // The wall clock passes midnight but the roll-over timer has not fired yet: the view still shows the + // old day, so Export must use that same day rather than a fresh Date.now(). + vi.setSystemTime(BEFORE_MIDNIGHT + 20 * MINUTE) + fireEvent.click(screen.getByTestId("usage-export-csv")) + + expect(csvSpy).toHaveBeenCalledTimes(1) + expect(csvSpy.mock.calls[0][1]).toMatchObject({ range: "today", now: BEFORE_MIDNIGHT }) + click.mockRestore() + }) + + it("does not leave a timer running after unmount", () => { + const { unmount } = render() + unmount() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/UsageSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UsageSettings.spec.tsx new file mode 100644 index 0000000000..4c602677eb --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UsageSettings.spec.tsx @@ -0,0 +1,273 @@ +import { fireEvent, render, screen, within } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers, type HistoryItem } from "@roo-code/types" + +import { UsageSettings } from "../UsageSettings" + +const post = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: post } })) + +const state = vi.hoisted(() => ({ taskHistory: [] as HistoryItem[], cwd: "/ws/a" })) +vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => state })) + +// The provider-limits section talks to the extension host; it has its own spec. +vi.mock("../ProviderLimits", () => ({ ProviderLimits: () =>
})) + +const NOW = Date.now() + +let n = 0 +const task = (overrides: Partial = {}): HistoryItem => ({ + id: `t${++n}`, + number: n, + ts: NOW - 60_000, + task: `Task ${n}`, + tokensIn: 1000, + tokensOut: 100, + totalCost: 0, + workspace: "/ws/a", + mode: "code", + ...overrides, +}) + +const priced = (cost: number, overrides: Partial = {}) => + task({ + totalCost: cost, + usageByModel: [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + requests: 1, + tokensIn: 1000, + tokensOut: 100, + cost, + }, + ], + ...overrides, + }) + +const copilot = (overrides: Partial = {}) => + task({ + usageByModel: [ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + requests: 1, + tokensIn: 1000, + tokensOut: 100, + cost: 0, + }, + ], + ...overrides, + }) + +describe("UsageSettings", () => { + beforeEach(() => { + post.mockClear() + state.taskHistory = [] + state.cwd = "/ws/a" + }) + + it("always renders the provider limits section", () => { + render() + expect(screen.getByTestId("provider-limits-stub")).toBeTruthy() + }) + + it("shows an empty state with no tasks", () => { + render() + expect(screen.getByTestId("usage-empty")).toBeTruthy() + expect(screen.queryByTestId("usage-stats")).toBeNull() + expect(screen.queryByTestId("usage-export-csv")).toBeNull() + }) + + describe("when a provider reports a price", () => { + beforeEach(() => { + state.taskHistory = [priced(1.25), priced(0.5)] + }) + + it("shows cost first and tokens second", () => { + render() + const stats = screen.getByTestId("usage-stats") + expect(stats.textContent).toContain("$1.75") + expect(stats.textContent).toContain("2.2") // 2 tasks x 1,100 tokens (suffix is a translation key in tests) + expect(screen.queryByTestId("usage-no-pricing-note")).toBeNull() + }) + + it("charts and ranks by cost", () => { + render() + const top = screen.getByTestId("usage-top-tasks") + expect(top.textContent).toContain("$1.25") + expect(within(screen.getByTestId("usage-by-provider")).getByText("settings:usage.byProvider")).toBeTruthy() + }) + }) + + describe("when no provider reports a price (e.g. GitHub Copilot)", () => { + beforeEach(() => { + state.taskHistory = [copilot()] + }) + + it("never shows a dollar figure and explains why", () => { + render() + expect(screen.getByTestId("usage-stats").textContent).not.toContain("$") + expect(screen.getByTestId("usage-no-pricing-note")).toBeTruthy() + }) + + it("leads with tokens and the in/out split", () => { + render() + const stats = screen.getByTestId("usage-stats").textContent! + expect(stats).toContain("1.1") + expect(stats).toContain("settings:usage.stats.inOut".split(".").pop()!) + }) + }) + + describe("mixed priced and unpriced providers", () => { + beforeEach(() => { + state.taskHistory = [priced(1.5), copilot({ tokensIn: 16_000_000 })] + }) + + it("prioritises cost over tokens when any provider reports a price", () => { + render() + const stats = screen.getByTestId("usage-stats").textContent! + expect(stats).toContain("$1.50") + expect(screen.queryByTestId("usage-no-pricing-note")).toBeNull() + }) + + it("shows subscription providers as tokens with a no-price tag, not $0.00 · 0%", () => { + render() + const providers = screen.getByTestId("usage-by-provider") + const unpriced = within(providers).getByTestId("usage-unpriced") + expect(unpriced.textContent).toContain("noPrice") + expect(unpriced.textContent).not.toContain("$0.00") + // The unpriced row has no share: it shows "–", never "0%". + const row = unpriced.parentElement! + expect(row.textContent).toContain("–") + expect(row.textContent).not.toMatch(/(^|\D)0%/) + expect(row.querySelector("span[style*='width: 0%']")).not.toBeNull() + }) + + it("gives priced providers their cost and share", () => { + render() + const providers = screen.getByTestId("usage-by-provider") + expect(providers.textContent).toContain("$1.50") + expect(providers.textContent).toContain("100%") + }) + + it("shows an unpriced top task by tokens rather than $0.00", () => { + render() + expect(screen.getByTestId("usage-top-tasks").textContent).not.toContain("$0.00") + }) + }) + + describe("attribution", () => { + it("lists the provider and model that were recorded", () => { + state.taskHistory = [copilot()] + render() + expect(screen.getByTestId("usage-by-provider").textContent).toContain("GitHub Copilot") + expect(screen.getByTestId("usage-by-model").textContent).toContain("claude-opus-5.5") + }) + + it("labels tasks with no recorded provider as not recorded rather than guessing", () => { + state.taskHistory = [task()] + render() + expect(screen.getByTestId("usage-by-provider").textContent).toContain("notRecorded") + expect(screen.getByTestId("usage-by-model").textContent).toContain("notRecorded") + }) + + it("shows provider · model under each top task", () => { + state.taskHistory = [copilot()] + render() + expect(screen.getByTestId("usage-top-tasks").textContent).toContain("GitHub Copilot · claude-opus-5.5") + }) + + it("summarises modes on a single line", () => { + state.taskHistory = [priced(1, { mode: "code" }), priced(0.5, { mode: "architect" })] + render() + const line = screen.getByTestId("usage-by-mode").textContent! + expect(line).toContain("code $1.00") + expect(line).toContain("architect $0.50") + }) + }) + + describe("modes with and without a price", () => { + it("keeps 'no price' on an unpriced mode so it is not mistaken for a priced one", () => { + state.taskHistory = [priced(1, { mode: "code" }), copilot({ mode: "debug" })] + render() + const line = screen.getByTestId("usage-by-mode").textContent! + expect(line).toContain("code $1.00") + expect(line).toMatch(/debug .*settings:usage\.noPrice/) + }) + }) + + describe("filters", () => { + it("defaults to this workspace and can widen to all", () => { + state.taskHistory = [priced(1), priced(2, { workspace: "/ws/other" })] + render() + expect(screen.getByTestId("usage-stats").textContent).toContain("$1.00") + expect(screen.getByTestId("usage-stats").textContent).not.toContain("$3.00") + }) + + it("changes the range", () => { + state.taskHistory = [priced(1), priced(5, { ts: NOW - 20 * 24 * 60 * 60 * 1000 })] + render() + // default 7 days → only the recent task + expect(screen.getByTestId("usage-stats").textContent).toContain("$1.00") + fireEvent.click(screen.getByTestId("usage-range-30d")) + expect(screen.getByTestId("usage-stats").textContent).toContain("$6.00") + fireEvent.click(screen.getByTestId("usage-range-today")) + expect(screen.getByTestId("usage-stats").textContent).toContain("$1.00") + }) + + it("includes everything for the all-time range", () => { + state.taskHistory = [priced(1), priced(5, { ts: NOW - 200 * 24 * 60 * 60 * 1000 })] + render() + fireEvent.click(screen.getByTestId("usage-range-all")) + expect(screen.getByTestId("usage-stats").textContent).toContain("$6.00") + }) + + it("marks the active range for assistive tech", () => { + render() + expect(screen.getByTestId("usage-range-7d").getAttribute("aria-checked")).toBe("true") + expect(screen.getByTestId("usage-range-30d").getAttribute("aria-checked")).toBe("false") + }) + }) + + describe("interactions", () => { + it("opens a task when its row is clicked", () => { + const t = priced(1) + state.taskHistory = [t] + render() + fireEvent.click(within(screen.getByTestId("usage-top-tasks")).getByRole("button")) + expect(post).toHaveBeenCalledWith({ type: "showTaskWithId", text: t.id }) + }) + + it("exports a CSV of the filtered range", () => { + state.taskHistory = [priced(1)] + const createObjectURL = vi.fn(() => "blob:x") + const revokeObjectURL = vi.fn() + Object.assign(URL, { createObjectURL, revokeObjectURL }) + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}) + + render() + fireEvent.click(screen.getByTestId("usage-export-csv")) + + expect(createObjectURL).toHaveBeenCalledTimes(1) + expect(click).toHaveBeenCalled() + expect(revokeObjectURL).toHaveBeenCalledWith("blob:x") + click.mockRestore() + }) + }) + + it("exposes the chart to assistive tech as a single labelled image", () => { + state.taskHistory = [priced(1)] + render() + const chart = screen.getByTestId("usage-daily-chart") + expect(chart.getAttribute("role")).toBe("img") + expect(chart.getAttribute("aria-label")).toBeTruthy() + }) + + it("draws one chart bar per day for a bounded range", () => { + state.taskHistory = [priced(1)] + render() + expect(screen.getByTestId("usage-daily-chart").children).toHaveLength(7) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/UsageSettings.visual.fixture.tsx b/webview-ui/src/components/settings/__tests__/UsageSettings.visual.fixture.tsx new file mode 100644 index 0000000000..1dbb6e3c9c --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UsageSettings.visual.fixture.tsx @@ -0,0 +1,143 @@ +import { useEffect } from "react" + +import { providerIdentifiers, VsCodeLmModelsMessageType, type HistoryItem, type ProviderQuota } from "@roo-code/types" + +import { UsageSettings } from "../UsageSettings" +import { AppProviders } from "../../../../playwright/AppProviders" +import { vscode } from "@src/utils/vscode" + +/** Matches the clock the visual test pins, so the history and the "updated" label are stable. */ +export const USAGE_NOW = new Date("2026-06-01T12:00:00Z").getTime() +const DAY = 24 * 60 * 60 * 1000 +const WORKSPACE = "/work/project" + +const task = (n: number, daysAgo: number, overrides: Partial): HistoryItem => ({ + id: `usage-task-${n}`, + number: n, + ts: USAGE_NOW - daysAgo * DAY, + task: `Task ${n}`, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: WORKSPACE, + mode: "code", + ...overrides, +}) + +const used = (apiProvider: string, modelId: string, tokensIn: number, tokensOut: number, cost: number) => [ + { apiProvider, modelId, requests: 4, tokensIn, tokensOut, cost }, +] + +/** A week of mixed priced (Anthropic) and unpriced (Copilot) work. */ +const history: HistoryItem[] = [ + task(1, 0, { + task: "Add a loading spinner to the Save button", + tokensIn: 52_000, + tokensOut: 4_100, + totalCost: 1.84, + cacheReads: 31_000, + usageByModel: used(providerIdentifiers.anthropic, "claude-sonnet-5", 52_000, 4_100, 1.84), + }), + task(2, 1, { + task: "Refactor the auth middleware", + tokensIn: 91_000, + tokensOut: 8_800, + totalCost: 3.27, + cacheReads: 60_000, + mode: "architect", + usageByModel: used(providerIdentifiers.anthropic, "claude-sonnet-5", 91_000, 8_800, 3.27), + }), + task(3, 2, { + task: "Write unit tests for the billing service", + tokensIn: 210_000, + tokensOut: 15_000, + totalCost: 0, + usageByModel: used(providerIdentifiers.githubCopilot, "gpt-5", 210_000, 15_000, 0), + }), + task(4, 4, { + task: "Investigate the flaky checkout test", + tokensIn: 34_000, + tokensOut: 2_900, + totalCost: 0.61, + mode: "debug", + usageByModel: used(providerIdentifiers.anthropic, "claude-haiku-5", 34_000, 2_900, 0.61), + }), +] + +const healthyQuotas: ProviderQuota[] = [ + { + providerId: providerIdentifiers.githubCopilot, + ok: true, + plan: "business", + fetchedAt: USAGE_NOW, + windows: [ + { + id: "premium", + valueLabel: "39,389 / 140,000 used", + usedPercent: 28, + resetAt: new Date("2026-07-01T00:00:00Z").getTime(), + }, + ], + }, + { + providerId: providerIdentifiers.deepseek, + ok: true, + fetchedAt: USAGE_NOW, + windows: [{ id: "balance", valueLabel: "$4.25 left" }], + }, +] + +const degradedQuotas: ProviderQuota[] = [ + { + providerId: providerIdentifiers.githubCopilot, + ok: false, + reason: "signedOut", + fetchedAt: USAGE_NOW, + }, + { providerId: providerIdentifiers.openrouter, ok: false, reason: "noKey", fetchedAt: USAGE_NOW }, + { providerId: providerIdentifiers.deepseek, ok: false, reason: "error", message: "HTTP 500", fetchedAt: USAGE_NOW }, +] + +/** Plays the extension host: answers the component's quota request, echoing its request id. */ +function FakeHost({ quotas }: { quotas: ProviderQuota[] }) { + useEffect(() => { + const original = vscode.postMessage.bind(vscode) + vscode.postMessage = ((message: { type: string; requestId?: string }) => { + if (message.type === VsCodeLmModelsMessageType.requestProviderQuotas) { + queueMicrotask(() => + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: VsCodeLmModelsMessageType.providerQuotas, + providerQuotas: quotas, + requestId: message.requestId, + }, + }), + ), + ) + return + } + return original(message as never) + }) as typeof vscode.postMessage + return () => { + vscode.postMessage = original as typeof vscode.postMessage + } + }, [quotas]) + return null +} + +export type UsageStoryVariant = "populated" | "degraded" | "empty" + +export function UsageSettingsStory({ variant = "populated" }: { variant?: UsageStoryVariant }) { + const quotas = variant === "degraded" ? degradedQuotas : variant === "empty" ? [] : healthyQuotas + return ( + + +
+ +
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/UsageSettings.visual.tsx b/webview-ui/src/components/settings/__tests__/UsageSettings.visual.tsx new file mode 100644 index 0000000000..5b227ac29c --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UsageSettings.visual.tsx @@ -0,0 +1,59 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { expectBoundedLayout } from "../../../../playwright/layout-contracts" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +const NOW = new Date("2026-06-01T12:00:00Z") + +for (const theme of visualThemes) { + test(`renders the Usage tab with provider limits and history in the VS Code ${theme.name} theme`, async ({ + mount, + page, + }) => { + await page.clock.setFixedTime(NOW) + const component = mountedStory(await mount("usage-settings")) + await applyVisualTheme(page, theme) + const story = component.getByTestId("usage-story") + + // Limits arrive from the (fake) host; wait for them so the screenshot is the settled state. + await expect(story.getByTestId(`provider-limit-github-copilot`)).toBeVisible() + await expect(story.getByTestId("usage-stats")).toBeVisible() + await expect(story.getByTestId("usage-daily-chart")).toBeVisible() + + await expect(story).toHaveScreenshot(`usage-settings-${theme.name}.png`) + const exportButton = story.getByTestId("usage-export-csv") + await expectBoundedLayout(page, story, { focusedControl: exportButton }) + }) +} + +test("shows quiet statuses for provider limits that cannot be read, and keeps the history", async ({ mount, page }) => { + await page.clock.setFixedTime(NOW) + const component = mountedStory(await mount("usage-settings-degraded")) + await applyVisualTheme(page, visualThemes[0]) + const story = component.getByTestId("usage-story") + + await expect(story.getByTestId("provider-limit-github-copilot")).toBeVisible() + await expect(story.getByTestId("provider-limit-openrouter")).toBeVisible() + await expect(story.getByTestId("provider-limit-deepseek")).toBeVisible() + // No bars for providers that report nothing, but the usage history is unaffected. + await expect(story.locator("[role=progressbar]")).toHaveCount(0) + await expect(story.getByTestId("usage-stats")).toBeVisible() + + await expect(story).toHaveScreenshot("usage-settings-degraded.png") +}) + +test("shows the empty state, and hides the limits section when no provider has a usage API", async ({ + mount, + page, +}) => { + await page.clock.setFixedTime(NOW) + const component = mountedStory(await mount("usage-settings-empty")) + await applyVisualTheme(page, visualThemes[0]) + const story = component.getByTestId("usage-story") + + await expect(story.getByTestId("usage-empty")).toBeVisible() + await expect(story.getByTestId("provider-limits")).toHaveCount(0) + await expect(story.getByTestId("usage-export-csv")).toHaveCount(0) + + await expect(story).toHaveScreenshot("usage-settings-empty.png") +}) diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-dark.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-dark.png new file mode 100644 index 0000000000..fdbbc1b168 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-dark.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-degraded.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-degraded.png new file mode 100644 index 0000000000..8c04e5d3c0 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-degraded.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-empty.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-empty.png new file mode 100644 index 0000000000..38a44bf83b Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-empty.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast-light.png new file mode 100644 index 0000000000..6e7dff60d5 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast.png new file mode 100644 index 0000000000..5ef985f388 Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-high-contrast.png differ diff --git a/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-light.png b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-light.png new file mode 100644 index 0000000000..dc9e0eddba Binary files /dev/null and b/webview-ui/src/components/settings/__tests__/__screenshots__/usage-settings-light.png differ diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index d39d8edd6a..2f4f9f6730 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -42,7 +42,8 @@ "language": "Idioma", "about": "Sobre Zoo Code", "skills": "Skills", - "rules": "Regles" + "rules": "Regles", + "usage": "Ús" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "La descripció ha de tenir com a màxim 1024 caràcters" }, "footer": "Crea les teves pròpies skills amb el mode Skill Writer, disponible al Modes Marketplace." + }, + "usage": { + "description": "Tokens i cost de l'historial de tasques de Zoo Code en aquesta màquina.", + "range": { + "label": "Interval de temps", + "today": "Avui", + "7d": "7 dies", + "30d": "30 dies", + "all": "Tot el temps" + }, + "scope": { + "label": "Mostra", + "workspace": "Aquest espai de treball", + "all": "Tots els espais de treball" + }, + "empty": "No hi ha tasques en aquest interval de temps.", + "stats": { + "cost": "cost", + "tokens": "tokens", + "tasks": "tasques", + "cacheHit": "{{percent}}% de la memòria cau", + "inOut": "{{input}} d'entrada · {{output}} de sortida", + "models": "models" + }, + "dailyCost": "Cost diari", + "byMode": "Per mode", + "topTasks": "Tasques més cares", + "disclaimer": "Els costos són estimacions de l'historial de tasques i poden diferir de la factura del teu proveïdor.", + "exportCsv": "Exporta CSV", + "noPricingNote": "Alguns proveïdors (p. ex. GitHub Copilot) facturen per subscripció i no informen d'un preu per tasca, per això aquest historial es mostra en tokens. Consulta els límits dels proveïdors a dalt per als crèdits.", + "dailyTokens": "Tokens diaris", + "topTasksTokens": "Tasques més grans", + "byProvider": "Per proveïdor", + "history": "Historial d'ús", + "limits": { + "title": "Límits dels proveïdors", + "refresh": "Actualitza", + "updated": "Actualitzat {{time}}", + "loading": "Comprovant els proveïdors connectats…", + "resets": "Es restableix {{date}}", + "signedOut": "No has iniciat sessió", + "noCopilot": "Sense quota de Copilot", + "noKey": "Clau d'API absent o no vàlida", + "unsupported": "URL base personalitzada — no comprovat", + "error": "No s'ha pogut carregar", + "window": { + "weekly": "Setmanal", + "daily": "Diari", + "monthly": "Mensual", + "unlimited": "Il·limitat", + "hours": "{{n}} hores", + "days": "{{n}} dies" + } + }, + "byModel": "Per model", + "notRecorded": "No registrat", + "noPrice": "sense preu", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 983db1df5d..712aa2d30f 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -42,7 +42,8 @@ "language": "Sprache", "about": "Über Zoo Code", "skills": "Skills", - "rules": "Regeln" + "rules": "Regeln", + "usage": "Nutzung" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Beschreibung muss 1024 Zeichen oder weniger sein" }, "footer": "Erstelle deine eigenen Skills mit dem Skill Writer Modus, verfügbar im Modes Marketplace." + }, + "usage": { + "description": "Tokens und Kosten aus deinem Zoo-Code-Aufgabenverlauf auf diesem Rechner.", + "range": { + "label": "Zeitraum", + "today": "Heute", + "7d": "7 Tage", + "30d": "30 Tage", + "all": "Gesamter Zeitraum" + }, + "scope": { + "label": "Anzeigen", + "workspace": "Dieser Workspace", + "all": "Alle Workspaces" + }, + "empty": "Keine Aufgaben in diesem Zeitraum.", + "stats": { + "cost": "Kosten", + "tokens": "Tokens", + "tasks": "Aufgaben", + "cacheHit": "{{percent}} % aus dem Cache", + "inOut": "{{input}} ein · {{output}} aus", + "models": "Modelle" + }, + "dailyCost": "Tägliche Kosten", + "byMode": "Nach Modus", + "topTasks": "Teuerste Aufgaben", + "disclaimer": "Die Kosten sind Schätzungen aus dem Aufgabenverlauf und können von der Rechnung deines Anbieters abweichen.", + "exportCsv": "CSV exportieren", + "noPricingNote": "Einige Anbieter (z. B. GitHub Copilot) rechnen per Abonnement ab und melden keinen Preis pro Aufgabe, daher wird dieser Verlauf in Tokens angezeigt. Credits findest du oben unter Anbieterlimits.", + "dailyTokens": "Tägliche Tokens", + "topTasksTokens": "Größte Aufgaben", + "byProvider": "Nach Anbieter", + "history": "Nutzungsverlauf", + "limits": { + "title": "Anbieterlimits", + "refresh": "Aktualisieren", + "updated": "Aktualisiert {{time}}", + "loading": "Verbundene Anbieter werden geprüft…", + "resets": "Zurücksetzung {{date}}", + "signedOut": "Nicht angemeldet", + "noCopilot": "Kein Copilot-Kontingent", + "noKey": "API-Schlüssel fehlt oder ist ungültig", + "unsupported": "Benutzerdefinierte Basis-URL – nicht geprüft", + "error": "Konnte nicht geladen werden", + "window": { + "weekly": "Wöchentlich", + "daily": "Täglich", + "monthly": "Monatlich", + "unlimited": "Unbegrenzt", + "hours": "{{n}} Stunden", + "days": "{{n}} Tage" + } + }, + "byModel": "Nach Modell", + "notRecorded": "Nicht erfasst", + "noPrice": "kein Preis", + "tokensShort": "Tok.", + "chartLabel": "{{metric}}, insgesamt {{total}}" } } diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index ea924ce79e..860faa87e0 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -42,7 +42,8 @@ "ui": "UI", "experimental": "Experimental", "language": "Language", - "about": "About Zoo Code" + "about": "About Zoo Code", + "usage": "Usage" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "output": "Output", "cacheReads": "Cache reads" } + }, + "usage": { + "description": "Tokens and cost from your Zoo Code task history on this machine.", + "range": { + "label": "Time range", + "today": "Today", + "7d": "7 days", + "30d": "30 days", + "all": "All time" + }, + "scope": { + "label": "Show", + "workspace": "This workspace", + "all": "All workspaces" + }, + "empty": "No tasks in this time range.", + "stats": { + "cost": "cost", + "tokens": "tokens", + "tasks": "tasks", + "cacheHit": "{{percent}}% from cache", + "inOut": "{{input}} in · {{output}} out", + "models": "models" + }, + "dailyCost": "Daily cost", + "byMode": "By mode", + "topTasks": "Most expensive tasks", + "disclaimer": "Costs are estimates from task history and may differ from your provider's bill.", + "exportCsv": "Export CSV", + "noPricingNote": "Some providers (e.g. GitHub Copilot) bill by subscription and don't report a per-task price, so this history is shown in tokens. See Provider limits above for credits.", + "dailyTokens": "Daily tokens", + "topTasksTokens": "Largest tasks", + "byProvider": "By provider", + "history": "Usage history", + "limits": { + "title": "Provider limits", + "refresh": "Refresh", + "updated": "Updated {{time}}", + "loading": "Checking connected providers…", + "resets": "Resets {{date}}", + "signedOut": "Not signed in", + "noCopilot": "No Copilot quota", + "noKey": "API key missing or invalid", + "unsupported": "Custom base URL — not checked", + "error": "Couldn't load", + "window": { + "weekly": "Weekly", + "daily": "Daily", + "monthly": "Monthly", + "unlimited": "Unlimited", + "hours": "{{n}}-hour", + "days": "{{n}}-day" + } + }, + "byModel": "By model", + "notRecorded": "Not recorded", + "noPrice": "no price", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 57612bf3e5..a4e5dc44c1 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -42,7 +42,8 @@ "language": "Idioma", "about": "Acerca de Zoo Code", "skills": "Skills", - "rules": "Reglas" + "rules": "Reglas", + "usage": "Uso" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "La descripción debe tener como máximo 1024 caracteres" }, "footer": "Crea tus propias skills con el modo Skill Writer, disponible en el Modes Marketplace." + }, + "usage": { + "description": "Tokens y coste del historial de tareas de Zoo Code en este equipo.", + "range": { + "label": "Intervalo de tiempo", + "today": "Hoy", + "7d": "7 días", + "30d": "30 días", + "all": "Todo el tiempo" + }, + "scope": { + "label": "Mostrar", + "workspace": "Este espacio de trabajo", + "all": "Todos los espacios de trabajo" + }, + "empty": "No hay tareas en este intervalo de tiempo.", + "stats": { + "cost": "coste", + "tokens": "tokens", + "tasks": "tareas", + "cacheHit": "{{percent}} % desde la caché", + "inOut": "{{input}} de entrada · {{output}} de salida", + "models": "modelos" + }, + "dailyCost": "Coste diario", + "byMode": "Por modo", + "topTasks": "Tareas más caras", + "disclaimer": "Los costes son estimaciones del historial de tareas y pueden diferir de la factura de tu proveedor.", + "exportCsv": "Exportar CSV", + "noPricingNote": "Algunos proveedores (p. ej. GitHub Copilot) facturan por suscripción y no informan de un precio por tarea, por lo que este historial se muestra en tokens. Consulta los límites de los proveedores arriba para ver los créditos.", + "dailyTokens": "Tokens diarios", + "topTasksTokens": "Tareas más grandes", + "byProvider": "Por proveedor", + "history": "Historial de uso", + "limits": { + "title": "Límites de los proveedores", + "refresh": "Actualizar", + "updated": "Actualizado {{time}}", + "loading": "Comprobando los proveedores conectados…", + "resets": "Se restablece {{date}}", + "signedOut": "Sesión no iniciada", + "noCopilot": "Sin cuota de Copilot", + "noKey": "Falta la clave de API o no es válida", + "unsupported": "URL base personalizada: no comprobado", + "error": "No se pudo cargar", + "window": { + "weekly": "Semanal", + "daily": "Diario", + "monthly": "Mensual", + "unlimited": "Ilimitado", + "hours": "{{n}} horas", + "days": "{{n}} días" + } + }, + "byModel": "Por modelo", + "notRecorded": "No registrado", + "noPrice": "sin precio", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a91a5137d3..656e250050 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -42,7 +42,8 @@ "language": "Langue", "about": "À propos de Zoo Code", "skills": "Skills", - "rules": "Règles" + "rules": "Règles", + "usage": "Utilisation" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "La description doit contenir au maximum 1024 caractères" }, "footer": "Créez vos propres skills avec le mode Skill Writer, disponible dans le Modes Marketplace." + }, + "usage": { + "description": "Tokens et coût de l'historique des tâches de Zoo Code sur cette machine.", + "range": { + "label": "Période", + "today": "Aujourd'hui", + "7d": "7 jours", + "30d": "30 jours", + "all": "Tout" + }, + "scope": { + "label": "Afficher", + "workspace": "Cet espace de travail", + "all": "Tous les espaces de travail" + }, + "empty": "Aucune tâche sur cette période.", + "stats": { + "cost": "coût", + "tokens": "tokens", + "tasks": "tâches", + "cacheHit": "{{percent}} % depuis le cache", + "inOut": "{{input}} en entrée · {{output}} en sortie", + "models": "modèles" + }, + "dailyCost": "Coût quotidien", + "byMode": "Par mode", + "topTasks": "Tâches les plus coûteuses", + "disclaimer": "Les coûts sont des estimations issues de l'historique des tâches et peuvent différer de la facture de votre fournisseur.", + "exportCsv": "Exporter en CSV", + "noPricingNote": "Certains fournisseurs (p. ex. GitHub Copilot) facturent par abonnement et ne communiquent pas de prix par tâche ; cet historique est donc affiché en tokens. Consultez les limites des fournisseurs ci-dessus pour les crédits.", + "dailyTokens": "Tokens quotidiens", + "topTasksTokens": "Plus grosses tâches", + "byProvider": "Par fournisseur", + "history": "Historique d'utilisation", + "limits": { + "title": "Limites des fournisseurs", + "refresh": "Actualiser", + "updated": "Mis à jour {{time}}", + "loading": "Vérification des fournisseurs connectés…", + "resets": "Réinitialisation {{date}}", + "signedOut": "Non connecté", + "noCopilot": "Aucun quota Copilot", + "noKey": "Clé API manquante ou invalide", + "unsupported": "URL de base personnalisée — non vérifiée", + "error": "Impossible de charger", + "window": { + "weekly": "Hebdomadaire", + "daily": "Quotidien", + "monthly": "Mensuel", + "unlimited": "Illimité", + "hours": "{{n}} heures", + "days": "{{n}} jours" + } + }, + "byModel": "Par modèle", + "notRecorded": "Non enregistré", + "noPrice": "sans prix", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index bc6e973623..af07b366f1 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -42,7 +42,8 @@ "language": "भाषा", "about": "परिचय", "skills": "Skills", - "rules": "नियम" + "rules": "नियम", + "usage": "उपयोग" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "विवरण 1024 वर्णों से अधिक नहीं होना चाहिए" }, "footer": "Skill Writer मोड के साथ अपने स्वयं के Skills बनाएं, Modes Marketplace में उपलब्ध।" + }, + "usage": { + "description": "इस मशीन पर आपके Zoo Code टास्क इतिहास से टोकन और लागत।", + "range": { + "label": "समय सीमा", + "today": "आज", + "7d": "7 दिन", + "30d": "30 दिन", + "all": "सभी समय" + }, + "scope": { + "label": "दिखाएँ", + "workspace": "यह वर्कस्पेस", + "all": "सभी वर्कस्पेस" + }, + "empty": "इस समय सीमा में कोई टास्क नहीं।", + "stats": { + "cost": "लागत", + "tokens": "टोकन", + "tasks": "टास्क", + "cacheHit": "{{percent}}% कैश से", + "inOut": "{{input}} इनपुट · {{output}} आउटपुट", + "models": "मॉडल" + }, + "dailyCost": "दैनिक लागत", + "byMode": "मोड के अनुसार", + "topTasks": "सबसे महंगे टास्क", + "disclaimer": "लागत टास्क इतिहास से अनुमान हैं और आपके प्रदाता के बिल से भिन्न हो सकती हैं।", + "exportCsv": "CSV निर्यात करें", + "noPricingNote": "कुछ प्रदाता (जैसे GitHub Copilot) सदस्यता के आधार पर बिल करते हैं और प्रति-टास्क मूल्य नहीं बताते, इसलिए यह इतिहास टोकन में दिखाया गया है। क्रेडिट के लिए ऊपर प्रदाता सीमाएँ देखें।", + "dailyTokens": "दैनिक टोकन", + "topTasksTokens": "सबसे बड़े टास्क", + "byProvider": "प्रदाता के अनुसार", + "history": "उपयोग इतिहास", + "limits": { + "title": "प्रदाता सीमाएँ", + "refresh": "रीफ़्रेश करें", + "updated": "{{time}} को अपडेट किया गया", + "loading": "कनेक्टेड प्रदाताओं की जाँच हो रही है…", + "resets": "{{date}} को रीसेट होगा", + "signedOut": "साइन इन नहीं है", + "noCopilot": "कोई Copilot कोटा नहीं", + "noKey": "API कुंजी अनुपलब्ध या अमान्य", + "unsupported": "कस्टम बेस URL — जाँच नहीं की गई", + "error": "लोड नहीं हो सका", + "window": { + "weekly": "साप्ताहिक", + "daily": "दैनिक", + "monthly": "मासिक", + "unlimited": "असीमित", + "hours": "{{n}} घंटे", + "days": "{{n}} दिन" + } + }, + "byModel": "मॉडल के अनुसार", + "notRecorded": "दर्ज नहीं", + "noPrice": "कोई मूल्य नहीं", + "tokensShort": "टोकन", + "chartLabel": "{{metric}}, कुल {{total}}" } } diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3ed57f373e..acfe059c97 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -42,7 +42,8 @@ "language": "Bahasa", "about": "Tentang Zoo Code", "skills": "Skills", - "rules": "Aturan" + "rules": "Aturan", + "usage": "Penggunaan" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Deskripsi harus 1024 karakter atau kurang" }, "footer": "Buat skills Anda sendiri dengan mode Skill Writer, tersedia di Modes Marketplace." + }, + "usage": { + "description": "Token dan biaya dari riwayat tugas Zoo Code Anda di mesin ini.", + "range": { + "label": "Rentang waktu", + "today": "Hari ini", + "7d": "7 hari", + "30d": "30 hari", + "all": "Sepanjang waktu" + }, + "scope": { + "label": "Tampilkan", + "workspace": "Workspace ini", + "all": "Semua workspace" + }, + "empty": "Tidak ada tugas dalam rentang waktu ini.", + "stats": { + "cost": "biaya", + "tokens": "token", + "tasks": "tugas", + "cacheHit": "{{percent}}% dari cache", + "inOut": "{{input}} masuk · {{output}} keluar", + "models": "model" + }, + "dailyCost": "Biaya harian", + "byMode": "Menurut mode", + "topTasks": "Tugas termahal", + "disclaimer": "Biaya adalah perkiraan dari riwayat tugas dan bisa berbeda dari tagihan penyedia Anda.", + "exportCsv": "Ekspor CSV", + "noPricingNote": "Beberapa penyedia (mis. GitHub Copilot) menagih berdasarkan langganan dan tidak melaporkan harga per tugas, sehingga riwayat ini ditampilkan dalam token. Lihat Batas penyedia di atas untuk kredit.", + "dailyTokens": "Token harian", + "topTasksTokens": "Tugas terbesar", + "byProvider": "Menurut penyedia", + "history": "Riwayat penggunaan", + "limits": { + "title": "Batas penyedia", + "refresh": "Segarkan", + "updated": "Diperbarui {{time}}", + "loading": "Memeriksa penyedia yang terhubung…", + "resets": "Diatur ulang {{date}}", + "signedOut": "Belum masuk", + "noCopilot": "Tidak ada kuota Copilot", + "noKey": "Kunci API hilang atau tidak valid", + "unsupported": "URL dasar kustom — tidak diperiksa", + "error": "Tidak dapat dimuat", + "window": { + "weekly": "Mingguan", + "daily": "Harian", + "monthly": "Bulanan", + "unlimited": "Tanpa batas", + "hours": "{{n}} jam", + "days": "{{n}} hari" + } + }, + "byModel": "Menurut model", + "notRecorded": "Tidak dicatat", + "noPrice": "tanpa harga", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b708304e63..3c658c7f48 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -42,7 +42,8 @@ "language": "Lingua", "about": "Informazioni su Zoo Code", "skills": "Skills", - "rules": "Regole" + "rules": "Regole", + "usage": "Utilizzo" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "La descrizione deve essere di massimo 1024 caratteri" }, "footer": "Crea le tue skill con la modalità Skill Writer, disponibile in Modes Marketplace." + }, + "usage": { + "description": "Token e costi dalla cronologia delle attività di Zoo Code su questo computer.", + "range": { + "label": "Intervallo di tempo", + "today": "Oggi", + "7d": "7 giorni", + "30d": "30 giorni", + "all": "Sempre" + }, + "scope": { + "label": "Mostra", + "workspace": "Questo workspace", + "all": "Tutti i workspace" + }, + "empty": "Nessuna attività in questo intervallo di tempo.", + "stats": { + "cost": "costo", + "tokens": "token", + "tasks": "attività", + "cacheHit": "{{percent}}% dalla cache", + "inOut": "{{input}} in ingresso · {{output}} in uscita", + "models": "modelli" + }, + "dailyCost": "Costo giornaliero", + "byMode": "Per modalità", + "topTasks": "Attività più costose", + "disclaimer": "I costi sono stime basate sulla cronologia delle attività e possono differire dalla fattura del tuo provider.", + "exportCsv": "Esporta CSV", + "noPricingNote": "Alcuni provider (es. GitHub Copilot) fatturano tramite abbonamento e non riportano un prezzo per attività, quindi questa cronologia è mostrata in token. Vedi i limiti dei provider sopra per i crediti.", + "dailyTokens": "Token giornalieri", + "topTasksTokens": "Attività più grandi", + "byProvider": "Per provider", + "history": "Cronologia di utilizzo", + "limits": { + "title": "Limiti dei provider", + "refresh": "Aggiorna", + "updated": "Aggiornato {{time}}", + "loading": "Controllo dei provider collegati…", + "resets": "Si reimposta {{date}}", + "signedOut": "Accesso non effettuato", + "noCopilot": "Nessuna quota Copilot", + "noKey": "Chiave API mancante o non valida", + "unsupported": "URL di base personalizzato — non controllato", + "error": "Impossibile caricare", + "window": { + "weekly": "Settimanale", + "daily": "Giornaliero", + "monthly": "Mensile", + "unlimited": "Illimitato", + "hours": "{{n}} ore", + "days": "{{n}} giorni" + } + }, + "byModel": "Per modello", + "notRecorded": "Non registrato", + "noPrice": "senza prezzo", + "tokensShort": "tok", + "chartLabel": "{{metric}}, totale {{total}}" } } diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index f6959b50dc..4702c26c66 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -42,7 +42,8 @@ "language": "言語", "about": "Zoo Codeについて", "skills": "Skills", - "rules": "ルール" + "rules": "ルール", + "usage": "使用状況" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "説明は1024文字以内である必要があります" }, "footer": "スキルライターモードで独自のスキルを作成します。モードマーケットプレイスで入手可能です。" + }, + "usage": { + "description": "このマシン上の Zoo Code のタスク履歴によるトークンとコスト。", + "range": { + "label": "期間", + "today": "今日", + "7d": "7日間", + "30d": "30日間", + "all": "全期間" + }, + "scope": { + "label": "表示", + "workspace": "このワークスペース", + "all": "すべてのワークスペース" + }, + "empty": "この期間にタスクはありません。", + "stats": { + "cost": "コスト", + "tokens": "トークン", + "tasks": "タスク", + "cacheHit": "{{percent}}% がキャッシュから", + "inOut": "入力 {{input}} · 出力 {{output}}", + "models": "モデル" + }, + "dailyCost": "日別コスト", + "byMode": "モード別", + "topTasks": "コストの高いタスク", + "disclaimer": "コストはタスク履歴からの推定値であり、プロバイダーの請求額と異なる場合があります。", + "exportCsv": "CSV をエクスポート", + "noPricingNote": "一部のプロバイダー(GitHub Copilot など)はサブスクリプション課金で、タスクごとの価格を報告しないため、この履歴はトークンで表示されます。クレジットについては上のプロバイダーの上限を参照してください。", + "dailyTokens": "日別トークン", + "topTasksTokens": "大きいタスク", + "byProvider": "プロバイダー別", + "history": "使用履歴", + "limits": { + "title": "プロバイダーの上限", + "refresh": "更新", + "updated": "{{time}} に更新", + "loading": "接続済みのプロバイダーを確認中…", + "resets": "{{date}} にリセット", + "signedOut": "サインインしていません", + "noCopilot": "Copilot のクォータなし", + "noKey": "API キーがないか無効です", + "unsupported": "カスタムベース URL — 確認していません", + "error": "読み込めませんでした", + "window": { + "weekly": "週間", + "daily": "日次", + "monthly": "月間", + "unlimited": "無制限", + "hours": "{{n}}時間", + "days": "{{n}}日" + } + }, + "byModel": "モデル別", + "notRecorded": "記録なし", + "noPrice": "価格なし", + "tokensShort": "トークン", + "chartLabel": "{{metric}}、合計 {{total}}" } } diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 6cadbd8a3b..19db9f45bc 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -42,7 +42,8 @@ "language": "언어", "about": "Zoo Code 정보", "skills": "Skills", - "rules": "규칙" + "rules": "규칙", + "usage": "사용량" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "설명은 1024자 이하여야 합니다" }, "footer": "스킬 작성자 모드로 자신만의 스킬을 만들 수 있습니다. 모드 마켓플레이스에서 사용 가능합니다." + }, + "usage": { + "description": "이 컴퓨터의 Zoo Code 작업 기록에서 집계한 토큰과 비용입니다.", + "range": { + "label": "기간", + "today": "오늘", + "7d": "7일", + "30d": "30일", + "all": "전체 기간" + }, + "scope": { + "label": "표시", + "workspace": "이 워크스페이스", + "all": "모든 워크스페이스" + }, + "empty": "이 기간에 작업이 없습니다.", + "stats": { + "cost": "비용", + "tokens": "토큰", + "tasks": "작업", + "cacheHit": "{{percent}}% 캐시에서", + "inOut": "입력 {{input}} · 출력 {{output}}", + "models": "모델" + }, + "dailyCost": "일별 비용", + "byMode": "모드별", + "topTasks": "비용이 큰 작업", + "disclaimer": "비용은 작업 기록을 기반으로 한 추정치이며 공급자의 청구서와 다를 수 있습니다.", + "exportCsv": "CSV 내보내기", + "noPricingNote": "일부 공급자(예: GitHub Copilot)는 구독 방식으로 청구하며 작업별 가격을 보고하지 않으므로 이 기록은 토큰으로 표시됩니다. 크레딧은 위의 공급자 한도를 확인하세요.", + "dailyTokens": "일별 토큰", + "topTasksTokens": "큰 작업", + "byProvider": "공급자별", + "history": "사용 기록", + "limits": { + "title": "공급자 한도", + "refresh": "새로 고침", + "updated": "{{time}}에 업데이트됨", + "loading": "연결된 공급자를 확인하는 중…", + "resets": "{{date}}에 초기화", + "signedOut": "로그인하지 않음", + "noCopilot": "Copilot 할당량 없음", + "noKey": "API 키가 없거나 올바르지 않음", + "unsupported": "사용자 지정 기본 URL — 확인하지 않음", + "error": "불러올 수 없음", + "window": { + "weekly": "주간", + "daily": "일간", + "monthly": "월간", + "unlimited": "무제한", + "hours": "{{n}}시간", + "days": "{{n}}일" + } + }, + "byModel": "모델별", + "notRecorded": "기록되지 않음", + "noPrice": "가격 없음", + "tokensShort": "토큰", + "chartLabel": "{{metric}}, 합계 {{total}}" } } diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 992e9c53e0..df330c031d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -42,7 +42,8 @@ "language": "Taal", "about": "Over Zoo Code", "skills": "Skills", - "rules": "Regels" + "rules": "Regels", + "usage": "Gebruik" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Beschrijving moet maximaal 1024 tekens zijn" }, "footer": "Maak je eigen skills met de Skill Writer modus, beschikbaar in de Modes Marketplace." + }, + "usage": { + "description": "Tokens en kosten uit je Zoo Code-takengeschiedenis op deze machine.", + "range": { + "label": "Tijdsbereik", + "today": "Vandaag", + "7d": "7 dagen", + "30d": "30 dagen", + "all": "Alle tijd" + }, + "scope": { + "label": "Tonen", + "workspace": "Deze werkruimte", + "all": "Alle werkruimtes" + }, + "empty": "Geen taken in dit tijdsbereik.", + "stats": { + "cost": "kosten", + "tokens": "tokens", + "tasks": "taken", + "cacheHit": "{{percent}}% uit cache", + "inOut": "{{input}} in · {{output}} uit", + "models": "modellen" + }, + "dailyCost": "Dagelijkse kosten", + "byMode": "Per modus", + "topTasks": "Duurste taken", + "disclaimer": "Kosten zijn schattingen op basis van de takengeschiedenis en kunnen afwijken van de rekening van je provider.", + "exportCsv": "CSV exporteren", + "noPricingNote": "Sommige providers (bijv. GitHub Copilot) factureren via een abonnement en melden geen prijs per taak, daarom wordt deze geschiedenis in tokens getoond. Zie hierboven de providerlimieten voor credits.", + "dailyTokens": "Dagelijkse tokens", + "topTasksTokens": "Grootste taken", + "byProvider": "Per provider", + "history": "Gebruiksgeschiedenis", + "limits": { + "title": "Providerlimieten", + "refresh": "Vernieuwen", + "updated": "Bijgewerkt {{time}}", + "loading": "Verbonden providers controleren…", + "resets": "Reset {{date}}", + "signedOut": "Niet aangemeld", + "noCopilot": "Geen Copilot-quotum", + "noKey": "API-sleutel ontbreekt of is ongeldig", + "unsupported": "Aangepaste basis-URL — niet gecontroleerd", + "error": "Kon niet laden", + "window": { + "weekly": "Wekelijks", + "daily": "Dagelijks", + "monthly": "Maandelijks", + "unlimited": "Onbeperkt", + "hours": "{{n}} uur", + "days": "{{n}} dagen" + } + }, + "byModel": "Per model", + "notRecorded": "Niet vastgelegd", + "noPrice": "geen prijs", + "tokensShort": "tok", + "chartLabel": "{{metric}}, totaal {{total}}" } } diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 459675b0e1..908436ec20 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -42,7 +42,8 @@ "language": "Język", "about": "O Zoo Code", "skills": "Skills", - "rules": "Reguły" + "rules": "Reguły", + "usage": "Użycie" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Opis musi mieć maksymalnie 1024 znaki" }, "footer": "Twórz własne umiejętności za pomocą trybu Skill Writer, dostępnego w Modes Marketplace." + }, + "usage": { + "description": "Tokeny i koszty z historii zadań Zoo Code na tym komputerze.", + "range": { + "label": "Zakres czasu", + "today": "Dzisiaj", + "7d": "7 dni", + "30d": "30 dni", + "all": "Cały czas" + }, + "scope": { + "label": "Pokaż", + "workspace": "Ten obszar roboczy", + "all": "Wszystkie obszary robocze" + }, + "empty": "Brak zadań w tym zakresie czasu.", + "stats": { + "cost": "koszt", + "tokens": "tokeny", + "tasks": "zadania", + "cacheHit": "{{percent}}% z pamięci podręcznej", + "inOut": "{{input}} wejście · {{output}} wyjście", + "models": "modele" + }, + "dailyCost": "Koszt dzienny", + "byMode": "Według trybu", + "topTasks": "Najdroższe zadania", + "disclaimer": "Koszty są szacunkami z historii zadań i mogą różnić się od rachunku od dostawcy.", + "exportCsv": "Eksportuj CSV", + "noPricingNote": "Niektórzy dostawcy (np. GitHub Copilot) rozliczają się w ramach subskrypcji i nie podają ceny za zadanie, dlatego ta historia jest pokazana w tokenach. Kredyty znajdziesz wyżej w limitach dostawców.", + "dailyTokens": "Tokeny dzienne", + "topTasksTokens": "Największe zadania", + "byProvider": "Według dostawcy", + "history": "Historia użycia", + "limits": { + "title": "Limity dostawców", + "refresh": "Odśwież", + "updated": "Zaktualizowano {{time}}", + "loading": "Sprawdzanie połączonych dostawców…", + "resets": "Reset {{date}}", + "signedOut": "Nie zalogowano", + "noCopilot": "Brak limitu Copilot", + "noKey": "Brak klucza API lub jest nieprawidłowy", + "unsupported": "Niestandardowy bazowy URL — nie sprawdzono", + "error": "Nie udało się wczytać", + "window": { + "weekly": "Tygodniowo", + "daily": "Dziennie", + "monthly": "Miesięcznie", + "unlimited": "Bez limitu", + "hours": "{{n}} godz.", + "days": "{{n}} dni" + } + }, + "byModel": "Według modelu", + "notRecorded": "Nie zapisano", + "noPrice": "brak ceny", + "tokensShort": "tok.", + "chartLabel": "{{metric}}, łącznie {{total}}" } } diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 500babaaa8..36947565a8 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -42,7 +42,8 @@ "language": "Idioma", "about": "Sobre", "skills": "Skills", - "rules": "Regras" + "rules": "Regras", + "usage": "Uso" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "A descrição deve ter no máximo 1024 caracteres" }, "footer": "Crie suas próprias skills com o modo Skill Writer, disponível em Modes Marketplace." + }, + "usage": { + "description": "Tokens e custo do histórico de tarefas do Zoo Code nesta máquina.", + "range": { + "label": "Intervalo de tempo", + "today": "Hoje", + "7d": "7 dias", + "30d": "30 dias", + "all": "Todo o período" + }, + "scope": { + "label": "Mostrar", + "workspace": "Este espaço de trabalho", + "all": "Todos os espaços de trabalho" + }, + "empty": "Nenhuma tarefa neste intervalo de tempo.", + "stats": { + "cost": "custo", + "tokens": "tokens", + "tasks": "tarefas", + "cacheHit": "{{percent}}% do cache", + "inOut": "{{input}} de entrada · {{output}} de saída", + "models": "modelos" + }, + "dailyCost": "Custo diário", + "byMode": "Por modo", + "topTasks": "Tarefas mais caras", + "disclaimer": "Os custos são estimativas do histórico de tarefas e podem diferir da fatura do seu provedor.", + "exportCsv": "Exportar CSV", + "noPricingNote": "Alguns provedores (ex.: GitHub Copilot) cobram por assinatura e não informam um preço por tarefa, por isso este histórico é mostrado em tokens. Veja os limites dos provedores acima para os créditos.", + "dailyTokens": "Tokens diários", + "topTasksTokens": "Maiores tarefas", + "byProvider": "Por provedor", + "history": "Histórico de uso", + "limits": { + "title": "Limites dos provedores", + "refresh": "Atualizar", + "updated": "Atualizado {{time}}", + "loading": "Verificando os provedores conectados…", + "resets": "Redefine {{date}}", + "signedOut": "Sem login", + "noCopilot": "Sem cota do Copilot", + "noKey": "Chave de API ausente ou inválida", + "unsupported": "URL base personalizada — não verificada", + "error": "Não foi possível carregar", + "window": { + "weekly": "Semanal", + "daily": "Diário", + "monthly": "Mensal", + "unlimited": "Ilimitado", + "hours": "{{n}} horas", + "days": "{{n}} dias" + } + }, + "byModel": "Por modelo", + "notRecorded": "Não registrado", + "noPrice": "sem preço", + "tokensShort": "tok", + "chartLabel": "{{metric}}, total {{total}}" } } diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 053ff0684d..259921ab43 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -42,7 +42,8 @@ "language": "Язык", "about": "О Zoo Code", "skills": "Skills", - "rules": "Правила" + "rules": "Правила", + "usage": "Использование" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Описание должно быть не более 1024 символов" }, "footer": "Создавайте собственные навыки с режимом Skill Writer, доступным в Modes Marketplace." + }, + "usage": { + "description": "Токены и стоимость по истории задач Zoo Code на этом компьютере.", + "range": { + "label": "Период", + "today": "Сегодня", + "7d": "7 дней", + "30d": "30 дней", + "all": "Всё время" + }, + "scope": { + "label": "Показать", + "workspace": "Эта рабочая область", + "all": "Все рабочие области" + }, + "empty": "В этом периоде нет задач.", + "stats": { + "cost": "стоимость", + "tokens": "токены", + "tasks": "задачи", + "cacheHit": "{{percent}}% из кэша", + "inOut": "{{input}} вход · {{output}} выход", + "models": "модели" + }, + "dailyCost": "Стоимость по дням", + "byMode": "По режимам", + "topTasks": "Самые дорогие задачи", + "disclaimer": "Стоимость — это оценка по истории задач, она может отличаться от счёта вашего провайдера.", + "exportCsv": "Экспорт в CSV", + "noPricingNote": "Некоторые провайдеры (например, GitHub Copilot) тарифицируют по подписке и не сообщают цену за задачу, поэтому эта история показана в токенах. Кредиты смотрите выше в лимитах провайдеров.", + "dailyTokens": "Токены по дням", + "topTasksTokens": "Самые крупные задачи", + "byProvider": "По провайдерам", + "history": "История использования", + "limits": { + "title": "Лимиты провайдеров", + "refresh": "Обновить", + "updated": "Обновлено {{time}}", + "loading": "Проверка подключённых провайдеров…", + "resets": "Сброс {{date}}", + "signedOut": "Вход не выполнен", + "noCopilot": "Нет квоты Copilot", + "noKey": "Ключ API отсутствует или недействителен", + "unsupported": "Пользовательский базовый URL — не проверено", + "error": "Не удалось загрузить", + "window": { + "weekly": "Еженедельно", + "daily": "Ежедневно", + "monthly": "Ежемесячно", + "unlimited": "Без ограничений", + "hours": "{{n}} ч", + "days": "{{n}} дн." + } + }, + "byModel": "По моделям", + "notRecorded": "Не записано", + "noPrice": "без цены", + "tokensShort": "ток.", + "chartLabel": "{{metric}}, всего {{total}}" } } diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index b37f28d44d..0ab8ca6e38 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -42,7 +42,8 @@ "language": "Dil", "about": "Zoo Code Hakkında", "skills": "Skills", - "rules": "Kurallar" + "rules": "Kurallar", + "usage": "Kullanım" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Açıklama en fazla 1024 karakter olmalıdır" }, "footer": "Skill Writer modu ile kendi becerilerinizi oluşturun. Modes Marketplace'de mevcut." + }, + "usage": { + "description": "Bu makinedeki Zoo Code görev geçmişinizden token ve maliyet.", + "range": { + "label": "Zaman aralığı", + "today": "Bugün", + "7d": "7 gün", + "30d": "30 gün", + "all": "Tüm zamanlar" + }, + "scope": { + "label": "Göster", + "workspace": "Bu çalışma alanı", + "all": "Tüm çalışma alanları" + }, + "empty": "Bu zaman aralığında görev yok.", + "stats": { + "cost": "maliyet", + "tokens": "token", + "tasks": "görev", + "cacheHit": "%{{percent}} önbellekten", + "inOut": "{{input}} giriş · {{output}} çıkış", + "models": "model" + }, + "dailyCost": "Günlük maliyet", + "byMode": "Moda göre", + "topTasks": "En pahalı görevler", + "disclaimer": "Maliyetler görev geçmişinden yapılan tahminlerdir ve sağlayıcınızın faturasından farklı olabilir.", + "exportCsv": "CSV olarak dışa aktar", + "noPricingNote": "Bazı sağlayıcılar (ör. GitHub Copilot) abonelik üzerinden faturalandırır ve görev başına fiyat bildirmez; bu yüzden bu geçmiş token cinsinden gösterilir. Krediler için yukarıdaki sağlayıcı limitlerine bakın.", + "dailyTokens": "Günlük token", + "topTasksTokens": "En büyük görevler", + "byProvider": "Sağlayıcıya göre", + "history": "Kullanım geçmişi", + "limits": { + "title": "Sağlayıcı limitleri", + "refresh": "Yenile", + "updated": "Güncellendi: {{time}}", + "loading": "Bağlı sağlayıcılar kontrol ediliyor…", + "resets": "Sıfırlanma: {{date}}", + "signedOut": "Oturum açılmadı", + "noCopilot": "Copilot kotası yok", + "noKey": "API anahtarı eksik veya geçersiz", + "unsupported": "Özel temel URL — kontrol edilmedi", + "error": "Yüklenemedi", + "window": { + "weekly": "Haftalık", + "daily": "Günlük", + "monthly": "Aylık", + "unlimited": "Sınırsız", + "hours": "{{n}} saat", + "days": "{{n}} gün" + } + }, + "byModel": "Modele göre", + "notRecorded": "Kaydedilmedi", + "noPrice": "fiyat yok", + "tokensShort": "tok", + "chartLabel": "{{metric}}, toplam {{total}}" } } diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 94843217ae..a93b0ea60c 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -42,7 +42,8 @@ "language": "Ngôn ngữ", "about": "Giới thiệu", "skills": "Skills", - "rules": "Quy tắc" + "rules": "Quy tắc", + "usage": "Mức sử dụng" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "Mô tả phải có tối đa 1024 ký tự" }, "footer": "Tạo các skill của riêng bạn với chế độ Skill Writer, có sẵn tại Modes Marketplace." + }, + "usage": { + "description": "Token và chi phí từ lịch sử nhiệm vụ Zoo Code của bạn trên máy này.", + "range": { + "label": "Khoảng thời gian", + "today": "Hôm nay", + "7d": "7 ngày", + "30d": "30 ngày", + "all": "Mọi lúc" + }, + "scope": { + "label": "Hiển thị", + "workspace": "Không gian làm việc này", + "all": "Tất cả không gian làm việc" + }, + "empty": "Không có nhiệm vụ nào trong khoảng thời gian này.", + "stats": { + "cost": "chi phí", + "tokens": "token", + "tasks": "nhiệm vụ", + "cacheHit": "{{percent}}% từ bộ nhớ đệm", + "inOut": "{{input}} vào · {{output}} ra", + "models": "mô hình" + }, + "dailyCost": "Chi phí hằng ngày", + "byMode": "Theo chế độ", + "topTasks": "Nhiệm vụ tốn kém nhất", + "disclaimer": "Chi phí là ước tính từ lịch sử nhiệm vụ và có thể khác với hóa đơn của nhà cung cấp.", + "exportCsv": "Xuất CSV", + "noPricingNote": "Một số nhà cung cấp (ví dụ GitHub Copilot) tính phí theo gói đăng ký và không báo cáo giá cho từng nhiệm vụ, nên lịch sử này được hiển thị bằng token. Xem Giới hạn nhà cung cấp ở trên để biết tín dụng.", + "dailyTokens": "Token hằng ngày", + "topTasksTokens": "Nhiệm vụ lớn nhất", + "byProvider": "Theo nhà cung cấp", + "history": "Lịch sử sử dụng", + "limits": { + "title": "Giới hạn nhà cung cấp", + "refresh": "Làm mới", + "updated": "Cập nhật {{time}}", + "loading": "Đang kiểm tra các nhà cung cấp đã kết nối…", + "resets": "Đặt lại {{date}}", + "signedOut": "Chưa đăng nhập", + "noCopilot": "Không có hạn mức Copilot", + "noKey": "Thiếu khóa API hoặc không hợp lệ", + "unsupported": "URL cơ sở tùy chỉnh — không kiểm tra", + "error": "Không thể tải", + "window": { + "weekly": "Hằng tuần", + "daily": "Hằng ngày", + "monthly": "Hằng tháng", + "unlimited": "Không giới hạn", + "hours": "{{n}} giờ", + "days": "{{n}} ngày" + } + }, + "byModel": "Theo mô hình", + "notRecorded": "Chưa ghi nhận", + "noPrice": "không có giá", + "tokensShort": "tok", + "chartLabel": "{{metric}}, tổng {{total}}" } } diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8be0fe1532..bcbf165d7e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -42,7 +42,8 @@ "language": "语言", "about": "关于 Zoo Code", "skills": "Skills", - "rules": "规则" + "rules": "规则", + "usage": "用量" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "描述不得超过1024个字符" }, "footer": "使用技能编写器模式创建您自己的技能,可在 模式市集 中获得。" + }, + "usage": { + "description": "来自此设备上 Zoo Code 任务历史的 Token 和费用。", + "range": { + "label": "时间范围", + "today": "今天", + "7d": "7 天", + "30d": "30 天", + "all": "全部时间" + }, + "scope": { + "label": "显示", + "workspace": "此工作区", + "all": "所有工作区" + }, + "empty": "此时间范围内没有任务。", + "stats": { + "cost": "费用", + "tokens": "Token", + "tasks": "任务", + "cacheHit": "{{percent}}% 来自缓存", + "inOut": "输入 {{input}} · 输出 {{output}}", + "models": "模型" + }, + "dailyCost": "每日费用", + "byMode": "按模式", + "topTasks": "费用最高的任务", + "disclaimer": "费用为根据任务历史得出的估算值,可能与提供商的账单不同。", + "exportCsv": "导出 CSV", + "noPricingNote": "部分提供商(如 GitHub Copilot)按订阅计费,不报告每个任务的价格,因此此历史以 Token 显示。额度请参见上方的提供商限额。", + "dailyTokens": "每日 Token", + "topTasksTokens": "最大的任务", + "byProvider": "按提供商", + "history": "用量历史", + "limits": { + "title": "提供商限额", + "refresh": "刷新", + "updated": "更新于 {{time}}", + "loading": "正在检查已连接的提供商…", + "resets": "重置于 {{date}}", + "signedOut": "未登录", + "noCopilot": "无 Copilot 配额", + "noKey": "API 密钥缺失或无效", + "unsupported": "自定义基础 URL — 未检查", + "error": "无法加载", + "window": { + "weekly": "每周", + "daily": "每日", + "monthly": "每月", + "unlimited": "无限制", + "hours": "{{n}} 小时", + "days": "{{n}} 天" + } + }, + "byModel": "按模型", + "notRecorded": "未记录", + "noPrice": "无价格", + "tokensShort": "Token", + "chartLabel": "{{metric}},共 {{total}}" } } diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 65eb5c67a5..5f70bdd4ba 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -42,7 +42,8 @@ "language": "語言", "about": "關於 Zoo Code", "skills": "Skills", - "rules": "規則" + "rules": "規則", + "usage": "用量" }, "about": { "bugReport": { @@ -1233,5 +1234,64 @@ "descriptionTooLong": "說明不得超過1024個字元" }, "footer": "使用技能編寫器模式建立您自己的技能,可在 模式市集 中取得。" + }, + "usage": { + "description": "來自此裝置上 Zoo Code 任務歷史的 Token 與費用。", + "range": { + "label": "時間範圍", + "today": "今天", + "7d": "7 天", + "30d": "30 天", + "all": "全部時間" + }, + "scope": { + "label": "顯示", + "workspace": "此工作區", + "all": "所有工作區" + }, + "empty": "此時間範圍內沒有任務。", + "stats": { + "cost": "費用", + "tokens": "Token", + "tasks": "任務", + "cacheHit": "{{percent}}% 來自快取", + "inOut": "輸入 {{input}} · 輸出 {{output}}", + "models": "模型" + }, + "dailyCost": "每日費用", + "byMode": "依模式", + "topTasks": "費用最高的任務", + "disclaimer": "費用為根據任務歷史得出的估算值,可能與供應商的帳單不同。", + "exportCsv": "匯出 CSV", + "noPricingNote": "部分供應商(如 GitHub Copilot)按訂閱計費,不回報每個任務的價格,因此此歷史以 Token 顯示。額度請參閱上方的供應商限額。", + "dailyTokens": "每日 Token", + "topTasksTokens": "最大的任務", + "byProvider": "依供應商", + "history": "用量歷史", + "limits": { + "title": "供應商限額", + "refresh": "重新整理", + "updated": "更新於 {{time}}", + "loading": "正在檢查已連線的供應商…", + "resets": "重設於 {{date}}", + "signedOut": "未登入", + "noCopilot": "無 Copilot 配額", + "noKey": "API 金鑰遺失或無效", + "unsupported": "自訂基底 URL — 未檢查", + "error": "無法載入", + "window": { + "weekly": "每週", + "daily": "每日", + "monthly": "每月", + "unlimited": "無限制", + "hours": "{{n}} 小時", + "days": "{{n}} 天" + } + }, + "byModel": "依模型", + "notRecorded": "未記錄", + "noPrice": "無價格", + "tokensShort": "Token", + "chartLabel": "{{metric}},共 {{total}}" } } diff --git a/webview-ui/src/utils/__tests__/providerLabel.spec.ts b/webview-ui/src/utils/__tests__/providerLabel.spec.ts new file mode 100644 index 0000000000..e2401a4f45 --- /dev/null +++ b/webview-ui/src/utils/__tests__/providerLabel.spec.ts @@ -0,0 +1,14 @@ +import { providerIdentifiers } from "@roo-code/types" + +import { providerLabel } from "../providerLabel" + +describe("providerLabel", () => { + it("returns the display name for a known provider", () => { + expect(providerLabel(providerIdentifiers.githubCopilot)).toBe("GitHub Copilot") + expect(providerLabel(providerIdentifiers.anthropic)).toBe("Anthropic") + }) + + it("falls back to the raw id for unknown or retired providers", () => { + expect(providerLabel("some-retired-provider")).toBe("some-retired-provider") + }) +}) diff --git a/webview-ui/src/utils/__tests__/usage.dst.spec.ts b/webview-ui/src/utils/__tests__/usage.dst.spec.ts new file mode 100644 index 0000000000..4b2a58c027 --- /dev/null +++ b/webview-ui/src/utils/__tests__/usage.dst.spec.ts @@ -0,0 +1,109 @@ +import { afterAll, describe, expect, it } from "vitest" + +import type { HistoryItem } from "@roo-code/types" + +import { rangeStart, summarizeUsage, usageToCsv } from "../usage" + +// These cases only mean something in a zone that observes DST: in UTC a fixed 24h step and a calendar +// day are the same thing, so the old implementation would pass. The zone is pinned here, and every test +// asserts that its fixture really crosses an offset change, so a runner that ignores TZ fails loudly +// instead of passing vacuously. +// +// The zone must be set before any fixture below is computed (they are built at collection time), so it +// is set at module level rather than in beforeAll. +const previousZone = process.env.TZ +process.env.TZ = "America/New_York" + +afterAll(() => { + if (previousZone === undefined) delete process.env.TZ + else process.env.TZ = previousZone +}) + +const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m, d, h, min).getTime() +const offsetOf = (ts: number) => new Date(ts).getTimezoneOffset() + +let counter = 0 +const item = (overrides: Partial): HistoryItem => ({ + id: `dst${++counter}`, + number: counter, + ts: 0, + task: `task ${counter}`, + tokensIn: 100, + tokensOut: 0, + totalCost: 1, + workspace: "/ws/a", + ...overrides, +}) + +// US clocks go forward on 2026-03-08 (a 23h day) and back on 2026-11-01 (a 25h day). +const SPRING = { now: at(2026, 2, 11, 15), firstDay: at(2026, 2, 5), lastDay: at(2026, 2, 11) } +const FALL = { now: at(2026, 10, 4, 15), firstDay: at(2026, 9, 29), lastDay: at(2026, 10, 4) } + +describe.each([ + ["spring forward (23h day)", SPRING], + ["fall back (25h day)", FALL], +])("a 7-day window across %s", (_name, w) => { + it("really crosses a clock change in this runner", () => { + expect(offsetOf(w.firstDay)).not.toBe(offsetOf(w.lastDay)) + }) + + it("starts at local midnight", () => { + const start = rangeStart("7d", w.now)! + expect(start).toBe(w.firstDay) + expect(new Date(start).getHours()).toBe(0) + expect(new Date(start).getMinutes()).toBe(0) + }) + + it("charts one bar per calendar day, ending on today", () => { + const { daily } = summarizeUsage([item({ ts: w.now - 60_000 })], { range: "7d", now: w.now }) + + expect(daily).toHaveLength(7) + // Every bar is a local midnight, on consecutive calendar dates, with no day skipped or repeated. + const dates = daily.map((d) => new Date(d.day)) + dates.forEach((d) => expect([d.getHours(), d.getMinutes()]).toEqual([0, 0])) + dates.slice(1).forEach((d, i) => { + const prev = new Date(dates[i]) + prev.setDate(prev.getDate() + 1) + expect(d.getTime()).toBe(prev.getTime()) + }) + expect(daily[0].day).toBe(w.firstDay) + expect(daily.at(-1)!.day).toBe(w.lastDay) + }) + + it("puts today's spend in today's bar, so the bars add up to the total", () => { + const history = [ + item({ ts: w.firstDay + 30 * 60_000, totalCost: 2 }), + item({ ts: w.lastDay + 30 * 60_000, totalCost: 3 }), + ] + const s = summarizeUsage(history, { range: "7d", now: w.now }) + expect(s.totalCost).toBe(5) + expect(s.daily.at(-1)).toMatchObject({ day: w.lastDay, cost: 3 }) + expect(s.daily[0]).toMatchObject({ day: w.firstDay, cost: 2 }) + expect(s.daily.reduce((sum, d) => sum + d.cost, 0)).toBe(s.totalCost) + }) + + it("includes a task from the first minute of the first day, and excludes the minute before it", () => { + const csv = usageToCsv( + [ + item({ task: "first-minute", ts: w.firstDay + 60_000 }), + item({ task: "just-before", ts: w.firstDay - 60_000 }), + ], + { range: "7d", now: w.now }, + ) + expect(csv).toContain("first-minute") + expect(csv).not.toContain("just-before") + }) +}) + +describe("a 30-day window across a clock change", () => { + it("charts 30 consecutive local days ending on today", () => { + const now = at(2026, 10, 3, 15) + expect(offsetOf(rangeStart("30d", now)!)).not.toBe(offsetOf(now)) + + const { daily } = summarizeUsage([item({ ts: now - 60_000 })], { range: "30d", now }) + expect(daily).toHaveLength(30) + expect(daily.at(-1)!.day).toBe(at(2026, 10, 3)) + daily.forEach((d) => expect(new Date(d.day).getHours()).toBe(0)) + expect(new Set(daily.map((d) => d.day)).size).toBe(30) + }) +}) diff --git a/webview-ui/src/utils/__tests__/usage.spec.ts b/webview-ui/src/utils/__tests__/usage.spec.ts new file mode 100644 index 0000000000..973068dc34 --- /dev/null +++ b/webview-ui/src/utils/__tests__/usage.spec.ts @@ -0,0 +1,662 @@ +import { providerIdentifiers, type HistoryItem } from "@roo-code/types" + +import { + UNRECORDED, + bucketView, + metricTotal, + modelIdFromKey, + pickMetric, + primaryUsage, + rangeStart, + summarizeUsage, + usageToCsv, +} from "../usage" + +// Fixed "now": Wed 2026-10-14 15:00 local time. +const NOW = new Date(2026, 9, 14, 15, 0, 0).getTime() +const DAY = 24 * 60 * 60 * 1000 + +let counter = 0 +const item = (overrides: Partial): HistoryItem => ({ + id: `t${++counter}`, + number: counter, + ts: NOW, + task: `task ${counter}`, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + workspace: "/ws/a", + ...overrides, +}) + +describe("rangeStart", () => { + it("returns local midnight for today", () => { + expect(rangeStart("today", NOW)).toBe(new Date(2026, 9, 14).getTime()) + }) + + it("includes today in 7d / 30d windows", () => { + expect(rangeStart("7d", NOW)).toBe(new Date(2026, 9, 8).getTime()) + expect(rangeStart("30d", NOW)).toBe(new Date(2026, 8, 15).getTime()) + }) + + it("is unbounded for all", () => { + expect(rangeStart("all", NOW)).toBeUndefined() + }) +}) + +describe("summarizeUsage", () => { + const used = (apiProvider: string, modelId: string, tokensIn: number, tokensOut: number, cost: number) => [ + { apiProvider, modelId, requests: 1, tokensIn, tokensOut, cost }, + ] + const history = [ + item({ + totalCost: 1.5, + tokensIn: 1000, + tokensOut: 200, + cacheReads: 3000, + mode: "code", + usageByModel: used(providerIdentifiers.anthropic, "opus", 1000, 200, 1.5), + }), + item({ + totalCost: 0.5, + tokensIn: 500, + tokensOut: 100, + mode: "architect", + ts: NOW - DAY, + usageByModel: used(providerIdentifiers.githubCopilot, "gpt-5", 500, 100, 0.5), + }), + item({ + totalCost: 2, + tokensIn: 10, + tokensOut: 10, + mode: "code", + workspace: "/ws/b", + usageByModel: used(providerIdentifiers.anthropic, "opus", 10, 10, 2), + }), + // Outside 7d window + item({ + totalCost: 99, + mode: "code", + ts: NOW - 10 * DAY, + usageByModel: used(providerIdentifiers.anthropic, "opus", 0, 0, 99), + }), + ] + + it("totals only items inside the range", () => { + const s = summarizeUsage(history, { range: "7d", now: NOW }) + expect(s.taskCount).toBe(3) + expect(s.totalCost).toBeCloseTo(4) + expect(s.totalTokens).toBe(1000 + 200 + 500 + 100 + 20) + }) + + it("filters by workspace", () => { + const s = summarizeUsage(history, { range: "7d", now: NOW, workspace: "/ws/a" }) + expect(s.taskCount).toBe(2) + expect(s.totalCost).toBeCloseTo(2) + }) + + it("groups by recorded provider and by model, sorted by cost desc", () => { + const s = summarizeUsage(history, { range: "7d", now: NOW }) + expect(s.byProvider.map((b) => [b.key, b.tasks])).toEqual([ + [providerIdentifiers.anthropic, 2], + [providerIdentifiers.githubCopilot, 1], + ]) + expect(s.byProvider[0].cost).toBeCloseTo(3.5) + expect(s.byModel.map((b) => [modelIdFromKey(b.key), b.apiProvider])).toEqual([ + ["opus", providerIdentifiers.anthropic], + ["gpt-5", providerIdentifiers.githubCopilot], + ]) + expect(s.byMode.map((b) => b.key)).toEqual(["code", "architect"]) + }) + + it("REGRESSION: provider/model come from what was recorded, not from any current profile", () => { + // The bug: usage was attributed via the *currently selected* profile, so + // switching providers relabelled past Copilot work as the new provider. + // Aggregation now takes no profile information at all. + const copilotTask = item({ + tokensIn: 16_700_000, + tokensOut: 83_400, + usageByModel: used(providerIdentifiers.githubCopilot, "claude-opus-5.5", 16_700_000, 83_400, 0), + apiConfigName: "default", // the profile name is irrelevant to attribution + }) + const before = summarizeUsage([copilotTask], { range: "all", now: NOW }) + + // "Switch provider": same history, but the user's profile now points at Gemini. + const after = summarizeUsage([{ ...copilotTask, apiConfigName: "gemini-profile" }], { range: "all", now: NOW }) + + for (const s of [before, after]) { + expect(s.byProvider.map((b) => b.key)).toEqual([providerIdentifiers.githubCopilot]) + expect(modelIdFromKey(s.byModel[0].key)).toBe("claude-opus-5.5") + } + }) + + it("attributes a task that switched providers mid-way to each provider by its own tokens", () => { + const s = summarizeUsage( + [ + item({ + tokensIn: 400, + tokensOut: 0, + usageByModel: [ + { + apiProvider: providerIdentifiers.githubCopilot, + modelId: "claude-opus-5.5", + requests: 1, + tokensIn: 100, + tokensOut: 0, + cost: 0, + }, + { + apiProvider: providerIdentifiers.gemini, + modelId: "gemini-pro", + requests: 1, + tokensIn: 300, + tokensOut: 0, + cost: 0, + }, + ], + }), + ], + { range: "all", now: NOW }, + ) + expect(s.byProvider.map((b) => [b.key, b.tokens])).toEqual([ + [providerIdentifiers.gemini, 300], + [providerIdentifiers.githubCopilot, 100], + ]) + expect(s.byProvider.every((b) => b.tasks === 1)).toBe(true) + }) + + it("buckets tasks with no recorded provider/model as unrecorded instead of guessing", () => { + const s = summarizeUsage([item({ totalCost: 1, tokensIn: 10, tokensOut: 5 })], { range: "all", now: NOW }) + expect(s.byProvider.map((b) => b.key)).toEqual([UNRECORDED]) + expect(s.byModel.map((b) => b.key)).toEqual([UNRECORDED]) + expect(modelIdFromKey(UNRECORDED)).toBeUndefined() + expect(s.byMode[0].key).toBe("unknown") + }) + + it("computes the cache hit rate against tokensIn, which already counts the cached tokens", () => { + const s = summarizeUsage( + [item({ tokensIn: 4000, cacheReads: 3000 }), item({ tokensIn: 1000, cacheReads: 0 })], + { range: "all", now: NOW }, + ) + // 3000 of 5000 input tokens came from the cache: 60%, not 3000 / (5000 + 3000). + expect(s.cacheHitRate).toBeCloseTo(0.6) + }) + + it("never reports more than 100%, and 0 with no input", () => { + expect( + summarizeUsage([item({ tokensIn: 100, cacheReads: 250 })], { range: "all", now: NOW }).cacheHitRate, + ).toBe(1) + expect(summarizeUsage([item({ tokensIn: 0, cacheReads: 0 })], { range: "all", now: NOW }).cacheHitRate).toBe(0) + }) + + it("produces a dense daily series for bounded ranges", () => { + const s = summarizeUsage(history, { range: "7d", now: NOW }) + expect(s.daily).toHaveLength(7) + expect(s.daily.at(-1)!.cost).toBeCloseTo(3.5) + expect(s.daily.at(-2)!.cost).toBeCloseTo(0.5) + expect(s.daily[0].cost).toBe(0) + }) + + it("returns top tasks by cost, limited", () => { + const s = summarizeUsage(history, { range: "all", now: NOW, topTaskLimit: 2 }) + expect(s.topTasks.map((t) => t.totalCost)).toEqual([99, 2]) + }) + + it("breaks cost ties by tokens so equal-priced tasks still rank meaningfully", () => { + const s = summarizeUsage( + [item({ id: "few", totalCost: 1, tokensIn: 10 }), item({ id: "many", totalCost: 1, tokensIn: 9_000 })], + { range: "all", now: NOW }, + ) + expect(s.topTasks.map((t) => t.id)).toEqual(["many", "few"]) + }) + + it("is safe on empty history", () => { + const s = summarizeUsage([], { range: "7d", now: NOW }) + expect(s.taskCount).toBe(0) + expect(s.totalCost).toBe(0) + expect(s.cacheHitRate).toBe(0) + }) + + it("tracks tokens per day alongside cost", () => { + const s = summarizeUsage(history, { range: "7d", now: NOW }) + expect(s.daily.at(-1)!.tokens).toBe(1000 + 200 + 20) + }) + + it("ranks zero-cost tasks by tokens (subscription providers)", () => { + const s = summarizeUsage([item({ id: "small", tokensIn: 10 }), item({ id: "big", tokensIn: 5_000_000 })], { + range: "all", + now: NOW, + }) + expect(s.topTasks.map((t) => t.id)).toEqual(["big", "small"]) + }) + + it("ignores non-finite costs", () => { + const s = summarizeUsage([item({ totalCost: NaN })], { range: "all", now: NOW }) + expect(s.totalCost).toBe(0) + }) +}) + +describe("mixed priced and unpriced providers", () => { + const mixed = () => { + const mk = (provider: string, model: string, tokens: number, cost: number) => + item({ + tokensIn: tokens, + tokensOut: 0, + totalCost: cost, + usageByModel: [ + { apiProvider: provider, modelId: model, requests: 1, tokensIn: tokens, tokensOut: 0, cost }, + ], + }) + return summarizeUsage( + [ + mk(providerIdentifiers.githubCopilot, "claude-opus-5.5", 16_000_000, 0), + mk(providerIdentifiers.anthropic, "claude-sonnet-5", 200_000, 1.2), + ], + { range: "all", now: NOW }, + ) + } + + it("prioritises cost as soon as any usage in the range is priced", () => { + expect(pickMetric(mixed())).toBe("cost") + }) + + it("flags each bucket as priced or not so subscription usage isn't shown as $0.00", () => { + const s = mixed() + const byKey = Object.fromEntries(s.byProvider.map((b) => [b.key, b])) + expect(byKey[providerIdentifiers.anthropic].priced).toBe(true) + expect(byKey[providerIdentifiers.githubCopilot].priced).toBe(false) + expect(s.byModel.find((b) => modelIdFromKey(b.key) === "claude-opus-5.5")!.priced).toBe(false) + }) + + it("keeps all-unpriced ranges on tokens and all-priced ranges on cost", () => { + const unpriced = summarizeUsage([item({ tokensIn: 5, usageByModel: [] })], { range: "all", now: NOW }) + expect(pickMetric(unpriced)).toBe("tokens") + }) +}) + +describe("bucketView", () => { + const priced = { cost: 3, tokens: 1000, priced: true } + const unpriced = { cost: 0, tokens: 16_000_000, priced: false } + + it("cost mode: a priced bucket is its cost and its share of total cost", () => { + expect(bucketView(priced, "cost", 12)).toEqual({ value: 3, unpriced: false, percent: 25 }) + }) + + it("cost mode: an unpriced bucket carries tokens, is flagged, and has no share (never 0%)", () => { + expect(bucketView(unpriced, "cost", 12)).toEqual({ value: 0, unpriced: true, percent: 0 }) + }) + + it("tokens mode: everything is tokens and nothing is flagged unpriced", () => { + expect(bucketView(unpriced, "tokens", 32_000_000)).toEqual({ value: 16_000_000, unpriced: false, percent: 50 }) + expect(bucketView(priced, "tokens", 2000)).toEqual({ value: 1000, unpriced: false, percent: 50 }) + }) + + it("guards division by zero", () => { + expect(bucketView(priced, "cost", 0).percent).toBe(0) + }) + + it("rounds shares to whole numbers", () => { + expect(bucketView({ cost: 1, tokens: 0, priced: true }, "cost", 3).percent).toBe(33) + }) +}) + +describe("metricTotal", () => { + it("selects the headline value for the metric", () => { + const s = { totalCost: 2.5, totalTokens: 900 } + expect(metricTotal(s, "cost")).toBe(2.5) + expect(metricTotal(s, "tokens")).toBe(900) + }) +}) + +describe("primaryUsage", () => { + it("returns the provider+model that used the most tokens", () => { + const task = item({ + usageByModel: [ + { apiProvider: "a", modelId: "small", requests: 1, tokensIn: 10, tokensOut: 0, cost: 0 }, + { apiProvider: "b", modelId: "big", requests: 1, tokensIn: 900, tokensOut: 0, cost: 0 }, + ], + }) + expect(primaryUsage(task)).toEqual({ apiProvider: "b", modelId: "big" }) + }) + + it("is undefined when nothing was recorded", () => { + expect(primaryUsage(item({}))).toBeUndefined() + expect( + primaryUsage(item({ usageByModel: [{ requests: 1, tokensIn: 1, tokensOut: 1, cost: 0 }] })), + ).toBeUndefined() + }) +}) + +describe("pickMetric", () => { + it("uses cost when any cost was recorded", () => { + expect(pickMetric({ totalCost: 0.01, totalTokens: 10 })).toBe("cost") + }) + + it("falls back to tokens when tokens exist but cost is zero (e.g. GitHub Copilot)", () => { + expect(pickMetric({ totalCost: 0, totalTokens: 6_200_000 })).toBe("tokens") + }) + + it("stays on cost when there is no usage at all", () => { + expect(pickMetric({ totalCost: 0, totalTokens: 0 })).toBe("cost") + }) +}) + +describe("spend that has no provider or model (context condensing)", () => { + const entry = (cost: number) => ({ + apiProvider: providerIdentifiers.anthropic, + modelId: "claude-sonnet-5", + requests: 1, + tokensIn: 100, + tokensOut: 10, + cost, + }) + + it("puts the unattributed remainder in the 'not recorded' rows so the rows add up to the total", () => { + // The task cost 5.00, but its recorded requests only account for 4.00: 1.00 was condensing. + const s = summarizeUsage([item({ totalCost: 5, usageByModel: [entry(4)] })], { range: "all", now: NOW }) + + expect(s.totalCost).toBe(5) + expect(s.byProvider.reduce((sum, b) => sum + b.cost, 0)).toBeCloseTo(5) + expect(s.byModel.reduce((sum, b) => sum + b.cost, 0)).toBeCloseTo(5) + expect(s.byProvider.find((b) => b.key === UNRECORDED)?.cost).toBeCloseTo(1) + expect(s.byModel.find((b) => b.key === UNRECORDED)?.cost).toBeCloseTo(1) + }) + + it("does not add a token count or a task to the 'not recorded' rows for that remainder", () => { + const s = summarizeUsage([item({ totalCost: 5, usageByModel: [entry(4)] })], { range: "all", now: NOW }) + const unrecorded = s.byProvider.find((b) => b.key === UNRECORDED)! + expect(unrecorded.tokens).toBe(0) + // The one task is counted under its real provider; the remainder is a cost line, not another task. + expect(s.byProvider.find((b) => b.key === providerIdentifiers.anthropic)?.tasks).toBe(1) + }) + + it("ignores float noise: no spurious 'not recorded' row when the costs agree", () => { + const s = summarizeUsage([item({ totalCost: 0.1 + 0.2, usageByModel: [entry(0.1), entry(0.2)] })], { + range: "all", + now: NOW, + }) + expect(s.byProvider.find((b) => b.key === UNRECORDED)).toBeUndefined() + expect(s.byModel.find((b) => b.key === UNRECORDED)).toBeUndefined() + }) + + it("adds nothing when the recorded requests cost more than the task total", () => { + const s = summarizeUsage([item({ totalCost: 3, usageByModel: [entry(4)] })], { range: "all", now: NOW }) + expect(s.byProvider.find((b) => b.key === UNRECORDED)).toBeUndefined() + }) + + it("leaves tasks with no usage record exactly as before", () => { + const s = summarizeUsage([item({ totalCost: 2, tokensIn: 50 })], { range: "all", now: NOW }) + expect(s.byProvider).toHaveLength(1) + expect(s.byProvider[0]).toMatchObject({ key: UNRECORDED, cost: 2 }) + }) +}) + +describe("modelIdFromKey", () => { + it("returns undefined for the unrecorded bucket, the id after the provider separator, or a bare key", () => { + expect(modelIdFromKey(UNRECORDED)).toBeUndefined() + expect(modelIdFromKey("anthropic\u0000claude-sonnet-5")).toBe("claude-sonnet-5") + expect(modelIdFromKey("plain-model")).toBe("plain-model") + }) +}) + +describe("usage attribution edge cases", () => { + const entry = (overrides: Record) => ({ + requests: 1, + tokensIn: 10, + tokensOut: 0, + cost: 0, + ...overrides, + }) + + it("keeps the first entry when two entries used the same number of tokens", () => { + const task = item({ + usageByModel: [ + entry({ apiProvider: "a", modelId: "first", tokensIn: 50 }), + entry({ apiProvider: "b", modelId: "second", tokensIn: 50 }), + ], + }) + expect(primaryUsage(task)).toEqual({ apiProvider: "a", modelId: "first" }) + }) + + it("buckets a model with no provider under an empty provider key, and a provider with no model as unrecorded", () => { + const s = summarizeUsage( + [ + item({ + totalCost: 1, + usageByModel: [entry({ modelId: "orphan", cost: 1 }), entry({ apiProvider: "p", cost: 0 })], + }), + ], + { range: "all", now: NOW }, + ) + expect(new Set(s.byModel.map((b) => modelIdFromKey(b.key)))).toEqual(new Set([undefined, "orphan"])) + expect(new Set(s.byProvider.map((b) => b.key))).toEqual(new Set([UNRECORDED, "p"])) + }) + + it("counts a task once per bucket even when several of its requests land in it", () => { + const s = summarizeUsage( + [ + item({ + usageByModel: [ + entry({ apiProvider: "p", modelId: "m", tokensIn: 1 }), + entry({ apiProvider: "p", modelId: "m", tokensIn: 2 }), + ], + }), + ], + { range: "all", now: NOW }, + ) + expect(s.byProvider[0].tasks).toBe(1) + expect(s.byModel[0].tasks).toBe(1) + expect(s.byProvider[0].tokens).toBe(3) + }) + + it("orders top tasks by cost, then by tokens, and treats missing numbers as zero", () => { + const cheapBig = item({ id: "cheap-big", totalCost: 1, tokensIn: 900, tokensOut: 100 }) + const cheapSmall = item({ id: "cheap-small", totalCost: 1, tokensIn: 10, tokensOut: 0 }) + const pricey = item({ id: "pricey", totalCost: 5, tokensIn: 1, tokensOut: 0 }) + const bare = (id: string) => + item({ id, totalCost: undefined as never, tokensIn: undefined as never, tokensOut: undefined as never }) + const s = summarizeUsage([cheapSmall, bare("bare-1"), cheapBig, pricey, bare("bare-2")], { + range: "all", + now: NOW, + }) + expect(s.topTasks.map((t) => t.id).slice(0, 3)).toEqual(["pricey", "cheap-big", "cheap-small"]) + expect( + s.topTasks + .map((t) => t.id) + .slice(3) + .sort(), + ).toEqual(["bare-1", "bare-2"]) + }) +}) + +describe("usageToCsv formula safety", () => { + it.each(['=HYPERLINK("http://x")', "+1+1", "-2+3", "@SUM(A1)", "\tcmd", "\rcmd"])( + "neutralises a cell that starts with %j", + (text) => { + const csv = usageToCsv([item({ task: text, mode: text })], { range: "all", now: NOW }) + const row = csv.split("\n")[1] + // Each text cell is quoted and prefixed with an apostrophe, so it cannot start with a trigger character. + expect(row).toMatch(/^[^,]+,"'[=+\-@\t\r]/) + expect(row).not.toMatch(/,"[=+\-@\t\r]/) + }, + ) + + it("leaves ordinary text and the numeric columns alone", () => { + const csv = usageToCsv([item({ task: "Fix the = sign", totalCost: 1.5, tokensIn: 10 })], { + range: "all", + now: NOW, + }) + const row = csv.split("\n")[1] + expect(row).toContain('"Fix the = sign"') + expect(row).not.toContain("'Fix") + expect(row.endsWith(",1.5000")).toBe(true) + }) +}) + +describe("usageToCsv and summarizeUsage count the same tasks", () => { + it("leaves out a task whose time is not a number, in the export as in the totals", () => { + const history = [ + item({ task: "good", ts: NOW, tokensIn: 7, totalCost: 1 }), + item({ task: "no time", ts: null as never, tokensIn: 99, totalCost: 50 }), + item({ task: "text time", ts: "yesterday" as never, tokensIn: 99, totalCost: 50 }), + ] + + const rows = usageToCsv(history, { range: "all", now: NOW }).split("\n").slice(1) + const summary = summarizeUsage(history, { range: "all", now: NOW }) + + expect(rows).toHaveLength(1) + expect(rows[0]).toContain('"good"') + expect(summary.taskCount).toBe(1) + expect(summary.totalCost).toBe(1) + }) +}) + +describe("usageToCsv numeric columns", () => { + const numericKeys = ["tokensIn", "tokensOut", "cacheReads", "cacheWrites", "totalCost"] as const + const cells = (overrides: Record) => + usageToCsv([item(overrides as never)], { range: "all", now: NOW }) + .split("\n")[1] + .split(",") + .slice(-5) + + it.each(numericKeys)("exports a formula in %s as 0, so no cell can be evaluated", (key) => { + for (const text of ['=HYPERLINK("http://x")', "+1+1", "-2+3", "@SUM(A1)", "\tcmd", "1;2"]) { + const row = cells({ [key]: text }) + const index = numericKeys.indexOf(key) + expect(row[index]).toBe(key === "totalCost" ? "0.0000" : "0") + } + }) + + it.each([[Number.NaN], [Number.POSITIVE_INFINITY], [Number.NEGATIVE_INFINITY], [-5], [null], [{}]])( + "exports %p as 0", + (value) => { + expect( + cells({ tokensIn: value, tokensOut: value, cacheReads: value, cacheWrites: value, totalCost: value }), + ).toEqual(["0", "0", "0", "0", "0.0000"]) + }, + ) + + it("keeps real counters as they are", () => { + expect(cells({ tokensIn: 10, tokensOut: 20, cacheReads: 3, cacheWrites: 4, totalCost: 1.5 })).toEqual([ + "10", + "20", + "3", + "4", + "1.5000", + ]) + }) + + it("leaves the date empty for a time that cannot be written, instead of throwing", () => { + for (const ts of [Number.NaN, Number.POSITIVE_INFINITY, 8.64e15 + 1]) { + const csv = usageToCsv([item({ task: "t", ts })], { range: "all", now: NOW }) + expect(csv.split("\n")[1].startsWith(',"t"')).toBe(true) + } + }) + + it("every cell of a row is digits, a quoted text with no leading trigger, or an ISO date", () => { + const hostile = '=cmd|" /C calc"!A0' + const row = usageToCsv( + [item({ task: hostile, mode: hostile, tokensIn: hostile as never, totalCost: hostile as never })], + { range: "all", now: NOW }, + ).split("\n")[1] + + expect(row).not.toMatch(/(^|,)[=+\-@\t\r]/) + expect(row).not.toMatch(/,"[=+\-@\t\r]/) + }) +}) + +describe("usageToCsv", () => { + it("sorts oldest first and leaves provider, model and counters blank or zero when nothing was recorded", () => { + const csv = usageToCsv( + [ + item({ task: "later", ts: NOW }), + item({ + task: "earlier", + title: undefined, + ts: NOW - DAY, + tokensIn: undefined as never, + tokensOut: undefined as never, + totalCost: undefined as never, + mode: undefined, + }), + ], + { range: "all", now: NOW }, + ) + const lines = csv.split("\n") + expect(lines[1]).toContain("earlier") + expect(lines[1].endsWith(',"","","",0,0,0,0,0.0000')).toBe(true) + expect(lines[2]).toContain("later") + }) + + it("exports a row for a task with neither a title nor a task text", () => { + const csv = usageToCsv([item({ task: undefined as never, title: undefined })], { range: "all", now: NOW }) + expect(csv.split("\n")[1]).toContain(',"",') + }) + + it("filters to a workspace and ignores one that is not set", () => { + const history = [item({ task: "in", workspace: "/ws/a" }), item({ task: "out", workspace: "/ws/b" })] + expect(usageToCsv(history, { range: "all", now: NOW, workspace: "/ws/a" }).split("\n")).toHaveLength(2) + expect(usageToCsv(history, { range: "all", now: NOW }).split("\n")).toHaveLength(3) + }) + + it("escapes quotes and newlines and respects the range", () => { + const csv = usageToCsv( + [ + item({ task: 'say "hi"\nthen go', totalCost: 0.12345, mode: "code" }), + item({ task: "old", ts: NOW - 40 * DAY }), + ], + { range: "30d", now: NOW }, + ) + const lines = csv.split("\n") + expect(lines[0]).toBe("date,task,mode,provider,model,tokensIn,tokensOut,cacheReads,cacheWrites,cost") + expect(lines).toHaveLength(2) + expect(lines[1]).toContain('"say ""hi"" then go"') + expect(lines[1].endsWith(",0.1235")).toBe(true) + }) +}) + +describe("defaults and non-finite input", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("uses the current time when no `now` is given", () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const recent = item({ ts: NOW - 1000, totalCost: 1 }) + const old = item({ ts: NOW - 3 * DAY, totalCost: 2 }) + + expect(summarizeUsage([recent, old], { range: "today" }).totalCost).toBe(1) + expect(usageToCsv([recent, old], { range: "today" }).split("\n")).toHaveLength(2) + }) + + it("ignores a non-finite per-model cost when reconciling the task cost", () => { + const summary = summarizeUsage( + [ + item({ + totalCost: 3, + usageByModel: [ + { + apiProvider: providerIdentifiers.anthropic, + modelId: "m", + requests: 1, + tokensIn: 1, + tokensOut: 1, + cost: Number.NaN, + }, + ], + }), + ], + { range: "all", now: NOW }, + ) + + // The whole task cost is unattributed because the recorded model cost is unusable. + expect(summary.byProvider.find((b) => b.key === UNRECORDED)?.cost).toBe(3) + // The unusable cost never reaches the recorded buckets as NaN. + const recordedProvider = summary.byProvider.find((b) => b.key === providerIdentifiers.anthropic) + expect(recordedProvider).toMatchObject({ cost: 0, tokens: 2, priced: false }) + expect(summary.byModel.find((b) => b.key !== UNRECORDED)).toMatchObject({ cost: 0, tokens: 2, priced: false }) + expect(summary.byProvider.reduce((sum, b) => sum + b.cost, 0)).toBe(3) + expect(summary.byModel.reduce((sum, b) => sum + b.cost, 0)).toBe(3) + }) +}) diff --git a/webview-ui/src/utils/providerLabel.ts b/webview-ui/src/utils/providerLabel.ts new file mode 100644 index 0000000000..740e0aef02 --- /dev/null +++ b/webview-ui/src/utils/providerLabel.ts @@ -0,0 +1,6 @@ +import { PROVIDERS } from "@src/components/settings/constants" + +const LABELS = new Map(PROVIDERS.map((p) => [p.value, p.label])) + +/** Human label for a provider id, falling back to the id itself for unknown/retired providers. */ +export const providerLabel = (id: string): string => LABELS.get(id) ?? id diff --git a/webview-ui/src/utils/usage.ts b/webview-ui/src/utils/usage.ts new file mode 100644 index 0000000000..2ee55755b0 --- /dev/null +++ b/webview-ui/src/utils/usage.ts @@ -0,0 +1,365 @@ +import type { HistoryItem } from "@roo-code/types" + +/** + * Pure aggregation over locally persisted task history for the Usage tab. + * + * Notes on correctness: + * - `HistoryItem.totalCost` is a task's *own* spend; subtasks are separate + * history items. Summing every item therefore counts each dollar once. + * - Provider and model come from `HistoryItem.usageByModel`, recorded when each + * request was made. They are never derived from the *current* profile, so + * switching providers does not relabel past usage. Tasks created before this + * was recorded have no such data and are bucketed as `UNRECORDED`. + */ + +/** Bucket key for usage whose provider/model was not recorded (older tasks). */ +export const UNRECORDED = "__unrecorded__" + +/** Cost below this (in dollars) is float noise, not unattributed spend. */ +const REMAINDER_EPSILON = 1e-6 + +export const USAGE_RANGES = ["today", "7d", "30d", "all"] as const +export type UsageRange = (typeof USAGE_RANGES)[number] + +export interface UsageBucket { + key: string + cost: number + tokens: number + /** Tasks that contributed to this bucket (a task using two models counts once in each). */ + tasks: number + /** For model buckets: the provider that served it (undefined when unrecorded). */ + apiProvider?: string + /** + * True when this bucket has any cost > 0. When the tab is showing cost, a bucket + * with `priced: false` (e.g. GitHub Copilot, billed by subscription) must not be + * shown as "$0.00 · 0%": it has no price, not zero spend. + */ + priced: boolean +} + +export interface DailyUsage { + /** Local-midnight timestamp of the day. */ + day: number + cost: number + tokens: number +} + +/** + * Which metric the Usage tab should chart. Subscription-backed providers + * (e.g. GitHub Copilot via the VS Code LM API) report no per-token price, so + * their tasks persist `totalCost: 0`. Charting cost would show flat zeros. + */ +export type UsageMetric = "cost" | "tokens" + +export function pickMetric(summary: Pick): UsageMetric { + return summary.totalCost > 0 || summary.totalTokens === 0 ? "cost" : "tokens" +} + +/** + * What a breakdown row (provider / model / mode) should display for a bucket. + * + * Pure so the "priced vs unpriced" rule lives in one tested place instead of + * being re-derived by the component: + * - tokens mode: value = tokens + * - cost mode, priced bucket: value = cost, share of total cost + * - cost mode, unpriced: a subscription bucket (e.g. Copilot) has no price, + * not zero spend → value = tokens, `unpriced: true`, + * and no share (so no "$0.00 · 0%") + */ +export interface BucketView { + /** Numeric value the bar/share is computed from. */ + value: number + /** True when the bucket should be shown as tokens with a "no price" tag and no share. */ + unpriced: boolean + /** Whole-number share of the total, 0 for unpriced rows. */ + percent: number +} + +export function bucketView( + bucket: Pick, + metric: UsageMetric, + total: number, +): BucketView { + const unpriced = metric === "cost" && !bucket.priced + const value = metric === "cost" ? bucket.cost : bucket.tokens + return { value, unpriced, percent: unpriced || total <= 0 ? 0 : Math.round((value / total) * 100) } +} + +/** The metric's headline value for a summary (cost or tokens). */ +export function metricTotal(summary: Pick, metric: UsageMetric): number { + return metric === "cost" ? summary.totalCost : summary.totalTokens +} + +export interface UsageSummary { + totalCost: number + tokensIn: number + tokensOut: number + cacheReads: number + cacheWrites: number + totalTokens: number + /** Share of input-side tokens served from cache, 0–1. */ + cacheHitRate: number + taskCount: number + byProvider: UsageBucket[] + byModel: UsageBucket[] + byMode: UsageBucket[] + daily: DailyUsage[] + topTasks: HistoryItem[] +} + +export interface UsageOptions { + range: UsageRange + /** When set, only tasks from this workspace are included. */ + workspace?: string + now?: number + topTaskLimit?: number +} + +/** + * The provider+model that did most of a task's work (by tokens), for labelling + * a task row. Undefined when nothing was recorded. + */ +export function primaryUsage(item: HistoryItem): { apiProvider?: string; modelId?: string } | undefined { + const entries = item.usageByModel?.filter((e) => e.apiProvider || e.modelId) + if (!entries?.length) return undefined + const top = entries.reduce((a, b) => (b.tokensIn + b.tokensOut > a.tokensIn + a.tokensOut ? b : a)) + return { apiProvider: top.apiProvider, modelId: top.modelId } +} + +function startOfLocalDay(ts: number): number { + const d = new Date(ts) + d.setHours(0, 0, 0, 0) + return d.getTime() +} + +/** Local midnight `days` calendar days before `ts` (not `days * 24h`, which is off by an hour across a DST change). */ +function startOfLocalDayMinus(ts: number, days: number): number { + const d = new Date(ts) + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() - days) + return d.getTime() +} + +/** Inclusive lower bound for a range, or `undefined` for "all". */ +export function rangeStart(range: UsageRange, now: number): number | undefined { + switch (range) { + case "today": + return startOfLocalDay(now) + case "7d": + return startOfLocalDayMinus(now, 6) + case "30d": + return startOfLocalDayMinus(now, 29) + default: + return undefined + } +} + +function addTo( + map: Map, + key: string, + cost: number, + tokens: number, + countedForTask: Set, + extra?: Partial, +) { + const bucket = map.get(key) ?? { key, cost: 0, tokens: 0, tasks: 0, priced: false, ...extra } + bucket.cost += cost + bucket.tokens += tokens + if (cost > 0) bucket.priced = true + // Count the task once per bucket even if several of its requests land here. + if (!countedForTask.has(key)) { + countedForTask.add(key) + bucket.tasks += 1 + } + map.set(key, bucket) +} + +const byCostDesc = (a: UsageBucket, b: UsageBucket) => b.cost - a.cost || b.tokens - a.tokens + +/** + * The tasks the Usage tab counts for these options: the same selection for the totals and for the CSV, so an export + * never has a row the displayed totals leave out. An item whose time is not a number (a corrupted task file) is + * dropped. + */ +function itemsInScope(history: HistoryItem[], options: UsageOptions): HistoryItem[] { + const start = rangeStart(options.range, options.now ?? Date.now()) + + return history.filter( + (item) => + typeof item.ts === "number" && + (start === undefined || item.ts >= start) && + (!options.workspace || item.workspace === options.workspace), + ) +} + +export function summarizeUsage(history: HistoryItem[], options: UsageOptions): UsageSummary { + const now = options.now ?? Date.now() + const start = rangeStart(options.range, now) + const topTaskLimit = options.topTaskLimit ?? 5 + + // The same `now` as above, so the range and the daily series agree on the day. + const items = itemsInScope(history, { ...options, now }) + + const providers = new Map() + const models = new Map() + const modes = new Map() + const daily = new Map() + + let totalCost = 0 + let tokensIn = 0 + let tokensOut = 0 + let cacheReads = 0 + let cacheWrites = 0 + + for (const item of items) { + const cost = Number.isFinite(item.totalCost) ? item.totalCost : 0 + const tIn = item.tokensIn || 0 + const tOut = item.tokensOut || 0 + const tokens = tIn + tOut + + totalCost += cost + tokensIn += tIn + tokensOut += tOut + cacheReads += item.cacheReads || 0 + cacheWrites += item.cacheWrites || 0 + + // Provider/model from what was recorded per request. A task with no record + // (created before this was tracked) goes to a single "unrecorded" bucket. + const seenProviders = new Set() + const seenModels = new Set() + type Entry = NonNullable[number] + const entries: Entry[] = item.usageByModel?.length + ? [...item.usageByModel] + : [{ requests: 0, tokensIn: tIn, tokensOut: tOut, cost }] + + // The task's cost also covers work that has no provider or model of its own (condensing the context). + // Without this, the provider and model rows would add up to less than the headline total. The + // remainder goes to the "not recorded" rows. Below a cent-fraction it is rounding noise and is ignored, + // so a float residue cannot create a spurious priced row. + if (item.usageByModel?.length) { + const attributed = item.usageByModel.reduce((sum, e) => sum + (Number.isFinite(e.cost) ? e.cost : 0), 0) + const remainder = cost - attributed + if (remainder > REMAINDER_EPSILON) entries.push({ requests: 0, tokensIn: 0, tokensOut: 0, cost: remainder }) + } + + for (const e of entries) { + const entryTokens = e.tokensIn + e.tokensOut + const entryCost = Number.isFinite(e.cost) ? e.cost : 0 + const providerKey = e.apiProvider || UNRECORDED + const modelKey = e.modelId ? `${e.apiProvider ?? ""}\u0000${e.modelId}` : UNRECORDED + addTo(providers, providerKey, entryCost, entryTokens, seenProviders) + addTo(models, modelKey, entryCost, entryTokens, seenModels, { apiProvider: e.apiProvider }) + } + + const modeSeen = new Set() + addTo(modes, item.mode || "unknown", cost, tokens, modeSeen) + + const day = startOfLocalDay(item.ts) + const entry = daily.get(day) ?? { cost: 0, tokens: 0 } + entry.cost += cost + entry.tokens += tokens + daily.set(day, entry) + } + + // Densify the daily series for bounded ranges so the chart has a bar per day + // (including zero-spend days). "all" stays sparse to avoid huge arrays. + const dailySeries: DailyUsage[] = [] + if (start !== undefined) { + // Step by calendar date, not by 24h: a 23h or 25h day (DST) would otherwise drift the + // cursor off local midnight and drop today's bar while today's tasks are still counted. + const cursor = new Date(start) + const last = startOfLocalDay(now) + for (let day = cursor.getTime(); day <= last; day = cursor.getTime()) { + const entry = daily.get(day) + dailySeries.push({ day, cost: entry?.cost ?? 0, tokens: entry?.tokens ?? 0 }) + cursor.setDate(cursor.getDate() + 1) + cursor.setHours(0, 0, 0, 0) + } + } else { + for (const [day, entry] of [...daily.entries()].sort(([a], [b]) => a - b)) { + dailySeries.push({ day, ...entry }) + } + } + + return { + totalCost, + tokensIn, + tokensOut, + cacheReads, + cacheWrites, + totalTokens: tokensIn + tokensOut, + // `tokensIn` already counts the cached tokens (the cost helpers store the total input), so it is the + // whole input side. Adding the cache reads again would understate the rate. + cacheHitRate: tokensIn > 0 ? Math.min(1, cacheReads / tokensIn) : 0, + taskCount: items.length, + byProvider: [...providers.values()].sort(byCostDesc), + byModel: [...models.values()].sort(byCostDesc), + byMode: [...modes.values()].sort(byCostDesc), + daily: dailySeries, + // Cost first, tokens as tie-breaker so zero-cost (subscription) usage still ranks meaningfully. + topTasks: [...items] + .sort( + (a, b) => + (b.totalCost || 0) - (a.totalCost || 0) || + (b.tokensIn || 0) + (b.tokensOut || 0) - ((a.tokensIn || 0) + (a.tokensOut || 0)), + ) + .slice(0, topTaskLimit), + } +} + +/** The model id from a `byModel` bucket key (or undefined for the unrecorded bucket). */ +export function modelIdFromKey(key: string): string | undefined { + if (key === UNRECORDED) return undefined + const i = key.indexOf("\u0000") + return i === -1 ? key : key.slice(i + 1) +} + +/** Serialises the filtered usage rows as CSV (one row per task). */ +export function usageToCsv(history: HistoryItem[], options: UsageOptions): string { + const header = [ + "date", + "task", + "mode", + "provider", + "model", + "tokensIn", + "tokensOut", + "cacheReads", + "cacheWrites", + "cost", + ] + + // Spreadsheet apps evaluate a quoted cell that starts with = + - @ (or tab/CR) as a formula, and the + // task text is user- and model-controlled. A leading apostrophe makes the cell inert text. + const escape = (value: string) => { + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value + return `"${safe.replace(/"/g, '""').replace(/\r?\n/g, " ")}"` + } + + // A counter that is not a finite, non-negative number (corrupted history, or text such as "=1+1" that a + // spreadsheet would evaluate) is exported as 0. What is written is then only ever digits, so every cell is inert. + const counter = (value: unknown) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0) + const date = (ts: unknown) => + typeof ts === "number" && Number.isFinite(ts) && !Number.isNaN(new Date(ts).getTime()) + ? new Date(ts).toISOString() + : "" + + const rows = itemsInScope(history, options) + .sort((a, b) => a.ts - b.ts) + .map((item) => + [ + date(item.ts), + escape(item.title || item.task || ""), + escape(item.mode || ""), + escape(primaryUsage(item)?.apiProvider ?? ""), + escape(primaryUsage(item)?.modelId ?? ""), + counter(item.tokensIn), + counter(item.tokensOut), + counter(item.cacheReads), + counter(item.cacheWrites), + counter(item.totalCost).toFixed(4), + ].join(","), + ) + + return [header.join(","), ...rows].join("\n") +}