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:
co-authored by
ABorakati
Claude Sonnet 4.6
Mohamed Boudra
lumingjun
parent
2b0740ff84
commit
acea7f3d24
@@ -0,0 +1,100 @@
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { ProviderUsage } from "../../../server/messages.js";
|
||||
import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js";
|
||||
import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js";
|
||||
|
||||
const KimiUsageResponseSchema = z.object({
|
||||
usage: z
|
||||
.object({
|
||||
limit: ApiOptionalStringSchema,
|
||||
remaining: ApiOptionalStringSchema,
|
||||
resetTime: ApiOptionalStringSchema,
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
const KimiAuthSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
});
|
||||
|
||||
interface KimiQuotaProviderOptions {
|
||||
logger: Logger;
|
||||
fetch?: ProviderApiFetch;
|
||||
}
|
||||
|
||||
export class KimiQuotaProvider implements ProviderUsageFetcher {
|
||||
readonly providerId = "kimi";
|
||||
readonly displayName = "Kimi";
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly fetchApi: ProviderApiFetch;
|
||||
|
||||
constructor(options: KimiQuotaProviderOptions) {
|
||||
this.logger = options.logger;
|
||||
this.fetchApi = options.fetch ?? fetch;
|
||||
}
|
||||
|
||||
async fetchUsage(): Promise<ProviderUsage> {
|
||||
const token =
|
||||
process.env["KIMI_TOKEN"] || process.env["KIMI_API_KEY"] || (await this.readKimiToken());
|
||||
|
||||
if (!token) return unavailableUsage(this);
|
||||
|
||||
const res = await fetchProviderApi(this.fetchApi, "https://api.kimi.com/coding/v1/usages", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
this.logger.debug({ status: res.status }, "Kimi usage fetch failed");
|
||||
return unavailableUsage(this);
|
||||
}
|
||||
|
||||
const resp = KimiUsageResponseSchema.parse(await res.json());
|
||||
const limit = resp.usage?.limit === undefined ? null : Number(resp.usage.limit);
|
||||
const remaining = resp.usage?.remaining === undefined ? null : Number(resp.usage.remaining);
|
||||
const hasFiniteLimit = typeof limit === "number" && Number.isFinite(limit) && limit > 0;
|
||||
const hasFiniteRemaining = typeof remaining === "number" && Number.isFinite(remaining);
|
||||
const usedPct =
|
||||
hasFiniteLimit && hasFiniteRemaining
|
||||
? Math.max(0, Math.min(100, ((limit - remaining) / limit) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId: this.providerId,
|
||||
displayName: this.displayName,
|
||||
status: "available",
|
||||
planLabel: null,
|
||||
windows: [
|
||||
{
|
||||
id: "coding_usage",
|
||||
label: "Coding usage",
|
||||
usedPct,
|
||||
remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct),
|
||||
resetsAt: resp.usage?.resetTime ?? null,
|
||||
tone: "ok",
|
||||
},
|
||||
],
|
||||
balances: [],
|
||||
details: [],
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readKimiToken(): Promise<string | null> {
|
||||
const path = join(homedir(), ".kimi", "credentials", "kimi-code.json");
|
||||
if (!existsSync(path)) return null;
|
||||
try {
|
||||
const credentials = KimiAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8")));
|
||||
return credentials.access_token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user