diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..674ba98771 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -280,6 +280,37 @@ export const globalSettingsSchema = z.object({ * When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`. */ chatFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Scale (percent) applied to Zoo Code's interface text. `null`/unset = 100. + */ + interfaceFontScale: z.number().int().min(50).max(200).nullish(), + /** + * Font size (px) for command/terminal output shown in chat. `null`/unset inherits. + */ + terminalFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Font size (px) for code blocks and diffs shown in chat. `null`/unset inherits. + */ + editorFontSize: z.number().int().min(8).max(32).nullish(), + /** + * Scale (percent) applied to chat row padding. `null`/unset = 100. + */ + spacingScale: z.number().int().min(50).max(200).nullish(), + /** + * Clock format used when rendering timestamps. "auto" follows the display language's locale. + * @default "auto" + */ + timeFormat: z.enum(["auto", "12h", "24h"]).optional(), + /** + * How dates are written. "auto" follows the display language's locale. + * @default "auto" + */ + dateFormat: z.enum(["auto", "mdy", "dmy", "ymd"]).optional(), + /** + * Show relative times ("5 min ago") in task history; when off, show the absolute date/time. + * @default true + */ + showRelativeTimes: z.boolean().optional(), /** * Controls the keyboard behavior for sending messages in the chat input. * - "send": Enter sends message, Shift+Enter creates newline (default) 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 b6ba122d53..91e74e54fd 100644 --- a/packages/types/src/providers/vscode-llm.ts +++ b/packages/types/src/providers/vscode-llm.ts @@ -11,8 +11,66 @@ 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 + /** 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..0921418960 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,8 @@ export interface ExtensionMessage { | typeof VsCodeLmModelsMessageType.vsCodeLmModels | typeof VsCodeLmModelsMessageType.githubCopilotModels | typeof VsCodeLmModelsMessageType.githubCopilotSignInResult + | typeof VsCodeLmModelsMessageType.providerQuotas + | "settingsSaveResult" | "vsCodeLmApiAvailable" | "updatePrompt" | "systemPrompt" @@ -158,6 +160,7 @@ export interface ExtensionMessage { modelInfo?: ModelInfo }[] githubCopilotAccount?: string | null + providerQuotas?: ProviderQuota[] mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ProviderSettingsEntry[] @@ -339,6 +342,13 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "chatFontSize" + | "interfaceFontScale" + | "terminalFontSize" + | "editorFontSize" + | "spacingScale" + | "timeFormat" + | "dateFormat" + | "showRelativeTimes" | "enterBehavior" | "includeCurrentTime" | "includeCurrentCost" @@ -516,6 +526,8 @@ export interface WebviewMessage { | typeof VsCodeLmModelsMessageType.githubCopilotSignIn | typeof VsCodeLmModelsMessageType.githubCopilotReconnect | typeof VsCodeLmModelsMessageType.githubCopilotManageAccount + | typeof VsCodeLmModelsMessageType.requestProviderQuotas + | typeof VsCodeLmModelsMessageType.cancelProviderQuotas | "openImage" | "saveImage" | "openFile" @@ -951,6 +963,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__/taskMetadata.usageByModel.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.usageByModel.spec.ts new file mode 100644 index 0000000000..a16e27259d --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.usageByModel.spec.ts @@ -0,0 +1,82 @@ +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("omits usageByModel entirely when nothing was recorded, keeping old items' shape", async () => { + const { historyItem } = await taskMetadata({ ...base, messages: [say("text", "hello", 1)] }) + expect(historyItem).not.toHaveProperty("usageByModel") + }) + + it("omits usageByModel for a task with no messages", async () => { + const { historyItem } = await taskMetadata({ ...base, messages: [] }) + expect(historyItem).not.toHaveProperty("usageByModel") + expect(historyItem.tokensIn).toBe(0) + }) +}) 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..092272ff6d --- /dev/null +++ b/src/core/task-persistence/__tests__/usageByModel.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" + +import { providerIdentifiers, type ClineMessage } from "@roo-code/types" + +import { 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([]) + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index c52b0bdc33..827095f67a 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,8 @@ export async function taskMetadata({ cacheWrites: tokenUsage.totalCacheWrites, cacheReads: tokenUsage.totalCacheReads, totalCost: tokenUsage.totalCost, + // Only emitted when something was recorded, so older tasks and empty tasks keep their exact shape. + ...(usageByModel.length > 0 ? { usageByModel } : {}), size: taskDirSize, workspace, mode, diff --git a/src/core/task-persistence/usageByModel.ts b/src/core/task-persistence/usageByModel.ts new file mode 100644 index 0000000000..16027c281e --- /dev/null +++ b/src/core/task-persistence/usageByModel.ts @@ -0,0 +1,51 @@ +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 + } + + // 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()] +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e573a7f107..d5e8cc7b79 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3936,6 +3936,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, }), ) @@ -4013,6 +4016,8 @@ export class Task extends EventEmitter implements TaskLike { this.clineMessages[lastApiReqIndex].text = JSON.stringify({ apiProtocol, + apiProvider, + modelId, } satisfies ClineApiReqInfo) await this.saveClineMessages() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 28a42913f3..9c6d253285 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2810,6 +2810,13 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, + timeFormat, + dateFormat, + showRelativeTimes, enterBehavior, cloudUserInfo, cloudIsAuthenticated, @@ -2991,6 +2998,13 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, + interfaceFontScale, + terminalFontSize, + editorFontSize, + spacingScale, + timeFormat: timeFormat ?? "auto", + dateFormat: dateFormat ?? "auto", + showRelativeTimes: showRelativeTimes ?? true, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, @@ -3224,6 +3238,13 @@ export class ClineProvider historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, chatFontSize: stateValues.chatFontSize, + interfaceFontScale: stateValues.interfaceFontScale, + terminalFontSize: stateValues.terminalFontSize, + editorFontSize: stateValues.editorFontSize, + spacingScale: stateValues.spacingScale, + timeFormat: stateValues.timeFormat ?? "auto", + dateFormat: stateValues.dateFormat ?? "auto", + showRelativeTimes: stateValues.showRelativeTimes ?? true, enterBehavior: stateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, 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..d83aa92aac --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.providerQuotas.spec.ts @@ -0,0 +1,215 @@ +// 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 { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +const SECRET = "sk-super-secret" + +const makeProvider = (overrides: Record = {}) => + ({ + postMessageToWebview: vi.fn(), + 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(() => { + 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("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 }), + ]), + 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: [], + 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: [], + 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: [], + 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("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/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..8fd3239449 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1905,6 +1905,146 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - updateSettings is all-or-nothing", () => { + const stored: Record = {} + + beforeEach(() => { + vi.clearAllMocks() + for (const key of Object.keys(stored)) delete stored[key] + Object.assign(stored, { soundEnabled: false, writeDelayMs: 1000, language: "en" }) + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => stored[key] as never) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + stored[key] = value + }) + }) + + it("stores every setting and confirms the save by request id", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "save-1", + updatedSettings: { soundEnabled: true, writeDelayMs: 2000 }, + }) + + expect(stored).toMatchObject({ soundEnabled: true, writeDelayMs: 2000 }) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-1", + success: true, + }) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("restores the settings already written when a later one fails, and reports the failure", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "writeDelayMs" && value === 2000) throw new Error("storage unavailable") + return real(key as never, value as never) + }) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "save-2", + updatedSettings: { soundEnabled: true, writeDelayMs: 2000 }, + }) + + // soundEnabled was written first, then rolled back; writeDelayMs never changed. + expect(stored).toMatchObject({ soundEnabled: false, writeDelayMs: 1000 }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "settingsSaveResult", + requestId: "save-2", + success: false, + error: "storage unavailable", + }) + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalledWith( + expect.objectContaining({ success: true }), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("rolled back")) + // The webview is re-synced with what is really stored. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("rolls back in reverse order, so the first-written value is restored last", async () => { + const order: string[] = [] + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + order.push(`${key}=${String(value)}`) + if (key === "terminalCommandDelay" && value === 50) throw new Error("boom") + return real(key as never, value as never) + }) + stored.terminalCommandDelay = 0 + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "save-3", + updatedSettings: { soundEnabled: true, writeDelayMs: 2000, terminalCommandDelay: 50 }, + }) + + expect(stored).toMatchObject({ soundEnabled: false, writeDelayMs: 1000, terminalCommandDelay: 0 }) + + // Writes go forward in order; restores come back in the opposite order. + const failedAt = order.indexOf("terminalCommandDelay=50") + const restoreOrder = order + .slice(failedAt + 1) + .map((entry) => entry.split("=")[0]) + .filter((key, index, all) => all.indexOf(key) === index) + expect(restoreOrder.indexOf("writeDelayMs")).toBeGreaterThanOrEqual(0) + expect(restoreOrder.indexOf("soundEnabled")).toBeGreaterThan(restoreOrder.indexOf("writeDelayMs")) + }) + + it("keeps going and still reports the failure when a restore itself fails", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "writeDelayMs") throw new Error("writeDelayMs is broken") + if (key === "soundEnabled" && value === false) throw new Error("cannot restore") + return real(key as never, value as never) + }) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "save-4", + updatedSettings: { soundEnabled: true, writeDelayMs: 2000 }, + }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Could not restore soundEnabled")) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ type: "settingsSaveResult", requestId: "save-4", success: false }), + ) + }) + + it("reports a non-Error failure by its text", async () => { + vi.mocked(mockClineProvider.contextProxy.setValue).mockRejectedValue("plain string failure") + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + requestId: "save-5", + updatedSettings: { soundEnabled: true }, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ success: false, error: "plain string failure" }), + ) + }) + + it("still saves and re-syncs, without a result message, when the request has no id", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { soundEnabled: true }, + }) + + expect(stored.soundEnabled).toBe(true) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("does nothing when there are no settings to save", async () => { + await webviewMessageHandler(mockClineProvider, { type: "updateSettings", requestId: "save-6" }) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).not.toHaveBeenCalled() + }) +}) + describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..e98d1820c1 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,9 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +/** In-flight `requestProviderQuotas` work by request id, so a cancel message can abort it. */ +const pendingProviderQuotaRequests = new Map() + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -737,18 +741,21 @@ export const webviewMessageHandler = async ( } break - case "updateSettings": - if (message.updatedSettings) { - if (message.updatedSettings.destructiveCommandGuardEnabled === true) { + case "updateSettings": { + const updatedSettings = message.updatedSettings + const saveRequestId = message.requestId + if (!updatedSettings) break + { + if (updatedSettings.destructiveCommandGuardEnabled === true) { try { const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath) if (!binaryPath) { - message.updatedSettings.destructiveCommandGuardEnabled = false + updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage(t("common:errors.destructiveCommandGuard.unavailable")) } } catch (error) { - message.updatedSettings.destructiveCommandGuardEnabled = false + updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage( t("common:errors.destructiveCommandGuard.enableFailed", { error: error instanceof Error ? error.message : String(error), @@ -757,7 +764,8 @@ export const webviewMessageHandler = async ( } } - for (const [key, value] of Object.entries(message.updatedSettings)) { + /** Applies one setting (its side effect, then the stored value). Returns whether a value was stored. */ + const applySetting = async (key: string, value: unknown): Promise => { let newValue = value if (key === "language") { @@ -854,7 +862,7 @@ export const webviewMessageHandler = async ( } } else if (key === "experiments") { if (!value) { - continue + return false } newValue = { @@ -863,17 +871,60 @@ export const webviewMessageHandler = async ( } } else if (key === "customSupportPrompts") { if (!value) { - continue + return false } } - await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue as never) + return true } - await provider.postStateToWebview() - } + // Every key is snapshotted before it is written. If any write fails, the ones already applied are + // restored (stored value and side effect), so a save is all-or-nothing and the webview can keep + // the user's edits and let them retry. + const written: Array<{ key: string; previous: unknown }> = [] + try { + for (const [key, value] of Object.entries(updatedSettings)) { + written.push({ key, previous: provider.contextProxy.getValue(key as keyof RooCodeSettings) }) + await applySetting(key, value) + } + await provider.postStateToWebview() + if (saveRequestId) { + await provider.postMessageToWebview({ + type: "settingsSaveResult", + requestId: saveRequestId, + success: true, + }) + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + for (const { key, previous } of written.reverse()) { + try { + await applySetting(key, previous) + // Put back exactly what was stored, whatever defaulting the side effect applied. + await provider.contextProxy.setValue(key as keyof RooCodeSettings, previous as never) + } catch (rollbackError) { + provider.log( + `[updateSettings] Could not restore ${key}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + } + provider.log(`[updateSettings] Save failed and was rolled back: ${reason}`) + vscode.window.showErrorMessage(t("common:errors.settings_save_failed", { error: reason })) + await provider.postStateToWebview() + if (saveRequestId) { + await provider.postMessageToWebview({ + type: "settingsSaveResult", + requestId: saveRequestId, + success: false, + error: reason, + }) + } + } + } break + } case "terminalOperation": if (message.terminalOperation) { @@ -1570,6 +1621,47 @@ 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) pendingProviderQuotaRequests.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) pendingProviderQuotaRequests.set(requestId, controller) + let providerQuotas: ProviderQuota[] = [] + try { + const { getProviderQuotas, loadProfiles } = + await import("../../services/language-models/providerQuotas") + const profiles = await loadProfiles(provider.providerSettingsManager) + if (!controller.signal.aborted) { + providerQuotas = await getProviderQuotas(profiles, { + force: message.bool === true, + signal: controller.signal, + }) + } + } catch (error) { + if (!controller.signal.aborted) { + provider.log( + `[requestProviderQuotas] ${error instanceof Error ? error.message : "Failed to load provider limits"}`, + ) + } + } finally { + if (requestId) pendingProviderQuotaRequests.delete(requestId) + } + // Nobody is waiting for a cancelled request: skip the post. + if (!controller.signal.aborted) { + await provider.postMessageToWebview({ + type: VsCodeLmModelsMessageType.providerQuotas, + providerQuotas, + requestId, + }) + } + break + } case VsCodeLmModelsMessageType.githubCopilotManageAccount: try { await openGitHubAccountManagement() diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 579cd6b7d7..bc6f64b125 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "No es pot accedir a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta és fora de l'espai de treball", "settings_import_failed": "Ha fallat la importació de la configuració: {{error}}.", + "settings_save_failed": "No s'ha pogut desar la configuració i s'han restaurat els valors anteriors: {{error}}", "mistake_limit_guidance": "Això pot indicar un error en el procés de pensament del model o la incapacitat d'utilitzar una eina correctament, que es pot mitigar amb orientació de l'usuari (p. ex. \"Prova de dividir la tasca en passos més petits\").", "violated_organization_allowlist": "Ha fallat l'execució de la tasca: el perfil actual no és compatible amb la configuració de la teva organització", "condense_failed": "Ha fallat la condensació del context", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 3d8175a77e..8ac78a072f 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Zugriff auf Pfad {{path}} nicht möglich: {{error}}", "path_outside_workspace": "Pfad liegt außerhalb des Arbeitsbereichs", "settings_import_failed": "Fehler beim Importieren der Einstellungen: {{error}}.", + "settings_save_failed": "Die Einstellungen konnten nicht gespeichert werden und wurden auf die vorherigen Werte zurückgesetzt: {{error}}", "mistake_limit_guidance": "Dies kann auf einen Fehler im Denkprozess des Modells oder die Unfähigkeit hinweisen, ein Tool richtig zu verwenden, was durch Benutzerführung behoben werden kann (z.B. \"Versuche, die Aufgabe in kleinere Schritte zu unterteilen\").", "violated_organization_allowlist": "Aufgabe konnte nicht ausgeführt werden: Das aktuelle Profil ist nicht kompatibel mit den Einstellungen deiner Organisation", "condense_failed": "Fehler beim Verdichten des Kontexts", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a7d616028f..a16574650b 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Cannot access path {{path}}: {{error}}", "path_outside_workspace": "Path is outside the workspace", "settings_import_failed": "Settings import failed: {{error}}.", + "settings_save_failed": "Settings could not be saved and were restored to their previous values: {{error}}", "mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").", "violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings", "condense_failed": "Failed to condense context", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2eb6848f11..f5d2d1b060 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "No se puede acceder a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta está fuera del espacio de trabajo", "settings_import_failed": "Error al importar la configuración: {{error}}.", + "settings_save_failed": "No se pudieron guardar los ajustes y se restauraron los valores anteriores: {{error}}", "mistake_limit_guidance": "Esto puede indicar un fallo en el proceso de pensamiento del modelo o la incapacidad de usar una herramienta correctamente, lo cual puede mitigarse con orientación del usuario (ej. \"Intenta dividir la tarea en pasos más pequeños\").", "violated_organization_allowlist": "Error al ejecutar la tarea: el perfil actual no es compatible con la configuración de tu organización", "condense_failed": "Error al condensar el contexto", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b6b65a88de..3cfeeb4664 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossible d'accéder au chemin {{path}} : {{error}}", "path_outside_workspace": "Le chemin est à l'extérieur de l'espace de travail", "settings_import_failed": "Échec de l'importation des paramètres : {{error}}", + "settings_save_failed": "Impossible d'enregistrer les paramètres ; les valeurs précédentes ont été rétablies : {{error}}", "mistake_limit_guidance": "Cela peut indiquer un échec dans le processus de réflexion du modèle ou une incapacité à utiliser un outil correctement, ce qui peut être atténué avec des conseils de l'utilisateur (par ex. \"Essaie de diviser la tâche en étapes plus petites\").", "violated_organization_allowlist": "Échec de l'exécution de la tâche : le profil actuel n'est pas compatible avec les paramètres de votre organisation", "condense_failed": "Échec de la condensation du contexte", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f69b5adccf..d77a44f18e 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "पाथ {{path}} तक पहुंच नहीं पा रहे हैं: {{error}}", "path_outside_workspace": "पथ वर्कस्पेस से बाहर है", "settings_import_failed": "सेटिंग्स इम्पोर्ट करने में विफल: {{error}}।", + "settings_save_failed": "सेटिंग्स सहेजी नहीं जा सकीं और पिछले मान बहाल कर दिए गए: {{error}}", "mistake_limit_guidance": "यह मॉडल की सोच प्रक्रिया में विफलता या किसी टूल का सही उपयोग न कर पाने का संकेत हो सकता है, जिसे उपयोगकर्ता के मार्गदर्शन से ठीक किया जा सकता है (जैसे \"कार्य को छोटे चरणों में बांटने की कोशिश करें\")।", "violated_organization_allowlist": "कार्य चलाने में विफल: वर्तमान प्रोफ़ाइल आपके संगठन की सेटिंग्स के साथ संगत नहीं है", "condense_failed": "संदर्भ को संक्षिप्त करने में विफल", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index e401612bf3..a9a78a8c28 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Tidak dapat mengakses path {{path}}: {{error}}", "path_outside_workspace": "Path berada di luar workspace", "settings_import_failed": "Impor pengaturan gagal: {{error}}.", + "settings_save_failed": "Pengaturan tidak dapat disimpan dan dikembalikan ke nilai sebelumnya: {{error}}", "mistake_limit_guidance": "Ini mungkin menunjukkan kegagalan dalam proses pemikiran model atau ketidakmampuan untuk menggunakan tool dengan benar, yang dapat diatasi dengan beberapa panduan pengguna (misalnya \"Coba bagi tugas menjadi langkah-langkah yang lebih kecil\").", "violated_organization_allowlist": "Gagal menjalankan tugas: profil saat ini tidak kompatibel dengan pengaturan organisasi kamu", "condense_failed": "Gagal mengompres konteks", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index aeb984940a..ead87cfa11 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossibile accedere al percorso {{path}}: {{error}}", "path_outside_workspace": "Il percorso è fuori dall'area di lavoro", "settings_import_failed": "Importazione delle impostazioni fallita: {{error}}.", + "settings_save_failed": "Impossibile salvare le impostazioni; sono stati ripristinati i valori precedenti: {{error}}", "mistake_limit_guidance": "Questo può indicare un fallimento nel processo di pensiero del modello o l'incapacità di utilizzare correttamente uno strumento, che può essere mitigato con la guida dell'utente (ad es. \"Prova a suddividere l'attività in passaggi più piccoli\").", "violated_organization_allowlist": "Impossibile eseguire l'attività: il profilo corrente non è compatibile con le impostazioni della tua organizzazione", "condense_failed": "Impossibile condensare il contesto", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a7ec0c4de3..d5532ea227 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "パス {{path}} にアクセスできません:{{error}}", "path_outside_workspace": "パスはワークスペースの外にあります", "settings_import_failed": "設定のインポートに失敗しました:{{error}}", + "settings_save_failed": "設定を保存できなかったため、以前の値に戻しました:{{error}}", "mistake_limit_guidance": "これは、モデルの思考プロセスの失敗やツールを適切に使用できないことを示している可能性があり、ユーザーのガイダンスによって軽減できます(例:「タスクをより小さなステップに分割してみてください」)。", "violated_organization_allowlist": "タスクの実行に失敗しました: 現在のプロファイルは組織の設定と互換性がありません", "condense_failed": "コンテキストの圧縮に失敗しました", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 906652ac14..ff55cfc8f9 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "경로 {{path}}에 접근할 수 없습니다: {{error}}", "path_outside_workspace": "경로가 작업 영역 밖에 있습니다", "settings_import_failed": "설정 가져오기 실패: {{error}}.", + "settings_save_failed": "설정을 저장할 수 없어 이전 값으로 복원했습니다: {{error}}", "mistake_limit_guidance": "이는 모델의 사고 과정 실패나 도구를 제대로 사용하지 못하는 것을 나타낼 수 있으며, 사용자 가이드를 통해 완화할 수 있습니다 (예: \"작업을 더 작은 단계로 나누어 시도해보세요\").", "violated_organization_allowlist": "작업 실행 실패: 현재 프로필이 조직 설정과 호환되지 않습니다", "condense_failed": "컨텍스트 압축에 실패했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 34cdbbbe89..a3a5b61595 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Kan pad {{path}} niet openen: {{error}}", "path_outside_workspace": "Pad is buiten de werkomgeving", "settings_import_failed": "Importeren van instellingen mislukt: {{error}}.", + "settings_save_failed": "De instellingen konden niet worden opgeslagen en zijn teruggezet naar de vorige waarden: {{error}}", "mistake_limit_guidance": "Dit kan duiden op een fout in het denkproces van het model of het onvermogen om een tool correct te gebruiken, wat kan worden verminderd met gebruikersbegeleiding (bijv. \"Probeer de taak op te delen in kleinere stappen\").", "violated_organization_allowlist": "Taak uitvoeren mislukt: het huidige profiel is niet compatibel met de instellingen van uw organisatie", "condense_failed": "Comprimeren van context mislukt", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a743fc009..cfdd0b67e0 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Nie można uzyskać dostępu do ścieżki {{path}}: {{error}}", "path_outside_workspace": "Ścieżka znajduje się poza obszarem roboczym", "settings_import_failed": "Nie udało się zaimportować ustawień: {{error}}.", + "settings_save_failed": "Nie udało się zapisać ustawień; przywrócono poprzednie wartości: {{error}}", "mistake_limit_guidance": "To może wskazywać na błąd w procesie myślowym modelu lub niezdolność do prawidłowego użycia narzędzia, co można złagodzić poprzez wskazówki użytkownika (np. \"Spróbuj podzielić zadanie na mniejsze kroki\").", "violated_organization_allowlist": "Nie udało się uruchomić zadania: bieżący profil nie jest kompatybilny z ustawieniami Twojej organizacji", "condense_failed": "Nie udało się skondensować kontekstu", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index eaf1cae06b..bbe3a0193b 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "Não é possível acessar o caminho {{path}}: {{error}}", "path_outside_workspace": "O caminho está fora do espaço de trabalho", "settings_import_failed": "Falha ao importar configurações: {{error}}", + "settings_save_failed": "Não foi possível salvar as configurações; os valores anteriores foram restaurados: {{error}}", "mistake_limit_guidance": "Isso pode indicar uma falha no processo de pensamento do modelo ou incapacidade de usar uma ferramenta adequadamente, o que pode ser mitigado com orientação do usuário (ex. \"Tente dividir a tarefa em etapas menores\").", "violated_organization_allowlist": "Falha ao executar a tarefa: o perfil atual não é compatível com as configurações da sua organização", "condense_failed": "Falha ao condensar o contexto", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6f48365ec1..a020b49dcd 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Невозможно получить доступ к пути {{path}}: {{error}}", "path_outside_workspace": "Путь находится вне рабочего пространства", "settings_import_failed": "Не удалось импортировать настройки: {{error}}.", + "settings_save_failed": "Не удалось сохранить настройки; восстановлены прежние значения: {{error}}", "mistake_limit_guidance": "Это может указывать на сбой в процессе мышления модели или неспособность правильно использовать инструмент, что можно смягчить с помощью руководства пользователя (например, \"Попробуйте разбить задачу на более мелкие шаги\").", "violated_organization_allowlist": "Не удалось выполнить задачу: текущий профиль несовместим с настройками вашей организации", "condense_failed": "Не удалось сжать контекст", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index b5c543c405..a60d5ff7a9 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "{{path}} yoluna erişilemiyor: {{error}}", "path_outside_workspace": "Yol çalışma alanının dışında", "settings_import_failed": "Ayarlar içe aktarılamadı: {{error}}.", + "settings_save_failed": "Ayarlar kaydedilemedi ve önceki değerlere geri döndürüldü: {{error}}", "mistake_limit_guidance": "Bu, modelin düşünce sürecindeki bir başarısızlığı veya bir aracı düzgün kullanamama durumunu gösterebilir, bu da kullanıcı rehberliği ile hafifletilebilir (örn. \"Görevi daha küçük adımlara bölmeyi deneyin\").", "violated_organization_allowlist": "Görev yürütülemedi: Geçerli profil kuruluşunuzun ayarlarıyla uyumlu değil", "condense_failed": "Bağlam sıkıştırılamadı", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index cb2ed8e9b8..8c072c9b5b 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Không thể truy cập đường dẫn {{path}}: {{error}}", "path_outside_workspace": "Đường dẫn nằm ngoài không gian làm việc", "settings_import_failed": "Nhập cài đặt thất bại: {{error}}.", + "settings_save_failed": "Không thể lưu cài đặt và đã khôi phục về giá trị trước đó: {{error}}", "mistake_limit_guidance": "Điều này có thể cho thấy sự thất bại trong quá trình suy nghĩ của mô hình hoặc không thể sử dụng công cụ đúng cách, có thể được giảm thiểu bằng hướng dẫn của người dùng (ví dụ: \"Hãy thử chia nhỏ nhiệm vụ thành các bước nhỏ hơn\").", "violated_organization_allowlist": "Không thể chạy tác vụ: hồ sơ hiện tại không tương thích với cài đặt của tổ chức của bạn", "condense_failed": "Không thể nén ngữ cảnh", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 08cfe736b5..7ed9f1b617 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -63,6 +63,7 @@ "cannot_access_path": "无法访问路径 {{path}}:{{error}}", "path_outside_workspace": "路径位于工作区之外", "settings_import_failed": "设置导入失败:{{error}}。", + "settings_save_failed": "无法保存设置,已恢复为之前的值:{{error}}", "mistake_limit_guidance": "这可能表明模型思维过程失败或无法正确使用工具,可通过用户指导来缓解(例如\"尝试将任务分解为更小的步骤\")。", "violated_organization_allowlist": "执行任务失败:当前配置文件与您的组织设置不兼容", "condense_failed": "压缩上下文失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92fdbaeb96..3d9c8d9858 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "無法存取路徑 {{path}}:{{error}}", "path_outside_workspace": "路徑位於工作區之外", "settings_import_failed": "設定匯入失敗:{{error}}。", + "settings_save_failed": "無法儲存設定,已還原為之前的值:{{error}}", "mistake_limit_guidance": "這可能表明模型思維過程失敗或無法正確使用工具,可透過使用者指導來緩解(例如「嘗試將工作分解為更小的步驟」)。", "violated_organization_allowlist": "執行工作失敗:目前設定檔與您的組織設定不相容", "condense_failed": "壓縮上下文失敗", 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..65996ad300 --- /dev/null +++ b/src/services/language-models/__tests__/copilotQuota.spec.ts @@ -0,0 +1,204 @@ +import { 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("reports noCopilot when the payload has no premium snapshot", async () => { + const result = await getGitHubCopilotQuota({}, (async () => json({ quota_snapshots: {} })) as never) + expect(result).toMatchObject({ ok: false, reason: "noCopilot" }) + }) + + 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) + }) + + 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("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..b27a7830aa --- /dev/null +++ b/src/services/language-models/__tests__/providerQuotas.spec.ts @@ -0,0 +1,970 @@ +import { 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, + loadProfiles, + 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("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("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" }] }) + expect(first).toEqual([{ id: "5h", valueLabel: "5-hour", usedPercent: undefined, resetAt: undefined }]) + expect( + parseMiniMaxRemains({ model_remains: [{ current_interval_remaining_percent: 5 }] })?.[0].usedPercent, + ).toBe(95) + }) + + 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("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("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 }) + }) + + it("never sends a key to a custom base URL", async () => { + const fetchSpy = vi.fn(okFetch) + const [q] = await getProviderQuotas( + [ + { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sk", + openRouterBaseUrl: "https://evil.example/api/v1", + } as never, + ], + {}, + fetchSpy, + ) + expect(q).toMatchObject({ ok: false, reason: "unsupported" }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it("calls the provider's own host with the saved key", async () => { + const fetchSpy = vi.fn(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("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("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("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..6a8c6be07f --- /dev/null +++ b/src/services/language-models/copilotQuota.ts @@ -0,0 +1,132 @@ +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, + } +} + +async function getGitHubToken(): Promise { + const { authProviderId, authScopeSets } = githubCopilotLanguageModel + for (const scopes of authScopeSets) { + const session = await vscode.authentication.getSession(authProviderId, scopes, { silent: true }) + if (session) return session.accessToken + } + return undefined +} + +let cache: { at: number; result: GitHubCopilotQuotaResult } | undefined +let inFlight: Promise | undefined + +/** + * Fetches the quota. Concurrent callers share one request; successful results + * are cached briefly unless `force` is set (the Refresh button). + */ +export function getGitHubCopilotQuota( + { force = false }: { force?: boolean } = {}, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) return Promise.resolve(cache.result) + if (inFlight) return inFlight + + inFlight = (async (): Promise => { + try { + const token = await getGitHubToken() + 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.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()) + const result: GitHubCopilotQuotaResult = quota ? { ok: true, quota } : { ok: false, reason: "noCopilot" } + if (result.ok) cache = { at: Date.now(), result } + return result + } catch (error) { + return { ok: false, reason: "error", message: safeErrorMessage(error) } + } finally { + inFlight = undefined + } + })() + + return inFlight +} + +/** @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..4286b4f6d1 --- /dev/null +++ b/src/services/language-models/providerQuotas.ts @@ -0,0 +1,673 @@ +import { + providerIdentifiers, + type ProviderQuota, + type ProviderQuotaWindow, + type ProviderSettings, +} 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 usage = toNumber(data.usage_monthly) ?? toNumber(data.usage) + return usage === undefined ? undefined : [{ id: "spend", valueLabel: `${formatMoney(usage)} spent this month` }] +} + +/** 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 +} + +/** 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) ?? + 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[] = [ + { + id: "5h", + valueLabel: "5-hour", + usedPercent: usedPercentOf("interval"), + resetAt: toEpochMs(model.end_time), + }, + ] + + // 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 +} + +/** 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: signal ? AbortSignal.any([signal, timeout]) : 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) => ({ + apiKey: 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[]): KeyedCredentials | undefined { + 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, + creds: KeyedCredentials, + fetchImpl: Fetch, + signal?: AbortSignal, +): Promise { + const now = Date.now() + if (!creds.apiKey || !creds.urls?.length) { + return { providerId: provider.providerId, ok: false, reason: "unsupported", fetchedAt: 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 creds.urls.entries()) { + try { + windows = provider.parse(await getJson(fetchImpl, url, creds.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, 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): Promise { + const result = await getGitHubCopilotQuota({ force }) + 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: [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 }[]> + getProfile(params: { id: string }): Promise +} + +/** + * Loads full profiles (including API keys) host-side. A profile that fails to + * load is skipped rather than failing the whole refresh. Keys never leave the + * extension host: only formatted quota rows are posted to the webview. + */ +export async function loadProfiles(source: ProfileSource): Promise { + const entries = await source.listConfig() + const profiles = await Promise.all( + entries.map((entry) => source.getProfile({ id: entry.id }).catch(() => undefined)), + ) + return profiles.filter((p): p is ProviderSettings => !!p) +} + +// 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, creds: KeyedCredentials): string { + return `${provider.providerId}|${(creds.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, +): Promise { + const creds = pickCredentials(provider, profiles) + const skipped = unqueryable(provider, creds) + if (skipped) return Promise.resolve(skipped) + + const resolved = creds as KeyedCredentials + const apiKey = resolved.apiKey as string + const key = cacheKey(provider, resolved) + 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, resolved, fetchImpl, controller.signal) + .then((result) => { + if (result.ok) { + cache.set(key, { at: Date.now(), apiKey, result }) + } else { + // A failed refresh must not leave an older success to be served as if it were current. + 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() + } + 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 }: { force?: boolean; signal?: AbortSignal } = {}, + 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)) + for (const provider of KEYED_PROVIDERS) { + if (connected.has(provider.providerId)) { + tasks.push(quotaForKeyedProvider(provider, profiles, force, fetchImpl, signal)) + } + } + + 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..6fa9cbd314 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -419,6 +419,23 @@ export const stories: Record = { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return }, + "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 + }, + "appearance-preview": async () => { + const { AppearancePreviewStory } = + await import("@/components/settings/__tests__/AppearancePreview.visual.fixture") + return + }, "ui-settings-long-locale": async () => { const [{ UISettingsStory }, { default: i18next }] = await Promise.all([ import("@/components/settings/__tests__/UISettings.visual.fixture"), diff --git a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx index f5c2ba1062..052d58267e 100644 --- a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx +++ b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx @@ -5,8 +5,9 @@ import { providerIdentifiers } from "@roo-code/types" import TaskHeader from "@src/components/chat/TaskHeader" -// Mock formatLargeNumber function -vi.mock("@/utils/format", () => ({ +// Keep the real format helpers (other components in the tree use them) and override only formatLargeNumber. +vi.mock("@/utils/format", async (importOriginal) => ({ + ...(await importOriginal()), formatLargeNumber: vi.fn((num) => num.toString()), })) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 77ae4529c0..138b505643 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -38,6 +38,7 @@ import WarningRow from "./WarningRow" import McpResourceRow from "../mcp/McpResourceRow" import { Mention } from "./Mention" +import { MessageTimestamp } from "./MessageTimestamp" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" import { BatchFilePermission } from "./BatchFilePermission" @@ -142,7 +143,7 @@ const ChatRow = memo( const prevHeightRef = useRef(0) const [chatrow, { height }] = useSize( -
+
, ) @@ -196,6 +197,8 @@ export const ChatRowContent = ({ clineMessages, currentTaskItem, enableCheckpoints, + timeFormat, + dateFormat, } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const [isEditing, setIsEditing] = useState(false) @@ -1224,6 +1227,13 @@ export const ChatRowContent = ({
)} + {/* When the reply was sent. Held back while it streams, so the line does not appear and + shift as the text grows. */} + {!message.partial && ( +
+ +
+ )} ) case "user_feedback": @@ -1299,6 +1309,12 @@ export const ChatRowContent = ({ )} + {/* When the message was sent. Hidden while editing: the edit box replaces the bubble. */} + {!isEditing && ( +
+ +
+ )} ) case "user_feedback_diff": diff --git a/webview-ui/src/components/chat/MessageTimestamp.tsx b/webview-ui/src/components/chat/MessageTimestamp.tsx new file mode 100644 index 0000000000..a925eb910c --- /dev/null +++ b/webview-ui/src/components/chat/MessageTimestamp.tsx @@ -0,0 +1,45 @@ +import { Clock } from "lucide-react" + +import { cn } from "@src/lib/utils" +import { formatDateTime, formatMessageTime, type DateFormat, type TimeFormat } from "@/utils/format" + +import { StandardTooltip } from "../ui/standard-tooltip" + +interface MessageTimestampProps { + /** Epoch ms the message was sent. */ + ts: number + /** From the user's Language settings; both default to "auto". */ + timeFormat?: TimeFormat + dateFormat?: DateFormat + className?: string +} + +/** + * "🕒 Yesterday 11:43 AM" under a sent message. + * + * Purely presentational: the caller supplies the user's time/date formats, so + * the component has no dependency on extension state and renders anywhere. The + * tooltip always carries the full date and time. A semantic