Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a0aa52a
feat(usage): add a Usage tab with provider limits and usage history
KarthickMahalingam Oct 28, 2026
c0891e4
fix(types): add apiProvider and modelId to ClineApiReqInfo
KarthickMahalingam Oct 28, 2026
a7ba4ad
test(visual): add Usage tab baselines
KarthickMahalingam Oct 28, 2026
0774432
fix(usage): wrap long text instead of clipping it at the reflow width
KarthickMahalingam Oct 28, 2026
010a565
fix(usage): address review feedback on quotas, cache hit rate, and retry
KarthickMahalingam Oct 28, 2026
56a7085
fix(usage): reconcile condense cost and record the resolved model
KarthickMahalingam Oct 28, 2026
a2cf471
fix(usage): use the provider registry in a test; refresh baselines fo…
KarthickMahalingam Oct 28, 2026
dafd502
fix(usage): cancel Copilot quota lookups with the request, unique req…
KarthickMahalingam Oct 28, 2026
cda3475
test(usage): require exactly one quota call, and avoid a racy dynamic…
KarthickMahalingam Oct 28, 2026
f263acf
style: format Task.spec
KarthickMahalingam Oct 28, 2026
ae50b61
fix(usage): do not join a cancelled quota request; ignore non-finite …
KarthickMahalingam Oct 28, 2026
9191d4a
fix(usage): clear a stale usageByModel when messages are replaced; cl…
KarthickMahalingam Oct 28, 2026
56d07b1
test: use providerIdentifiers in the usageByModel store test
KarthickMahalingam Oct 28, 2026
017237c
fix(usage): cancel profile loading and the Copilot token stage; add s…
KarthickMahalingam Oct 28, 2026
ed9657f
fix(usage): compute metadata from the persisted (merged) messages; as…
KarthickMahalingam Oct 28, 2026
f19d08a
refactor(usage): remove guards made unreachable by the abortable waits
KarthickMahalingam Oct 28, 2026
094138b
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 29, 2026
cb58245
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 29, 2026
ab61e55
test(usage): pin the cache lifetime and hostile base URLs; report a C…
KarthickMahalingam Oct 29, 2026
a6a594c
feat(usage): translate the names of fixed provider-limit windows
KarthickMahalingam Oct 29, 2026
3074d68
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 30, 2026
361be13
test(usage): cover the day-count window name
KarthickMahalingam Oct 30, 2026
1586ed0
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 30, 2026
bcb4e40
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
dc9d128
fix(usage): record the serving handler per request; OpenRouter total …
KarthickMahalingam Oct 31, 2026
ef34663
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
f616c70
fix(lint): hold the served model id in an object, not a let that is a…
KarthickMahalingam Oct 31, 2026
34335c4
fix(usage): skip non-object stored requests; abort quota requests on …
KarthickMahalingam Oct 31, 2026
a7b0743
fix(usage): query the active profile's own credentials; deadline for …
KarthickMahalingam Oct 31, 2026
d66e03d
fix(ci): add the new export to the handler mock; drop the unused load…
KarthickMahalingam Oct 31, 2026
89025cc
Merge branch 'main' into feat/usage-tab
KarthickMahalingam Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions packages/types/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,38 @@ describe("historyItemSchema", () => {
expect(historyItemSchema.parse(baseItem)).not.toHaveProperty("title")
})
})

