feat: live multi-provider quota panel (#1278)

* feat: live provider quota panel (Claude + Codex)

Adds Claude and Codex plan usage to the context window percentage circle tooltip, querying their respective APIs directly using existing CLI auth tokens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: address review feedback - restore quota trigger, guard NaN date

- Re-add triggerFetch() on agent idle/error in agent status subscription
- Guard formatResetsAtLabel against NaN from malformed API date strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve composer conflict and stub subscribe in websocket tests

* feat(quota): add cursor, copilot, zai, grok, and kimi quota UI and improve type safety

* feat(quota): make Claude and Codex pluggable, and map additional quota/credits metrics

* security(quota): fix shell injection vulnerability by migrating exec to execFile

* revert: restore original scripts/dev.ps1 and packages/desktop/scripts/dev.ps1

* Fix i18n resource test expectation

* Fix quota tooltip empty-provider state

* Preserve zero values in Grok quota

* Fix empty quota fetch state

* Ignore quota frames in relay reconnect tests

* Persist refreshed Codex tokens to source auth file

* fix(quota): read Claude OAuth credentials from the macOS login Keychain

On macOS, Claude Code stores its OAuth credential in the login Keychain
(generic-password item, service "Claude Code-credentials"), not in
~/.claude/.credentials.json — that file usually does not exist there, so the
Claude provider's existsSync check failed and macOS users silently got no
Claude quota. Read it via the macOS `security` CLI (its ACL only trusts
/usr/bin/security to decrypt; a native Keychain read would prompt), and stay
read-only there since Claude Code owns the refresh/persist. Linux and Windows
keep using ~/.claude/.credentials.json unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix quota keychain timeout and local host reconciliation

* Fix terminal notification test quota stub

* Ignore quota frames in terminal notification tests

* fix(quota): bypass Claude token refresh on macOS Keychain-backed logins

* fix(dev): update local dev daemon port to 6768 on Windows scripts/dev.ps1

* fix(dev): bypass Unix dev-daemon.sh on Windows in dev.ps1

* fix(client): handle and dispatch provider_quota event in DaemonClient

* fix(desktop): fix PowerShell quote stripping in desktop dev.ps1 config seeding

* fix(desktop): execute config update via temp file to avoid PowerShell quoting bugs

* fix(desktop): fix concurrently command invocation on Windows in dev.ps1

* fix(desktop): resolve path escaping and environment setting syntax errors in dev.ps1 for Windows

* Add on-demand provider usage views

* Fix Cursor usage billing dates

* Move provider usage renderer coverage to e2e

* Use provider manifest for quota fetchers

* Add provider usage fetch timeouts

* Reshape provider usage fetchers

---------

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
Co-authored-by: lumingjun <lumingjun@bytedance.com>
This commit is contained in:
Aditya Borakati
2026-06-20 12:19:50 +08:00
committed by GitHub
co-authored by ABorakati Claude Sonnet 4.6 Mohamed Boudra lumingjun
parent 2b0740ff84
commit acea7f3d24
59 changed files with 3944 additions and 132 deletions
+87
View File
@@ -133,6 +133,93 @@ describe("workspace descriptor message compatibility", () => {
});
});
describe("provider usage list message contract", () => {
test("accepts the usage list request as a namespaced correlated RPC", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "provider.usage.list.request",
requestId: "usage-1",
});
expect(parsed).toEqual({
type: "provider.usage.list.request",
requestId: "usage-1",
});
});
test("accepts new providers and new usage windows as normalized data", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "provider.usage.list.response",
payload: {
requestId: "usage-2",
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
fetchedAt: "2026-06-19T00:00:00.000Z",
windows: [
{
id: "biweekly",
label: "Biweekly",
usedPct: 23,
remainingPct: 77,
resetsAt: "2026-07-03T00:00:00.000Z",
tone: "ok",
},
],
balances: [
{
id: "credits",
label: "Credits",
remaining: 120,
unit: "credits",
},
],
details: [{ id: "region", label: "Region", value: "US" }],
error: null,
},
],
},
});
expect(parsed.type).toBe("provider.usage.list.response");
if (parsed.type !== "provider.usage.list.response") {
throw new Error("Expected provider.usage.list.response");
}
expect(parsed.payload.providers[0]?.providerId).toBe("glm");
expect(parsed.payload.providers[0]?.windows[0]?.label).toBe("Biweekly");
});
test("keeps protocol numbers strict after API boundary normalization", () => {
const parsed = SessionOutboundMessageSchema.safeParse({
type: "provider.usage.list.response",
payload: {
requestId: "usage-3",
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [
{
id: "session",
label: "Session",
usedPct: "7",
},
],
},
],
},
});
expect(parsed.success).toBe(false);
});
});
describe("agent detach RPC", () => {
test("parses the namespaced detach request", () => {
const parsed = SessionInboundMessageSchema.parse({
+73
View File
@@ -1201,6 +1201,11 @@ export const ProviderDiagnosticRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ProviderUsageListRequestMessageSchema = z.object({
type: z.literal("provider.usage.list.request"),
requestId: z.string(),
});
export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema,
@@ -2029,6 +2034,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
ProviderDiagnosticRequestMessageSchema,
ProviderUsageListRequestMessageSchema,
ResumeAgentRequestMessageSchema,
ImportAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
@@ -2300,6 +2306,8 @@ export const ServerInfoStatusPayloadSchema = z
projectRemove: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(),
})
@@ -3830,6 +3838,61 @@ export const ProviderDiagnosticResponseMessageSchema = z.object({
}),
});
export const ProviderUsageToneSchema = z.enum(["default", "ok", "warning", "danger"]);
export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "error"]);
export const ProviderUsageWindowSchema = z.object({
id: z.string(),
label: z.string(),
usedPct: z.number().nullable().optional(),
remainingPct: z.number().nullable().optional(),
resetsAt: z.string().nullable().optional(),
runsOutAt: z.string().nullable().optional(),
shortfallPct: z.number().nullable().optional(),
tone: ProviderUsageToneSchema.optional(),
});
export const ProviderUsageBalanceSchema = z.object({
id: z.string(),
label: z.string(),
used: z.number().nullable().optional(),
remaining: z.number().nullable().optional(),
limit: z.number().nullable().optional(),
unit: z.enum(["usd", "credits", "requests", "tokens"]),
resetsAt: z.string().nullable().optional(),
tone: ProviderUsageToneSchema.optional(),
});
export const ProviderUsageDetailSchema = z.object({
id: z.string(),
label: z.string(),
value: z.string(),
tone: ProviderUsageToneSchema.optional(),
});
export const ProviderUsageSchema = z.object({
providerId: z.string(),
displayName: z.string(),
status: ProviderUsageStatusSchema,
planLabel: z.string().nullable(),
sourceLabel: z.string().nullable().optional(),
fetchedAt: z.string().nullable().optional(),
nextRefreshAt: z.string().nullable().optional(),
windows: z.array(ProviderUsageWindowSchema),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
error: z.string().nullable().optional(),
});
export const ProviderUsageListResponseMessageSchema = z.object({
type: z.literal("provider.usage.list.response"),
payload: z.object({
requestId: z.string(),
fetchedAt: z.string(),
providers: z.array(ProviderUsageSchema),
}),
});
const AgentSlashCommandSchema = z.object({
name: z.string(),
description: z.string(),
@@ -4093,6 +4156,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProvidersSnapshotUpdateMessageSchema,
RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema,
ProviderUsageListResponseMessageSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
TerminalsChangedSchema,
@@ -4228,6 +4292,15 @@ export type RefreshProvidersSnapshotResponseMessage = z.infer<
export type ProviderDiagnosticResponseMessage = z.infer<
typeof ProviderDiagnosticResponseMessageSchema
>;
export type ProviderUsageTone = z.infer<typeof ProviderUsageToneSchema>;
export type ProviderUsageStatus = z.infer<typeof ProviderUsageStatusSchema>;
export type ProviderUsage = z.infer<typeof ProviderUsageSchema>;
export type ProviderUsageWindow = z.infer<typeof ProviderUsageWindowSchema>;
export type ProviderUsageBalance = z.infer<typeof ProviderUsageBalanceSchema>;
export type ProviderUsageDetail = z.infer<typeof ProviderUsageDetailSchema>;
export type ProviderUsageListResponseMessage = z.infer<
typeof ProviderUsageListResponseMessageSchema
>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;