Skip to content

Commit a0aa52a

Browse files
feat(usage): add a Usage tab with provider limits and usage history
1 parent 3f51e9d commit a0aa52a

49 files changed

Lines changed: 6404 additions & 19 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/types/src/history.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ export const pendingTaskActionSchema = z.discriminatedUnion("kind", [
2626

2727
export type PendingTaskAction = z.infer<typeof pendingTaskActionSchema>
2828

29+
/**
30+
* Token/cost totals for one provider+model within a task. A task can span
31+
* several (the user may switch profiles mid-task), so history keeps a list.
32+
*/
33+
export const usageByModelEntrySchema = z.object({
34+
apiProvider: z.string().optional(),
35+
modelId: z.string().optional(),
36+
requests: z.number(),
37+
tokensIn: z.number(),
38+
tokensOut: z.number(),
39+
cacheReads: z.number().optional(),
40+
cacheWrites: z.number().optional(),
41+
cost: z.number(),
42+
})
43+
44+
export type UsageByModelEntry = z.infer<typeof usageByModelEntrySchema>
45+
2946
export const historyItemSchema = z.object({
3047
id: z.string(),
3148
rootTaskId: z.string().optional(),
@@ -39,6 +56,8 @@ export const historyItemSchema = z.object({
3956
cacheWrites: z.number().optional(),
4057
cacheReads: z.number().optional(),
4158
totalCost: z.number(),
59+
/** Per provider+model breakdown, recorded as requests happen. Absent on tasks created before this was tracked. */
60+
usageByModel: z.array(usageByModelEntrySchema).optional(),
4261
size: z.number().optional(),
4362
workspace: z.string().optional(),
4463
mode: z.string().optional(),

‎packages/types/src/providers/vscode-llm.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,66 @@ export const vsCodeLmModelsMessageTypes = [
1111
"githubCopilotManageAccount",
1212
"githubCopilotSignInResult",
1313
"githubCopilotModels",
14+
"requestProviderQuotas",
15+
"cancelProviderQuotas",
16+
"providerQuotas",
1417
] as const
1518

19+
/**
20+
* One limit/balance a provider reports (e.g. Copilot premium credits for the
21+
* month, an OpenRouter key limit, a DeepSeek prepaid balance).
22+
*/
23+
export interface ProviderQuotaWindow {
24+
/** Stable key, e.g. "premium", "key_limit", "balance". */
25+
id: string
26+
/** Pre-formatted primary figure, e.g. "39,389.3 / 140,000 used", "$12.40 left". */
27+
valueLabel: string
28+
/** 0–100 when the provider reports a bounded allowance. */
29+
usedPercent?: number
30+
/** Epoch ms. */
31+
resetAt?: number
32+
}
33+
34+
export type ProviderQuota =
35+
| {
36+
providerId: string
37+
ok: true
38+
plan?: string
39+
windows: ProviderQuotaWindow[]
40+
fetchedAt: number
41+
}
42+
| {
43+
providerId: string
44+
ok: false
45+
reason: "signedOut" | "noCopilot" | "noKey" | "unsupported" | "error"
46+
message?: string
47+
fetchedAt: number
48+
}
49+
50+
/**
51+
* The signed-in account's GitHub Copilot premium-request allowance, as reported by
52+
* `GET /copilot_internal/user` (the same source Copilot Chat's own usage panel reads).
53+
*/
54+
export interface GitHubCopilotQuota {
55+
/** e.g. "business", "individual". */
56+
plan?: string
57+
unlimited: boolean
58+
/** Total credits for the period; undefined when unlimited or not reported. */
59+
entitlement?: number
60+
/** Credits used this period, `entitlement - quota_remaining` (one decimal, like GitHub's panel). */
61+
used?: number
62+
/** 0–100. */
63+
usedPercent?: number
64+
/** Epoch ms when the allowance resets. */
65+
resetAt?: number
66+
/** Epoch ms when this snapshot was fetched. */
67+
fetchedAt: number
68+
}
69+
70+
export type GitHubCopilotQuotaResult =
71+
| { ok: true; quota: GitHubCopilotQuota }
72+
| { ok: false; reason: "signedOut" | "noCopilot" | "error"; message?: string }
73+
1674
export const vsCodeLmModelsMessageTypeSchema = z.enum(vsCodeLmModelsMessageTypes)
1775

1876
export const VsCodeLmModelsMessageType = vsCodeLmModelsMessageTypeSchema.enum

‎packages/types/src/vscode-extension-host.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { RouterModelsMessageType, type ModelInfo, type ModelRecord, type RouterM
1616
import { LmStudioModelsMessageType } from "./providers/lm-studio.js"
1717
import { OllamaModelsMessageType } from "./providers/ollama.js"
1818
import { OpenAiModelsMessageType } from "./providers/openai.js"
19-
import { VsCodeLmModelsMessageType } from "./providers/vscode-llm.js"
19+
import { VsCodeLmModelsMessageType, type ProviderQuota } from "./providers/vscode-llm.js"
2020
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
2121
import type { SkillMetadata } from "./skills.js"
2222
import type { RuleMetadata } from "./rules.js"
@@ -50,6 +50,7 @@ export interface ExtensionMessage {
5050
| typeof VsCodeLmModelsMessageType.vsCodeLmModels
5151
| typeof VsCodeLmModelsMessageType.githubCopilotModels
5252
| typeof VsCodeLmModelsMessageType.githubCopilotSignInResult
53+
| typeof VsCodeLmModelsMessageType.providerQuotas
5354
| "vsCodeLmApiAvailable"
5455
| "updatePrompt"
5556
| "systemPrompt"
@@ -158,6 +159,7 @@ export interface ExtensionMessage {
158159
modelInfo?: ModelInfo
159160
}[]
160161
githubCopilotAccount?: string | null
162+
providerQuotas?: ProviderQuota[]
161163
mcpServers?: McpServer[]
162164
commits?: GitCommit[]
163165
listApiConfig?: ProviderSettingsEntry[]
@@ -516,6 +518,8 @@ export interface WebviewMessage {
516518
| typeof VsCodeLmModelsMessageType.githubCopilotSignIn
517519
| typeof VsCodeLmModelsMessageType.githubCopilotReconnect
518520
| typeof VsCodeLmModelsMessageType.githubCopilotManageAccount
521+
| typeof VsCodeLmModelsMessageType.requestProviderQuotas
522+
| typeof VsCodeLmModelsMessageType.cancelProviderQuotas
519523
| "openImage"
520524
| "saveImage"
521525
| "openFile"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it, vi } from "vitest"
2+
3+
import { providerIdentifiers, type ClineMessage } from "@roo-code/types"
4+
5+
vi.mock("get-folder-size", () => ({ default: { loose: vi.fn(async () => 1234) } }))
6+
vi.mock("../../../utils/storage", () => ({ getTaskDirectoryPath: vi.fn(async () => "/tmp/task") }))
7+
vi.mock("../../../i18n", () => ({ t: (k: string) => k }))
8+
9+
import { taskMetadata } from "../taskMetadata"
10+
11+
const say = (say: ClineMessage["say"], text: string, ts: number): ClineMessage => ({ ts, type: "say", say, text })
12+
13+
const base = {
14+
taskId: "t1",
15+
taskNumber: 1,
16+
globalStoragePath: "/tmp",
17+
workspace: "/ws",
18+
}
19+
20+
describe("taskMetadata – usageByModel", () => {
21+
it("records per provider+model usage on the history item", async () => {
22+
const messages = [
23+
say("text", "do the thing", 1),
24+
say(
25+
"api_req_started",
26+
JSON.stringify({
27+
apiProvider: providerIdentifiers.githubCopilot,
28+
modelId: "claude-opus-5.5",
29+
tokensIn: 900,
30+
tokensOut: 90,
31+
cost: 0,
32+
}),
33+
2,
34+
),
35+
say(
36+
"api_req_started",
37+
JSON.stringify({
38+
apiProvider: providerIdentifiers.gemini,
39+
modelId: "gemini-pro",
40+
tokensIn: 100,
41+
tokensOut: 10,
42+
cost: 0.2,
43+
}),
44+
3,
45+
),
46+
]
47+
const { historyItem } = await taskMetadata({ ...base, messages })
48+
49+
expect(historyItem.usageByModel).toEqual([
50+
{
51+
apiProvider: providerIdentifiers.githubCopilot,
52+
modelId: "claude-opus-5.5",
53+
requests: 1,
54+
tokensIn: 900,
55+
tokensOut: 90,
56+
cost: 0,
57+
},
58+
{
59+
apiProvider: providerIdentifiers.gemini,
60+
modelId: "gemini-pro",
61+
requests: 1,
62+
tokensIn: 100,
63+
tokensOut: 10,
64+
cost: 0.2,
65+
},
66+
])
67+
// Existing totals are unchanged by the new breakdown.
68+
expect(historyItem.tokensIn).toBe(1000)
69+
expect(historyItem.totalCost).toBeCloseTo(0.2)
70+
})
71+
72+
it("omits usageByModel entirely when nothing was recorded, keeping old items' shape", async () => {
73+
const { historyItem } = await taskMetadata({ ...base, messages: [say("text", "hello", 1)] })
74+
expect(historyItem).not.toHaveProperty("usageByModel")
75+
})
76+
77+
it("omits usageByModel for a task with no messages", async () => {
78+
const { historyItem } = await taskMetadata({ ...base, messages: [] })
79+
expect(historyItem).not.toHaveProperty("usageByModel")
80+
expect(historyItem.tokensIn).toBe(0)
81+
})
82+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { providerIdentifiers, type ClineMessage } from "@roo-code/types"
4+
5+
import { summarizeUsageByModel } from "../usageByModel"
6+
7+
const req = (data: Record<string, unknown>, ts = 1): ClineMessage => ({
8+
ts,
9+
type: "say",
10+
say: "api_req_started",
11+
text: JSON.stringify(data),
12+
})
13+
14+
describe("summarizeUsageByModel", () => {
15+
it("groups requests by the provider+model recorded on each request", () => {
16+
const result = summarizeUsageByModel([
17+
req({
18+
apiProvider: providerIdentifiers.githubCopilot,
19+
modelId: "claude-opus-5.5",
20+
tokensIn: 1000,
21+
tokensOut: 100,
22+
cost: 0,
23+
}),
24+
req({
25+
apiProvider: providerIdentifiers.githubCopilot,
26+
modelId: "claude-opus-5.5",
27+
tokensIn: 500,
28+
tokensOut: 50,
29+
cost: 0,
30+
}),
31+
req({
32+
apiProvider: providerIdentifiers.gemini,
33+
modelId: "gemini-3.1-pro-preview",
34+
tokensIn: 200,
35+
tokensOut: 20,
36+
cost: 0.4,
37+
}),
38+
])
39+
40+
expect(result).toEqual([
41+
{
42+
apiProvider: providerIdentifiers.githubCopilot,
43+
modelId: "claude-opus-5.5",
44+
requests: 2,
45+
tokensIn: 1500,
46+
tokensOut: 150,
47+
cost: 0,
48+
},
49+
{
50+
apiProvider: providerIdentifiers.gemini,
51+
modelId: "gemini-3.1-pro-preview",
52+
requests: 1,
53+
tokensIn: 200,
54+
tokensOut: 20,
55+
cost: 0.4,
56+
},
57+
])
58+
})
59+
60+
it("keeps a task that switched providers mid-way split by what actually served each request", () => {
61+
const result = summarizeUsageByModel([
62+
req({
63+
apiProvider: providerIdentifiers.githubCopilot,
64+
modelId: "claude-opus-5.5",
65+
tokensIn: 100,
66+
tokensOut: 10,
67+
}),
68+
req({ apiProvider: providerIdentifiers.gemini, modelId: "gemini-pro", tokensIn: 300, tokensOut: 30 }),
69+
])
70+
expect(result.map((r) => r.apiProvider)).toEqual([
71+
providerIdentifiers.githubCopilot,
72+
providerIdentifiers.gemini,
73+
])
74+
})
75+
76+
it("sums cache counters only when reported", () => {
77+
const [entry] = summarizeUsageByModel([
78+
req({
79+
apiProvider: providerIdentifiers.anthropic,
80+
modelId: "m",
81+
tokensIn: 1,
82+
tokensOut: 1,
83+
cacheReads: 5,
84+
cacheWrites: 2,
85+
}),
86+
req({ apiProvider: providerIdentifiers.anthropic, modelId: "m", tokensIn: 1, tokensOut: 1, cacheReads: 3 }),
87+
])
88+
expect(entry.cacheReads).toBe(8)
89+
expect(entry.cacheWrites).toBe(2)
90+
91+
const [bare] = summarizeUsageByModel([req({ apiProvider: "a", modelId: "m", tokensIn: 1, tokensOut: 1 })])
92+
expect(bare).not.toHaveProperty("cacheReads")
93+
})
94+
95+
it("puts requests recorded before this was tracked in an unrecorded entry rather than guessing", () => {
96+
const [entry] = summarizeUsageByModel([req({ apiProtocol: "openai", tokensIn: 10, tokensOut: 5, cost: 0 })])
97+
expect(entry).toMatchObject({ apiProvider: undefined, modelId: undefined, requests: 1, tokensIn: 10 })
98+
})
99+
100+
it("ignores in-flight placeholders and non-request messages", () => {
101+
const result = summarizeUsageByModel([
102+
req({ apiProtocol: "openai", apiProvider: providerIdentifiers.gemini, modelId: "m" }), // no counts yet
103+
{ ts: 2, type: "say", say: "text", text: "hello" },
104+
{ ts: 3, type: "say", say: "api_req_started", text: "not json" },
105+
{ ts: 4, type: "say", say: "api_req_started" },
106+
])
107+
expect(result).toEqual([])
108+
})
109+
110+
it("returns an empty list for no messages", () => {
111+
expect(summarizeUsageByModel([])).toEqual([])
112+
})
113+
})

‎src/core/task-persistence/taskMetadata.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ClineMessage, HistoryItem } from "@roo-code/types"
55

66
import { combineApiRequests } from "../../shared/combineApiRequests"
77
import { combineCommandSequences } from "../../shared/combineCommandSequences"
8+
import { summarizeUsageByModel } from "./usageByModel"
89
import { getApiMetrics } from "../../shared/getApiMetrics"
910
import { findLastIndex } from "../../shared/array"
1011
import { getTaskDirectoryPath } from "../../utils/storage"
@@ -89,6 +90,8 @@ export async function taskMetadata({
8990
}
9091
}
9192

93+
const usageByModel = hasMessages ? summarizeUsageByModel(messages) : []
94+
9295
// Create historyItem once with pre-calculated values.
9396
// initialStatus is included when provided (e.g., "active" for child tasks)
9497
// to ensure the status is set from the very first save, avoiding race conditions
@@ -107,6 +110,8 @@ export async function taskMetadata({
107110
cacheWrites: tokenUsage.totalCacheWrites,
108111
cacheReads: tokenUsage.totalCacheReads,
109112
totalCost: tokenUsage.totalCost,
113+
// Only emitted when something was recorded, so older tasks and empty tasks keep their exact shape.
114+
...(usageByModel.length > 0 ? { usageByModel } : {}),
110115
size: taskDirSize,
111116
workspace,
112117
mode,

0 commit comments

Comments
 (0)