describe("usageByModel on history items", () => {
const base = { id: "task-1", number: 1, ts: 1, task: "t", tokensIn: 1, tokensOut: 1, totalCost: 0 }

it("preserves every field of a per-model entry", () => {
const entry = {
apiProvider: "provider-a",
modelId: "model-a",
requests: 3,
tokensIn: 100,
tokensOut: 50,
cacheReads: 40,
cacheWrites: 10,
cost: 0.25,
}
expect(historyItemSchema.parse({ ...base, usageByModel: [entry, { ...entry, modelId: "model-b" }] })).toEqual({
...base,
usageByModel: [entry, { ...entry, modelId: "model-b" }],
})
})

it("accepts an entry with no provider, model or cache counts, and an item with no breakdown", () => {
const bare = { requests: 1, tokensIn: 2, tokensOut: 3, cost: 0 }
expect(historyItemSchema.parse({ ...base, usageByModel: [bare] }).usageByModel).toEqual([bare])
expect(historyItemSchema.parse(base).usageByModel).toBeUndefined()
})

it.each([
["a missing request count", { tokensIn: 1, tokensOut: 1, cost: 0 }],
["a non-numeric cost", { requests: 1, tokensIn: 1, tokensOut: 1, cost: "free" }],
["a non-numeric token count", { requests: 1, tokensIn: "1", tokensOut: 1, cost: 0 }],
])("rejects %s", (_name, entry) => {
expect(historyItemSchema.safeParse({ ...base, usageByModel: [entry] }).success).toBe(false)
})
})
19 changes: 19 additions & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ export const pendingTaskActionSchema = z.discriminatedUnion("kind", [

export type PendingTaskAction = z.infer<typeof pendingTaskActionSchema>

/**
* 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<typeof usageByModelEntrySchema>

export const historyItemSchema = z.object({
id: z.string(),
rootTaskId: z.string().optional(),
Expand All @@ -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(),
Expand Down
63 changes: 63 additions & 0 deletions packages/types/src/providers/vscode-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,71 @@ export const vsCodeLmModelsMessageTypes = [
"githubCopilotManageAccount",
"githubCopilotSignInResult",
"githubCopilotModels",
"requestProviderQuotas",
"cancelProviderQuotas",
"providerQuotas",
] as const

/**
* One limit/balance a provider reports (e.g. Copilot premium credits for the
* month, an OpenRouter key limit, a DeepSeek prepaid balance).
*/
export interface ProviderQuotaWindow {
/** Stable key, e.g. "premium", "key_limit", "balance". */
id: string
/** Pre-formatted primary figure, e.g. "39,389.3 / 140,000 used", "$12.40 left". */
valueLabel: string
/**
* Set when the label is just the name of a fixed window ("Weekly", "5-hour", "Unlimited"), so the webview can
* show it in the user's language. `valueLabel` stays as the English fallback.
*/
name?: { key: "weekly" | "daily" | "monthly" | "unlimited" | "hours" | "days"; count?: number }
/** 0–100 when the provider reports a bounded allowance. */
usedPercent?: number
/** Epoch ms. */
resetAt?: number
}

export type ProviderQuota =
| {
providerId: string
ok: true
plan?: string
windows: ProviderQuotaWindow[]
fetchedAt: number
}
| {
providerId: string
ok: false
reason: "signedOut" | "noCopilot" | "noKey" | "unsupported" | "error"
message?: string
fetchedAt: number
}

/**
* The signed-in account's GitHub Copilot premium-request allowance, as reported by
* `GET /copilot_internal/user` (the same source Copilot Chat's own usage panel reads).
*/
export interface GitHubCopilotQuota {
/** e.g. "business", "individual". */
plan?: string
unlimited: boolean
/** Total credits for the period; undefined when unlimited or not reported. */
entitlement?: number
/** Credits used this period, `entitlement - quota_remaining` (one decimal, like GitHub's panel). */
used?: number
/** 0–100. */
usedPercent?: number
/** Epoch ms when the allowance resets. */
resetAt?: number
/** Epoch ms when this snapshot was fetched. */
fetchedAt: number
}

export type GitHubCopilotQuotaResult =
| { ok: true; quota: GitHubCopilotQuota }
| { ok: false; reason: "signedOut" | "noCopilot" | "error"; message?: string }

export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes)

export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum
Expand Down
15 changes: 14 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -50,6 +50,7 @@ export interface ExtensionMessage {
| typeof VsCodeLmModelsMessageType.vsCodeLmModels
| typeof VsCodeLmModelsMessageType.githubCopilotModels
| typeof VsCodeLmModelsMessageType.githubCopilotSignInResult
| typeof VsCodeLmModelsMessageType.providerQuotas
| "vsCodeLmApiAvailable"
| "updatePrompt"
| "systemPrompt"
Expand Down Expand Up @@ -158,6 +159,9 @@ export interface ExtensionMessage {
modelInfo?: ModelInfo
}[]
githubCopilotAccount?: string | null
providerQuotas?: ProviderQuota[]
/** True when the quotas could not be loaded at all (as opposed to "no provider has a usage API"). */
providerQuotasFailed?: boolean
mcpServers?: McpServer[]
commits?: GitCommit[]
listApiConfig?: ProviderSettingsEntry[]
Expand Down Expand Up @@ -516,6 +520,8 @@ export interface WebviewMessage {
| typeof VsCodeLmModelsMessageType.githubCopilotSignIn
| typeof VsCodeLmModelsMessageType.githubCopilotReconnect
| typeof VsCodeLmModelsMessageType.githubCopilotManageAccount
| typeof VsCodeLmModelsMessageType.requestProviderQuotas
| typeof VsCodeLmModelsMessageType.cancelProviderQuotas
| "openImage"
| "saveImage"
| "openFile"
Expand Down Expand Up @@ -951,6 +957,13 @@ export interface ClineApiReqInfo {
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
apiProtocol?: "anthropic" | "openai"
/**
* The provider and model that actually served this request, recorded at the
* time of the call. Usage reporting reads these instead of the *current*
* profile, so switching providers later doesn't relabel past requests.
*/
apiProvider?: string
modelId?: string
}

export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"

import type { HistoryItem } from "@roo-code/types"
import { providerIdentifiers, type HistoryItem } from "@roo-code/types"

import { TaskHistoryStore } from "../TaskHistoryStore"

Expand Down Expand Up @@ -119,6 +119,39 @@ describe("TaskHistoryStore real cross-host locking", () => {
}
})

it("clears a stored usageByModel when the same task is saved again without one (e.g. after a rewind)", async () => {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-usage-clear-"))
const store = new TaskHistoryStore(storagePath)
const usageByModel = [
{
apiProvider: providerIdentifiers.anthropic,
modelId: "m",
requests: 1,
tokensIn: 5,
tokensOut: 5,
cost: 1,
},
]

try {
await store.initialize()
await store.upsert({ ...item("usage-task"), usageByModel })
expect(store.get("usage-task")?.usageByModel).toEqual(usageByModel)

// What taskMetadata emits when the replaced messages record no usage.
await store.upsert({ ...item("usage-task"), usageByModel: undefined })
expect(store.get("usage-task")?.usageByModel).toBeUndefined()

// ...and the stored file no longer carries the stale breakdown either.
await store.invalidate("usage-task")
expect(store.get("usage-task")).toBeDefined()
expect(store.get("usage-task")?.usageByModel).toBeUndefined()
} finally {
store.dispose()
await fs.rm(storagePath, { recursive: true, force: true })
}
})

it("reports a bounded error when one store never reaches the write barrier", async () => {
const storagePath = await fs.mkdtemp(path.join(os.tmpdir(), "task-history-missed-barrier-"))
const storeA = new TaskHistoryStore(storagePath)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest"

import { providerIdentifiers, type ClineMessage } from "@roo-code/types"

vi.mock("get-folder-size", () => ({ default: { loose: vi.fn(async () => 1234) } }))
vi.mock("../../../utils/storage", () => ({ getTaskDirectoryPath: vi.fn(async () => "/tmp/task") }))
vi.mock("../../../i18n", () => ({ t: (k: string) => k }))

import { taskMetadata } from "../taskMetadata"

const say = (say: ClineMessage["say"], text: string, ts: number): ClineMessage => ({ ts, type: "say", say, text })

const base = {
taskId: "t1",
taskNumber: 1,
globalStoragePath: "/tmp",
workspace: "/ws",
}

describe("taskMetadata – usageByModel", () => {
it("records per provider+model usage on the history item", async () => {
const messages = [
say("text", "do the thing", 1),
say(
"api_req_started",
JSON.stringify({
apiProvider: providerIdentifiers.githubCopilot,
modelId: "claude-opus-5.5",
tokensIn: 900,
tokensOut: 90,
cost: 0,
}),
2,
),
say(
"api_req_started",
JSON.stringify({
apiProvider: providerIdentifiers.gemini,
modelId: "gemini-pro",
tokensIn: 100,
tokensOut: 10,
cost: 0.2,
}),
3,
),
]
const { historyItem } = await taskMetadata({ ...base, messages })

expect(historyItem.usageByModel).toEqual([
{
apiProvider: providerIdentifiers.githubCopilot,
modelId: "claude-opus-5.5",
requests: 1,
tokensIn: 900,
tokensOut: 90,
cost: 0,
},
{
apiProvider: providerIdentifiers.gemini,
modelId: "gemini-pro",
requests: 1,
tokensIn: 100,
tokensOut: 10,
cost: 0.2,
},
])
// Existing totals are unchanged by the new breakdown.
expect(historyItem.tokensIn).toBe(1000)
expect(historyItem.totalCost).toBeCloseTo(0.2)
})

it("leaves usageByModel undefined when nothing was recorded, so a stored breakdown is cleared", async () => {
const { historyItem } = await taskMetadata({ ...base, messages: [say("text", "hello", 1)] })
expect(historyItem).toHaveProperty("usageByModel", undefined)
// Nothing is written for it: the stored shape of older tasks is unchanged.
expect(JSON.parse(JSON.stringify(historyItem))).not.toHaveProperty("usageByModel")
})

it("leaves usageByModel undefined for a task with no messages", async () => {
const { historyItem } = await taskMetadata({ ...base, messages: [] })
expect(historyItem).toHaveProperty("usageByModel", undefined)
expect(historyItem.tokensIn).toBe(0)
})
})
Loading
Loading