From 307714b0511cdcf11d65ae13ac3dbf06f26d986d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Galliano?= Date: Fri, 21 Aug 2026 11:21:25 -0600 Subject: [PATCH 1/2] feat(quota): add live commandcode-quota dashboard and OMP auth fix Fetches live account quota from Command Code alpha usage endpoints (whoami, billing/credits, billing/subscriptions, usage/summary) and renders a plain-text dashboard via ui.notify. - Graceful degradation: optional endpoint transport/timeout/parse failures degrade to null sections instead of aborting; only 401/403 are hard failures. 429 is transient, not fatal. - Overall deadline bounds the whole command to QUOTA_TIMEOUT_MS (per-request controllers are chained; post-deadline phases fail fast). - OMP auth: filter unresolved $COMMANDCODE_API_KEY placeholder and fall back to the host resolver via pickCommandCodeApiKey. - ZDR privacy header respected on quota requests. - Redaction reuses redactCommandCodeErrorText plus JSON-quoted credential fields; outer-catch errors are redacted too. - resetAt parsed from seconds, ms, numeric string, or ISO string. - 21 hermetic unit tests wired into npm test (test:quota). --- CHANGELOG.md | 4 + README.md | 3 + index.ts | 49 +++ package.json | 3 +- src/converters.ts | 23 ++ src/quota.ts | 613 +++++++++++++++++++++++++++++++++++ tests/test-omp-compat.mjs | 8 +- tests/test-pure-functions.ts | 33 +- tests/test-quota.ts | 395 ++++++++++++++++++++++ 9 files changed, 1128 insertions(+), 3 deletions(-) create mode 100644 src/quota.ts create mode 100644 tests/test-quota.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3cd9b..81128f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Reformat `/commandcode-quota` output into a dashboard layout: credits remaining/used with a percentage, monthly/purchased/free sources, plan, month-to-date cost/requests/tokens, and API key name, while keeping the 5-hour/weekly usage windows and full-detail link. +- Optionally show an aggregate token count and API key name when the usage endpoints report them. +- Fix `/commandcode-quota` on Oh My Pi: OMP surfaces an unresolved `$COMMANDCODE_API_KEY` placeholder through the model registry, which previously caused a 401 on the quota endpoints. The command now filters placeholders and falls back to the env/auth-file key resolver, matching the stream path. +- Fix `/commandcode-quota` usage windows so the 5-hour and weekly limits actually render: the API reports `windowLimits` as a top-level sibling of `credits` (not nested inside it), and its `resetAt` is in milliseconds, not seconds. Both are now parsed correctly, giving real "resets in …" countdowns instead of omitting the section or showing a huge day count. - Prefer Command Code's Provider API (`/provider/v1/chat/completions` and `/provider/v1/messages`) and automatically fall back to the existing `/alpha/generate` transport only when the Provider API returns `403 upgrade_required` for a Go-plan account. - Remember the detected transport for the running process, re-detect it when credentials change, prevent stale in-flight requests from overwriting the new credential's transport, and never fall back for unrelated authentication, permission, rate-limit, network, or server failures. - Use Pi's native OpenAI- and Anthropic-compatible providers for Provider API streaming, including adaptive thinking for current reasoning-capable Claude models, while preserving the existing hardened generate transport, dynamic model discovery, offline cache, refresh/status commands, pricing, and OAuth credentials. diff --git a/README.md b/README.md index 4b32189..b140206 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,9 @@ While pi is running, use these provider commands without restarting: - `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. - `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. +- `/commandcode-quota` shows your Command Code account usage and quota in a dashboard-style layout: credits remaining and used with a percentage, monthly/purchased/free sources, the current plan, month-to-date cost/requests/tokens, the API key name, and the 5-hour and weekly usage windows. + +The `commandcode-quota` command reads from the Command Code alpha usage endpoints (the same ones the `cmd` CLI `/usage` command uses): `whoami`, `billing/credits`, `billing/subscriptions`, and `usage/summary`. It authenticates with the same API key the provider already uses. If command cannot reach those endpoints or they change, the command reports a readable error instead of failing. Output is plain text (via `ui.notify`) so it works across pi and compatible hosts such as OMP. Set `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. diff --git a/index.ts b/index.ts index 72ae558..be7891f 100644 --- a/index.ts +++ b/index.ts @@ -18,6 +18,7 @@ import { join } from "node:path" import { getConfiguredApiKey } from "./src/api-key.ts" import { createStreamCommandCode } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" +import { pickCommandCodeApiKey } from "./src/converters.ts" import { baseUrlForModel, DEFAULT_MODELS_URL, @@ -32,8 +33,19 @@ import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts import { normalizeCommandCodeMessage } from "./src/overflow.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" +import { fetchCommandCodeQuota, formatQuota, redactValue } from "./src/quota.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts" +const COMMAND_CODE_PROVIDER_ID = "commandcode" + +async function resolveCommandCodeApiKey(ctx: ExtensionCommandContext): Promise { + const registryKey = await ctx.modelRegistry?.getApiKeyForProvider?.(COMMAND_CODE_PROVIDER_ID) + // Mirror src/core.ts: OMP may surface an unresolved placeholder; fall back + // to the env/auth-file resolver so we never send a literal placeholder as a + // Bearer token (which caused a 401 on /alpha/whoami). + return pickCommandCodeApiKey(registryKey, getConfiguredApiKey()) +} + function commandCodeHeaders(): Record | undefined { if (process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } @@ -118,6 +130,43 @@ export default async function (pi: ExtensionAPI) { return normalized ? { message: normalized.message } : undefined }) + pi.registerCommand("commandcode-quota", { + description: "Show Command Code account usage and quota", + handler: async (_args, ctx) => { + await ctx.waitForIdle?.() + + // Resolve the key in a host-agnostic way so the command also works on + // OMP (which passes an unresolved "$COMMANDCODE_API_KEY" placeholder + // through the registry): filter placeholders and fall back to the + // env/auth-file resolver, mirroring src/core.ts. + const apiKey = await resolveCommandCodeApiKey(ctx) + if (!apiKey) { + ctx.ui.notify( + "Command Code quota requires an API key. Run /login and select Command Code, or set the COMMANDCODE_API_KEY env var.", + "warning", + ) + return + } + + const result = await fetchCommandCodeQuota({ + apiKey, + // Alpha endpoints live under the legacy base (no /provider/v1), + // same as the fallback generate transport. + baseUrl: legacyApiBase(apiBase), + // Respect the user's zero-data-retention preference on usage/account + // calls too, matching the provider stream path. + extraHeaders: commandCodeHeaders(), + }) + + if (!result.ok) { + ctx.ui.notify(redactValue(result.error.message), "error") + return + } + + ctx.ui.notify(formatQuota(result.quota), "info") + }, + }) + const runtime = createCommandCodeRuntime(pi, { endpoint: modelsUrl, cachePath: modelsCachePath, diff --git a/package.json b/package.json index f284d3b..e00b16f 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,13 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "pi:isolated": "node scripts/pi-isolated.mjs", "pi:authenticated": "node scripts/pi-authenticated.mjs", + "test:quota": "tsx tests/test-quota.ts", "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts", "test:api-key": "tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", diff --git a/src/converters.ts b/src/converters.ts index 4012fbb..bb60df2 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -138,6 +138,29 @@ export function getApiKey( return undefined } +// Hosts such as OMP may pass the literal env-var name "$COMMANDCODE_API_KEY" +// (or "COMMANDCODE_API_KEY") as the "resolved" registry key instead of the +// actual credential. Treat those as unresolved. +export const COMMAND_CODE_PLACEHOLDER_KEYS = new Set([ + "$COMMANDCODE_API_KEY", + "COMMANDCODE_API_KEY", +]) + +/** + * Pick the real API key from a host registry value and/or the env/auth-file + * fallback, never returning a literal placeholder or an empty/whitespace value. + * Pure/testable. + */ +export function pickCommandCodeApiKey( + registryKey: string | undefined, + hostKey: string | undefined, +): string | undefined { + const trimmed = typeof registryKey === "string" ? registryKey.trim() : undefined + if (!trimmed) return hostKey + if (COMMAND_CODE_PLACEHOLDER_KEYS.has(trimmed)) return hostKey + return trimmed +} + export function textContent(message: { content?: unknown }): string { return recordArray(message.content) .filter((part) => part.type === "text") diff --git a/src/quota.ts b/src/quota.ts new file mode 100644 index 0000000..15a0263 --- /dev/null +++ b/src/quota.ts @@ -0,0 +1,613 @@ +/** + * Command Code usage/quota fetch layer for the `/commandcode-quota` command. + * + * Command Code exposes account usage through a set of authenticated alpha + * endpoints (the same ones the `cmd` CLI `/usage` command uses): + * + * - `/alpha/whoami` -> resolved account + optional org id + * - `/alpha/billing/credits` -> monthly/purchased/free credits + window limits + * - `/alpha/billing/subscriptions`-> plan id, status, billing period + * - `/alpha/usage/summary` -> period totals (cost, request count, optional tokens) + * + * These endpoints are not part of the documented public Provider API + * (`/provider/v1/*`) but are shipped with every `command-code` CLI release and + * authenticate with the same API key the provider already uses. Fetches are + * wrapped defensively so the quota command degrades to a readable error rather + * than surfacing raw transport details. + */ + +import { redactCommandCodeErrorText } from "./overflow.ts" + +export const DEFAULT_API_BASE = "https://api.commandcode.ai" + +export const QUOTA_TIMEOUT_MS = 15_000 + +/** + * A single rolling usage window (the 5-hour or weekly cap on a plan's monthly + * credits). Values are measured in credit value, not request count. + */ +export interface CommandCodeWindowLimit { + window: "fiveHour" | "weekly" + used: number + cap: number + /** Unix epoch seconds when this window resets, normalized from seconds or ms. */ + resetAt: number | null +} + +/** Credits exposed by the `/alpha/billing/credits` endpoint. */ +export interface CommandCodeCredits { + monthlyCredits: number + purchasedCredits: number + freeCredits: number + remainingCredits: number + windowLimits: CommandCodeWindowLimit[] +} + +/** Subscription/plan info exposed by `/alpha/billing/subscriptions`. */ +export interface CommandCodeSubscription { + planId: string | null + status: string | null + currentPeriodStart: string | null + currentPeriodEnd: string | null +} + +/** Period totals exposed by `/alpha/usage/summary`. */ +export interface CommandCodeUsageSummary { + totalCost: number + totalCount: number + /** Optional aggregate token count; only shown when the endpoint reports it. */ + totalTokens?: number +} + +/** Fully normalized quota snapshot for display. */ +export interface CommandCodeQuota { + account: { + login: string + orgId: string | null + /** Optional API key / account display name; falls back to login. */ + keyName?: string + } + credits: CommandCodeCredits | null + subscription: CommandCodeSubscription | null + summary: CommandCodeUsageSummary | null +} + +export type CommandCodeQuotaResult = + | { ok: true; quota: CommandCodeQuota } + | { ok: false; error: { message: string; kind: "config" | "http" | "network" | "timeout" } } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function numberValue(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined + return value >= 0 ? value : undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined +} + +interface FetchOptions { + apiKey: string + baseUrl?: string + fetchImpl?: typeof fetch + timeoutMs?: number + /** Extra HTTP headers merged after Content-Type/Authorization (e.g. ZDR). */ + extraHeaders?: Record +} + +function buildUrl(path: string, params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") search.set(key, value) + } + const query = search.toString() + return `${path}${query ? `?${query}` : ""}` +} + +/** Extract the WindowLimit array from the top-level `windowLimits` object. */ +export function windowLimitsFromCredits(value: unknown): CommandCodeWindowLimit[] { + if (!isRecord(value)) return [] + const limits: CommandCodeWindowLimit[] = [] + + for (const [window, entry] of [ + ["fiveHour", value.fiveHour], + ["weekly", value.weekly], + ] as const) { + if (!isRecord(entry)) continue + const used = numberValue(entry.used) ?? 0 + const cap = numberValue(entry.cap) ?? 0 + if (cap <= 0 && used <= 0) continue + limits.push({ + window, + used, + cap, + resetAt: normalizeResetAt(entry.resetAt), + }) + } + + return limits +} + +/** + * Normalize a `resetAt` value to epoch seconds. Accepts seconds (10-digit), + * milliseconds (13-digit, live API), a numeric string, or an ISO timestamp + * string, then converts ms -> s consistently. Invalid/negative values -> null. + */ +function normalizeResetAt(value: unknown): number | null { + let num: number | undefined + if (typeof value === "number" && Number.isFinite(value)) { + num = value + } else if (typeof value === "string" && value.length > 0) { + const trimmed = value.trim() + if (/^\d+$/.test(trimmed)) { + num = Number(trimmed) + } else { + const parsed = Date.parse(trimmed) + if (!Number.isNaN(parsed)) num = Math.round(parsed / 1000) + } + } + if (num === undefined || num < 0) return null + return num >= 1e12 ? Math.round(num / 1000) : num +} + +function parseCredits(value: unknown): CommandCodeCredits | null { + const credits = isRecord(value) ? value.credits : undefined + if (!isRecord(credits)) return null + + // `windowLimits` is a top-level sibling of `credits` in the + // `/alpha/billing/credits` response, not nested inside it. + const windowLimits = isRecord(value) ? value.windowLimits : undefined + + const monthlyCredits = numberValue(credits.monthlyCredits) ?? 0 + const purchasedCredits = numberValue(credits.purchasedCredits) ?? 0 + const freeCredits = numberValue(credits.freeCredits) ?? 0 + + return { + monthlyCredits, + purchasedCredits, + freeCredits, + remainingCredits: monthlyCredits + purchasedCredits + freeCredits, + windowLimits: windowLimitsFromCredits(windowLimits), + } +} + +function parseSubscription(value: unknown): CommandCodeSubscription | null { + const data = isRecord(value) ? value.data : undefined + if (!isRecord(data)) return null + + return { + planId: stringValue(data.planId) ?? null, + status: stringValue(data.status) ?? null, + currentPeriodStart: stringValue(data.currentPeriodStart) ?? null, + currentPeriodEnd: stringValue(data.currentPeriodEnd) ?? null, + } +} + +function parseSummary(value: unknown): CommandCodeUsageSummary | null { + if (!isRecord(value)) return null + const totalTokens = numberValue(value.totalTokens) ?? numberValue(value.tokens) + return { + totalCost: numberValue(value.totalCost) ?? 0, + totalCount: numberValue(value.totalCount) ?? 0, + ...(totalTokens === undefined ? {} : { totalTokens }), + } +} + +function parseWhoami(value: unknown): { + login: string + orgId: string | null + keyName?: string +} { + const org = isRecord(value) ? value.org : undefined + const user = isRecord(value) ? value.user : undefined + + const orgLogin = isRecord(org) ? stringValue(org.login) : undefined + const orgId = isRecord(org) ? stringValue(org.id) : undefined + const userLogin = + (isRecord(user) ? stringValue(user.userName) : undefined) ?? + (isRecord(user) ? stringValue(user.name) : undefined) + + const keyName = + stringValue(isRecord(user) ? user.keyName : undefined) ?? + stringValue(isRecord(user) ? user.displayName : undefined) + + return { + login: orgLogin ?? userLogin ?? "Unknown account", + orgId: orgId ?? null, + ...(keyName === undefined ? {} : { keyName }), + } +} + +/** + * Parse the `windowLimits` into a human-readable, header-safe line list that + * the formatting layer appends. Split out so the pure shape is independently + * testable. + */ +export function formatWindowLimits( + limits: readonly CommandCodeWindowLimit[], + now: () => number = Date.now, +): string[] { + const labels: Record = { + fiveHour: "5-hour", + weekly: "Weekly", + } + + return limits.map((limit) => { + const label = labels[limit.window] ?? limit.window + const used = limit.used.toFixed(2) + const cap = limit.cap.toFixed(2) + const pct = limit.cap > 0 ? Math.round((limit.used / limit.cap) * 100) : 0 + const reset = limit.resetAt === null ? "" : ` (resets ${formatResetClock(limit.resetAt, now)})` + return `${label}: ${used} / ${cap} credits (${pct}% used)${reset}` + }) +} + +function formatResetClock(resetAtSeconds: number, now: () => number = Date.now): string { + const date = new Date(resetAtSeconds * 1000) + if (Number.isNaN(date.getTime())) return "unknown" + const nowMs = now() + const diffMs = date.getTime() - nowMs + if (diffMs <= 0) return "soon" + const minutes = Math.ceil(diffMs / 60_000) + if (minutes < 60) return `in ${minutes}m` + const hours = Math.floor(minutes / 60) + const rem = minutes % 60 + if (hours < 24) return rem > 0 ? `in ${hours}h ${rem}m` : `in ${hours}h` + const days = Math.floor(hours / 24) + return days === 1 ? "in 1 day" : `in ${days} days` +} + +/** Derived credits view for the Remaining/Used layout. */ +interface CreditView { + /** Credits remaining (monthly + purchased + free). */ + remaining: number + /** Dollars spent this period (totalCost). */ + spent: number + /** Total pool used as the percentage denominator: remaining + spent. */ + pool: number + /** Percent of the pool used, 0-100. */ + usedPercent: number + hasCreditsInfo: boolean +} + +function creditView(quota: CommandCodeQuota): CreditView { + const credits = quota.credits + const remaining = credits ? credits.remainingCredits : 0 + const spent = quota.summary?.totalCost ?? 0 + const pool = remaining + spent + const hasCreditsInfo = Boolean(credits) || spent > 0 + return { + remaining, + spent, + pool, + usedPercent: hasCreditsInfo ? Math.round((pool > 0 ? spent / pool : 0) * 100) : 0, + hasCreditsInfo, + } +} + +function creditDetailLine(credits: CommandCodeCredits | null): string { + if (!credits) return "" + const parts = [`monthly $${credits.monthlyCredits.toFixed(2)}`] + parts.push(`purchased $${credits.purchasedCredits.toFixed(2)}`) + if (credits.freeCredits > 0) parts.push(`free $${credits.freeCredits.toFixed(2)}`) + return `Sources: ${parts.join(" / ")}` +} + +function subscriptionLine(subscription: CommandCodeSubscription): string { + const rank = subscription.planId ?? "Unknown" + const plan = rank.replace(/[_-]+/g, " ").trim() + const status = subscription.status ? ` (${subscription.status})` : "" + return `Plan: ${plan}${status}` +} + +function accountName(account: CommandCodeQuota["account"]): string { + return account.keyName ?? account.login +} + +/** + * Render a normalized quota snapshot as clean, aligned, dashboard-style text + * suitable for `ui.notify`. Pure so it can be unit tested without a runtime. + */ +export function formatQuota(quota: CommandCodeQuota, now: () => number = Date.now): string { + const lines: string[] = [] + + const credit = creditView(quota) + if (credit.hasCreditsInfo) { + lines.push("") + lines.push("Credits") + lines.push(padValue(`Remaining: $${credit.remaining.toFixed(2)} of $${credit.pool.toFixed(2)}`)) + lines.push(padValue(`Used: $${credit.spent.toFixed(2)}`)) + lines.push(` ${credit.usedPercent}% used`) + } + + const detail = creditDetailLine(quota.credits) + if (detail) lines.push(detail) + + if (quota.subscription) lines.push(subscriptionLine(quota.subscription)) + + if (quota.summary) { + lines.push("") + lines.push("Usage (this month)") + lines.push(padValue(`Cost: $${quota.summary.totalCost.toFixed(2)}`)) + lines.push(padValue(`Requests: ${quota.summary.totalCount.toLocaleString("en-US")}`)) + if (quota.summary.totalTokens && quota.summary.totalTokens > 0) { + lines.push(padValue(`Tokens: ${formatTokens(quota.summary.totalTokens)}`)) + } + } + + lines.push("") + lines.push("Username") + lines.push(padValue(accountName(quota.account))) + + const limits = quota.credits?.windowLimits ?? [] + if (limits.length > 0) { + lines.push("") + lines.push("Usage windows:") + lines.push(...formatWindowLimits(limits, now).map((line) => ` ${line}`)) + } + + lines.push("") + lines.push(`Full detail: https://commandcode.ai/usage`) + + // Trim leading/trailing blank lines so sections stay cleanly separated. + while (lines.length > 0 && lines[0].length === 0) lines.shift() + while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop() + return lines.join("\n") +} + +function padValue(value: string): string { + return ` ${value}` +} + +function formatTokens(tokens: number): string { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B` + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` + return String(tokens) +} + +/** + * Fetch the current account quota from Command Code. + * + * Resolution chain: whoami -> org id -> credits + subscription (parallel) -> + * summary (needs the billing period start). Any individual endpoint failing + * degrades gracefully: the remaining data is still reported, and a hard + * failure (auth/config, network) is surfaced as a typed error. + */ +export async function fetchCommandCodeQuota( + options: FetchOptions, +): Promise { + if (!options.apiKey) { + return { + ok: false, + error: { message: "No Command Code API key found", kind: "config" }, + } + } + + const baseUrl = options.baseUrl ?? DEFAULT_API_BASE + const fetchImpl = options.fetchImpl ?? fetch + const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${options.apiKey}`, + ...options.extraHeaders, + } + + // One overall deadline shared across the sequential phases (whoami -> billing + // -> summary) so a slow or blackholed dependency cannot compound per-request + // timeouts into a ~45s stall; the command reports within QUOTA_TIMEOUT_MS. + const overallController = new AbortController() + const overallTimer = setTimeout(() => overallController.abort(), timeoutMs) + + const request = async (path: string): Promise => { + // The overall deadline may already have fired (e.g. a prior phase consumed + // the budget) — AbortSignal does not replay past abort events to listeners + // added afterward, so check synchronously instead of relying on the listener. + if (overallController.signal.aborted) { + throw new QuotaTimeoutError() + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const onOverallAbort = () => controller.abort() + overallController.signal.addEventListener("abort", onOverallAbort) + try { + const response = await fetchImpl(`${baseUrl}${path}`, { + method: "GET", + headers, + signal: controller.signal, + }) + + if (!response.ok) { + let message = response.statusText + if (response.status === 401 || response.status === 403) { + message = "Command Code rejected the API key (401/403)" + } + return { + __httpError: true, + message, + status: response.status, + body: await response.text().catch(() => ""), + } + } + return await response.json() + } catch (error) { + if (controller.signal.aborted) { + throw new QuotaTimeoutError() + } + throw error + } finally { + clearTimeout(timer) + overallController.signal.removeEventListener("abort", onOverallAbort) + } + } + + /** Optional-endpoint wrapper: never throws. Transport/timeout/parse failures + * become a sentinel so the rest of the dashboard still renders, matching the + * existing graceful degradation for HTTP 5xx responses. */ + const safeRequest = async (path: string): Promise => { + try { + return await request(path) + } catch (error) { + return { + __quotaError: true, + message: errorMessage(error), + kind: error instanceof QuotaTimeoutError ? "timeout" : "network", + } + } + } + + try { + const whoami = await request("/alpha/whoami") + if (isHttpError(whoami)) return httpFailure(whoami, "whoami") + const account = parseWhoami(whoami) + + const orgId = account.orgId ?? undefined + const creditsPath = buildUrl("/alpha/billing/credits", { orgId }) + const subPath = buildUrl("/alpha/billing/subscriptions", { orgId }) + + const [creditsRaw, subRaw] = await Promise.all([safeRequest(creditsPath), safeRequest(subPath)]) + + // Hard auth/permission failures abort; everything else (including thrown + // network/timeout/parse failures) degrades to a null section. + if (isHttpError(creditsRaw) && isBlockingQuotaHttpError(creditsRaw)) { + return httpFailure(creditsRaw, "credits") + } + if (isHttpError(subRaw) && isBlockingQuotaHttpError(subRaw)) { + return httpFailure(subRaw, "subscription") + } + + const credits = + creditsRaw && !isHttpError(creditsRaw) && !isQuotaError(creditsRaw) + ? parseCredits(creditsRaw) + : null + const subscription = + subRaw && !isHttpError(subRaw) && !isQuotaError(subRaw) ? parseSubscription(subRaw) : null + + const since = subscription?.currentPeriodStart ?? undefined + const summaryPath = buildUrl("/alpha/usage/summary", { orgId, since }) + const summaryRaw = await safeRequest(summaryPath) + if (isHttpError(summaryRaw) && isBlockingQuotaHttpError(summaryRaw)) { + return httpFailure(summaryRaw, "summary") + } + const summary = + summaryRaw && !isHttpError(summaryRaw) && !isQuotaError(summaryRaw) + ? parseSummary(summaryRaw) + : null + + if (credits === null && subscription === null && summary === null) { + if (overallController.signal.aborted) { + return { + ok: false, + error: { message: "Command Code quota request timed out", kind: "timeout" }, + } + } + return { + ok: false, + error: { + message: "Command Code returned no usage data for the account", + kind: "http", + }, + } + } + + return { + ok: true, + quota: { account, credits, subscription, summary }, + } + } catch (error) { + if (error instanceof QuotaTimeoutError || overallController.signal.aborted) { + return { + ok: false, + error: { message: "Command Code quota request timed out", kind: "timeout" }, + } + } + return { + ok: false, + error: { + message: redactValue(`Failed to fetch Command Code quota: ${errorMessage(error)}`), + kind: "network", + }, + } + } finally { + clearTimeout(overallTimer) + } +} + +class QuotaTimeoutError extends Error { + constructor() { + super("Command Code quota request timed out") + this.name = "QuotaTimeoutError" + } +} + +interface HttpErrorShape { + __httpError: true + message: string + status: number + body: string +} + +function isHttpError(value: unknown): value is HttpErrorShape { + if (!isRecord(value) || value.__httpError !== true) return false + return ( + typeof value.status === "number" && + typeof value.message === "string" && + typeof value.body === "string" + ) +} + +/** Sentinel produced by safeRequest for thrown transport/timeout/parse failures. */ +interface QuotaErrorShape { + __quotaError: true + message: string + kind: "timeout" | "network" +} + +function isQuotaError(value: unknown): value is QuotaErrorShape { + if (!isRecord(value) || value.__quotaError !== true) return false + return typeof value.message === "string" && (value.kind === "timeout" || value.kind === "network") +} + +/** + * Hard-failure HTTP statuses for the quota dashboard: authentication and + * permission failures. Rate limiting (429) is deliberately NOT included — it + * is a transient dependency condition that should degrade like other non-auth + * endpoint failures, not abort the whole command. + */ +function isBlockingQuotaHttpError(error: HttpErrorShape): boolean { + return error.status === 401 || error.status === 403 +} + +function httpFailure(error: HttpErrorShape, context: string): CommandCodeQuotaResult { + const detail = error.body.trim().slice(0, 200) + const message = detail + ? `${context} request failed (${error.status}): ${detail}` + : `${context} request failed (${error.status}): ${error.message}` + return { + ok: false, + error: { message: redactValue(message), kind: "http" }, + } +} + +/** Best-effort scrub of values that look like tokens/secrets from a message. */ +export function redactValue(value: string): string { + // Reuse the broader Command Code redaction (Bearer, credential key-value + // fields, user_/cc_ tokens, query-string secrets, standalone keys) so quota + // errors get the same protection as stream errors. Additionally catch + // JSON-quoted credential fields ({"apiKey":"..."}) that the upstream pattern + // requires to be adjacent to `=`/`:`. + return redactCommandCodeErrorText(value) + .replace( + /("\s*(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*"\s*:\s*")([^"]{8,})/gi, + "$1[redacted]", + ) + .trim() +} diff --git a/tests/test-omp-compat.mjs b/tests/test-omp-compat.mjs index 9daae39..eeb5793 100644 --- a/tests/test-omp-compat.mjs +++ b/tests/test-omp-compat.mjs @@ -165,7 +165,13 @@ function runOmp(args, timeoutMs = 30_000) { try { console.log("[omp-compat] list models through real extension") modelListRequestCount = 0 - const result = await runOmp(["-e", EXT_PATH, "--list-models"]) + // Prefer the flag form `omp -e EXT --list-models`; Homebrew's `omp` + // distribution only exposes the `omp models` subcommand, so fall back to + // that form when the flag invocation is not recognized. + let result = await runOmp(["-e", EXT_PATH, "--list-models"]) + if (result.code !== 0) { + result = await runOmp(["models", "-e", EXT_PATH]) + } assert.equal(result.code, 0, result.stderr) const listOutput = result.stdout || result.stderr assert.match(listOutput, /commandcode/) diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 136025f..d71dba0 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -16,6 +16,7 @@ import { mapFinishReason, messagesToCC, parseStreamEventLine, + pickCommandCodeApiKey, projectSlugFromPath, textContent, toJsonSchema, @@ -106,6 +107,36 @@ describe("error redaction", () => { }) }) +describe("pickCommandCodeApiKey()", () => { + it("falls back to the host key for a placeholder registry value", () => { + assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", "file-key"), "file-key") + assert.equal(pickCommandCodeApiKey("COMMANDCODE_API_KEY", "file-key"), "file-key") + }) + + it("returns undefined when only a placeholder is provided (no fallback)", () => { + assert.equal(pickCommandCodeApiKey("$COMMANDCODE_API_KEY", undefined), undefined) + }) + + it("prefers a real registry key over the host fallback", () => { + assert.equal(pickCommandCodeApiKey("real-registry-key", "file-key"), "real-registry-key") + }) + + it("falls back to the host key when the registry has none", () => { + assert.equal(pickCommandCodeApiKey(undefined, "file-key"), "file-key") + assert.equal(pickCommandCodeApiKey(undefined, undefined), undefined) + }) + + it("falls back to the host key for empty or whitespace registry values", () => { + assert.equal(pickCommandCodeApiKey("", "file-key"), "file-key") + assert.equal(pickCommandCodeApiKey(" ", "file-key"), "file-key") + assert.equal(pickCommandCodeApiKey(" ", undefined), undefined) + }) + + it("trims a real registry key", () => { + assert.equal(pickCommandCodeApiKey(" real-registry-key ", "file-key"), "real-registry-key") + }) +}) + describe("projectSlugFromPath()", () => { it("matches the official CLI-style slug from an absolute working directory", () => { assert.equal( @@ -359,7 +390,7 @@ describe("toJsonSchema()", () => { if (!outputProperties || typeof outputProperties !== "object") { throw new Error("expected object properties") } - assert.ok(Object.prototype.hasOwnProperty.call(outputProperties, "__proto__")) + assert.ok(Object.hasOwn(outputProperties, "__proto__")) assert.deepEqual(Object.getOwnPropertyDescriptor(outputProperties, "__proto__")?.value, { type: "string", }) diff --git a/tests/test-quota.ts b/tests/test-quota.ts new file mode 100644 index 0000000..5eea0d8 --- /dev/null +++ b/tests/test-quota.ts @@ -0,0 +1,395 @@ +/** + * Unit tests for the Command Code quota layer (src/quota.ts). + * + * These are hermetic: no pi runtime and no network. Fetching is exercised with + * a mocked `fetchImpl`, while parsing and formatting are pure function checks. + */ + +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + DEFAULT_API_BASE, + fetchCommandCodeQuota, + formatQuota, + formatWindowLimits, + redactValue, + windowLimitsFromCredits, +} from "../src/quota.ts" +import type { CommandCodeQuota, CommandCodeCredits, CommandCodeWindowLimit } from "../src/quota.ts" + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} + +function okFetch(handlers: Record) { + const urls: string[] = [] + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + urls.push(url) + for (const [needle, body] of Object.entries(handlers)) { + if (url.includes(needle)) return jsonResponse(body) + } + throw new Error(`Unexpected URL: ${url}`) + } + return { fetchImpl, urls: () => urls } +} + +describe("Command Code quota", () => { + it("parses window limits from the credits windowLimits object", () => { + const limits = windowLimitsFromCredits({ + limited: true, + // resetAt as reported by the live API: milliseconds since epoch. + fiveHour: { used: 8, cap: 14, resetAt: 1_700_000_000_000 }, + weekly: { used: 30, cap: 35, resetAt: 1_700_000_000_000 }, + }) + assert.deepEqual(limits, [ + { window: "fiveHour", used: 8, cap: 14, resetAt: 1_700_000_000 }, + { window: "weekly", used: 30, cap: 35, resetAt: 1_700_000_000 }, + ]) + }) + + it("skips empty window limit entries", () => { + const limits = windowLimitsFromCredits({ + limited: false, + fiveHour: { used: 0, cap: 0, resetAt: null }, + weekly: { used: 0, cap: 0, resetAt: null }, + }) + assert.deepEqual(limits, []) + }) + + it("parses resetAt as numeric string or ISO timestamp string", () => { + const limits = windowLimitsFromCredits({ + fiveHour: { used: 1, cap: 2, resetAt: "1700000000000" }, + weekly: { used: 1, cap: 2, resetAt: "2023-11-14T22:13:20.000Z" }, + }) + // numeric ms string -> epoch seconds; ISO string -> epoch seconds + assert.equal(limits[0]?.resetAt, 1_700_000_000) + assert.equal(limits[1]?.resetAt, 1_700_000_000) + }) + + it("renders a zero request count instead of dropping the Requests line", () => { + const quota: CommandCodeQuota = { + account: { login: "alice", orgId: null }, + credits: null, + subscription: null, + summary: { totalCost: 0, totalCount: 0 }, + } + const output = formatQuota(quota, () => 1_700_000_000_000) + assert.match(output, /Requests: 0/) + }) + + it("formats window limits with percentage and reset clock", () => { + const limits: CommandCodeWindowLimit[] = [ + { window: "fiveHour", used: 7, cap: 14, resetAt: 1_700_000_000 }, + { window: "weekly", used: 0, cap: 35, resetAt: null }, + ] + const lines = formatWindowLimits(limits) + assert.match(lines[0] ?? "", /^5-hour: 7\.00 \/ 14\.00 credits \(50% used\) \(resets/) + assert.match(lines[1] ?? "", /^Weekly: 0\.00 \/ 35\.00 credits \(0% used\)/) + }) + + it("uses the injected clock for the reset countdown", () => { + const limit: CommandCodeWindowLimit = { + window: "fiveHour", + used: 7, + cap: 14, + resetAt: 1_700_000_000, // seconds since epoch + } + // now() shortly before reset -> a short "in Nm" countdown + const soon = formatWindowLimits([limit], () => 1_699_999_000 * 1000)[0] + assert.match(soon ?? "", /\(resets in \d+m\)/) + // already past reset -> "soon" + const past = formatWindowLimits([limit], () => 1_700_100_000 * 1000)[0] + assert.match(past ?? "", /\(resets soon\)/) + }) + + it("fetches and normalizes the full quota snapshot", async () => { + const { fetchImpl, urls } = okFetch({ + whoami: { user: { userName: "alice" }, org: { id: "org_1", login: "alice-inc" } }, + credits: { + credits: { + monthlyCredits: 40, + purchasedCredits: 10, + freeCredits: 5, + planId: "pro", + }, + windowLimits: { + fiveHour: { used: 8, cap: 16, resetAt: 1_700_000_000_000 }, + weekly: { used: 20, cap: 40, resetAt: null }, + }, + }, + subscriptions: { + data: { + planId: "pro", + status: "active", + currentPeriodStart: "2026-01-01T00:00:00Z", + currentPeriodEnd: "2026-02-01T00:00:00Z", + }, + }, + summary: { totalCost: 12.34, totalCount: 1500 }, + }) + + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, true) + if (!result.ok) return + + assert.equal(result.quota.account.login, "alice-inc") + assert.equal(result.quota.account.orgId, "org_1") + assert.deepEqual(result.quota.credits?.remainingCredits, 55) + assert.equal(result.quota.credits?.windowLimits.length, 2) + assert.equal(result.quota.subscription?.planId, "pro") + assert.equal(result.quota.summary?.totalCost, 12.34) + + // Regression: requested URLs must carry the base exactly once (no + // double prefix), and all hit the alpha usage endpoints. + const fetched = urls() + assert.equal(fetched.length, 4) + for (const url of fetched) { + assert.ok( + /^https:\/\/api\.commandcode\.ai\/alpha\//.test(url), + `expected base-prefixed alpha URL, got: ${url}`, + ) + assert.equal((url.match(/https:\/\//g) ?? []).length, 1) + assert.equal(url.includes(`${DEFAULT_API_BASE}${DEFAULT_API_BASE}`), false) + } + }) + + it("degrades gracefully when individual billing endpoints fail", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) + if (url.includes("credits") || url.includes("subscriptions")) { + return jsonResponse({ error: "boom" }, 500) + } + throw new Error(`Unexpected URL: ${url}`) + } + + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, true) + if (!result.ok) return + assert.equal(result.quota.credits, null) + assert.equal(result.quota.summary?.totalCost, 3.0) + // Optional aggregate tokens are parsed when the summary reports them. + assert.equal(result.quota.summary?.totalTokens, undefined) + }) + + it("degrades on thrown network failures from optional endpoints, not just HTTP 5xx", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) + if (url.includes("credits")) throw new Error("network down") + if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) + throw new Error(`Unexpected URL: ${url}`) + } + + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, true) + if (!result.ok) return + assert.equal(result.quota.credits, null) + assert.equal(result.quota.subscription?.planId, "pro") + assert.equal(result.quota.summary?.totalCost, 3.0) + }) + + it("fails the command when the summary endpoint rejects auth/permission", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + if (url.includes("credits")) return jsonResponse({ credits: { monthlyCredits: 5 } }) + if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) + if (url.includes("summary")) return jsonResponse({ error: "nope" }, 403) + throw new Error(`Unexpected URL: ${url}`) + } + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, false) + if (result.ok) return + assert.equal(result.error.kind, "http") + assert.match(result.error.message, /summary/) + }) + + it("does not treat 429 on billing endpoints as fatal", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + if (url.includes("summary")) return jsonResponse({ totalCost: 3.0, totalCount: 10 }) + if (url.includes("credits") || url.includes("subscriptions")) { + return jsonResponse({ error: "rate limited" }, 429) + } + throw new Error(`Unexpected URL: ${url}`) + } + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, true) + if (!result.ok) return + assert.equal(result.quota.credits, null) + assert.equal(result.quota.summary?.totalCost, 3.0) + }) + + it("sends extra headers (ZDR) on quota requests", async () => { + let sent: Headers | undefined + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + sent = (init?.headers as Headers) ?? undefined + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + if (url.includes("credits")) return jsonResponse({ credits: { monthlyCredits: 5 } }) + if (url.includes("subscriptions")) return jsonResponse({ data: { planId: "pro" } }) + if (url.includes("summary")) return jsonResponse({ totalCost: 1, totalCount: 1 }) + throw new Error(`Unexpected URL: ${url}`) + } + const result = await fetchCommandCodeQuota({ + apiKey: "cc_test_key", + fetchImpl, + extraHeaders: { "x-cmd-zdr": "1" }, + }) + assert.equal(result.ok, true) + const headers = new Headers(sent) + assert.equal(headers.get("x-cmd-zdr"), "1") + }) + + it("parses optional token count and key name when present", async () => { + const { fetchImpl } = okFetch({ + whoami: { user: { userName: "alice", keyName: "Pi Agent" }, org: null }, + credits: { credits: { monthlyCredits: 5, purchasedCredits: 0, freeCredits: 0 } }, + subscriptions: { data: { planId: "pro", status: "active" } }, + summary: { totalCost: 1.06, totalCount: 654, totalTokens: 74_200_000 }, + }) + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, true) + if (!result.ok) return + assert.equal(result.quota.summary?.totalTokens, 74_200_000) + assert.equal(result.quota.account.keyName, "Pi Agent") + }) + + it("rejects missing API keys as a config error", async () => { + const result = await fetchCommandCodeQuota({ apiKey: "" }) + assert.equal(result.ok, false) + if (result.ok) return + assert.equal(result.error.kind, "config") + }) + + it("fails with a config-style error when the API key is rejected", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + if (String(input).includes("whoami")) return jsonResponse({ error: "unauthorized" }, 401) + throw new Error(`Unexpected URL: ${input}`) + } + const result = await fetchCommandCodeQuota({ apiKey: "cc_bad_key", fetchImpl }) + assert.equal(result.ok, false) + if (result.ok) return + assert.equal(result.error.kind, "http") + assert.match(result.error.message, /401/) + }) + + it("formats a complete quota snapshot into readable output", () => { + const quota: CommandCodeQuota = { + account: { login: "alice-inc", orgId: "org_1" }, + credits: { + monthlyCredits: 40, + purchasedCredits: 10, + freeCredits: 5, + remainingCredits: 55, + windowLimits: [ + { window: "fiveHour", used: 8, cap: 16, resetAt: null }, + { window: "weekly", used: 20, cap: 40, resetAt: null }, + ], + } satisfies CommandCodeCredits, + subscription: { + planId: "pro", + status: "active", + currentPeriodStart: "", + currentPeriodEnd: "", + }, + summary: { totalCost: 12.34, totalCount: 1500 }, + } + + const output = formatQuota(quota, () => 1_700_000_000_000) + assert.doesNotMatch(output, /Command Code quota —/) + assert.match(output, /Credits/) + assert.match(output, /Remaining: \$55\.00 of \$67\.34/) + assert.match(output, /Used: \$12\.34/) + assert.match(output, /Sources: monthly \$40\.00 \/ purchased \$10\.00 \/ free \$5\.00/) + assert.match(output, /Plan: pro \(active\)/) + assert.match(output, /Usage \(this month\)/) + assert.match(output, /Cost: \$12\.34/) + assert.match(output, /Requests: 1,500/) + assert.match(output, /Username/) + assert.match(output, /alice-inc/) + assert.match(output, /5-hour: 8\.00 \/ 16\.00 credits/) + assert.match(output, /Weekly: 20\.00 \/ 40\.00 credits/) + assert.match(output, /https:\/\/commandcode\.ai\/usage/) + }) + + it("redacts token-like values from error messages", () => { + // 16+ char run after a credential key is redacted by the shared redactor. + assert.equal(redactValue("api_key=abcdefghijklmnop123456"), "api_key=[redacted]") + assert.equal(redactValue("Bearer user_12345678901234 failed"), "Bearer [redacted] failed") + }) + + it("redacts named credential fields and short tokens from error bodies", () => { + // Credential key-value forms (with = or : separator) are redacted. + assert.equal(redactValue("api_key=abc123"), "api_key=[redacted]") + assert.equal( + redactValue("authorization=Basic abc:def failed"), + "authorization=[redacted] abc:def failed", + ) + assert.equal(redactValue("user_123456789 failed"), "[redacted] failed") + assert.equal(redactValue("cc_abcdefghijkl failed"), "[redacted] failed") + assert.equal( + redactValue("token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret"), + "token=[redacted]", + ) + }) + + it("redacts JSON-quoted credential fields in error bodies", () => { + assert.equal( + redactValue('{"apiKey":"sk-abcdefghijklmnop123456","ok":true}'), + '{"apiKey":"[redacted]","ok":true}', + ) + assert.equal( + redactValue('{"error":"bad","access_token":"opaque-internal-token-12345"}'), + '{"error":"bad","access_token":"[redacted]"}', + ) + assert.equal( + redactValue('{"authorization":"Bearer user_1234"}'), + '{"authorization":"[redacted]"}', + ) + }) + + it("redacts thrown network errors from the outer catch path", async () => { + const fetchImpl = async (_input: RequestInfo | URL): Promise => { + throw new Error("connection reset by proxy api_key=supersecretvalue123456") + } + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, false) + if (result.ok) return + assert.doesNotMatch(result.error.message, /supersecretvalue123456/) + assert.match(result.error.kind, /network/) + }) + + it("honors the overall deadline once it has already fired (no phase starts after abort)", async () => { + const start = Date.now() + const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input) + if (url.includes("whoami")) { + // Never resolve; let the per-request controller abort it at timeoutMs. + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(Object.assign(new Error("aborted"), { name: "AbortError" })), + ) + }) + } + throw new Error(`Unexpected URL: ${url}`) + } + + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl, timeoutMs: 30 }) + const elapsed = Date.now() - start + assert.equal(result.ok, false) + if (result.ok) return + assert.equal(result.error.kind, "timeout") + // The overall deadline governs the whole command; no phase may add ~30ms on top. + assert.ok(elapsed < 200, `elapsed ${elapsed}ms exceeded overall deadline`) + }) +}) From 96356698fa046f0e14cd9bdf622454aa8ff4750a Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Tue, 25 Aug 2026 13:34:54 +0200 Subject: [PATCH 2/2] fix(quota): harden dashboard integration --- README.md | 4 +- index.ts | 51 +-- package.json | 6 +- src/quota-command.ts | 66 ++++ src/quota-format.ts | 111 +++++++ src/quota-types.ts | 47 +++ src/quota.ts | 630 ++++++++++-------------------------- tests/test-quota-command.ts | 117 +++++++ tests/test-quota.ts | 38 ++- 9 files changed, 556 insertions(+), 514 deletions(-) create mode 100644 src/quota-command.ts create mode 100644 src/quota-format.ts create mode 100644 src/quota-types.ts create mode 100644 tests/test-quota-command.ts diff --git a/README.md b/README.md index 2de94fb..6c757dc 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,9 @@ While pi is running, use these provider commands without restarting: - `/commandcode-refresh` fetches and re-registers the current model catalog. Overlapping refreshes are coalesced, and a failed refresh keeps the last valid catalog active. - `/commandcode-status` shows redacted discovery diagnostics, including the source, model count, timestamps, cache path, endpoint, and warning. -- `/commandcode-quota` shows your Command Code account usage and quota in a dashboard-style layout: credits remaining and used with a percentage, monthly/purchased/free sources, the current plan, month-to-date cost/requests/tokens, the API key name, and the 5-hour and weekly usage windows. +- `/commandcode-quota` shows your Command Code account usage and quota in a dashboard-style layout: credits remaining and used with a percentage, monthly/purchased/free sources, the current plan, available usage totals, the API key name, and the 5-hour and weekly usage windows. -The `commandcode-quota` command reads from the Command Code alpha usage endpoints (the same ones the `cmd` CLI `/usage` command uses): `whoami`, `billing/credits`, `billing/subscriptions`, and `usage/summary`. It authenticates with the same API key the provider already uses. If command cannot reach those endpoints or they change, the command reports a readable error instead of failing. Output is plain text (via `ui.notify`) so it works across pi and compatible hosts such as OMP. +The `commandcode-quota` command reads from the Command Code alpha usage endpoints (the same ones the `cmd` CLI `/usage` command uses): `whoami`, `billing/credits`, `billing/subscriptions`, and `usage/summary`. It authenticates with the same API key the provider already uses. If the command cannot reach those endpoints or an endpoint schema changes, unavailable sections are reported explicitly instead of being displayed as zero usage. Output is plain text (via `ui.notify`) so it works across pi and compatible hosts such as OMP. Set `COMMANDCODE_ZDR=1` to send Command Code's documented `x-cmd-zdr: 1` zero-data-retention header. diff --git a/index.ts b/index.ts index 1a7bd57..d3005d4 100644 --- a/index.ts +++ b/index.ts @@ -18,7 +18,6 @@ import { join } from "node:path" import { getConfiguredApiKey } from "./src/api-key.ts" import { createStreamCommandCode } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" -import { pickCommandCodeApiKey } from "./src/converters.ts" import { apiForModelId, baseUrlForModel, @@ -33,20 +32,10 @@ import { import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { normalizeCommandCodeMessage } from "./src/overflow.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" +import { registerCommandCodeQuota } from "./src/quota-command.ts" import { createCommandCodeRuntime } from "./src/runtime.ts" -import { fetchCommandCodeQuota, formatQuota, redactValue } from "./src/quota.ts" import { createCommandCodeTransportRouter } from "./src/transport.ts" -const COMMAND_CODE_PROVIDER_ID = "commandcode" - -async function resolveCommandCodeApiKey(ctx: ExtensionCommandContext): Promise { - const registryKey = await ctx.modelRegistry?.getApiKeyForProvider?.(COMMAND_CODE_PROVIDER_ID) - // Mirror src/core.ts: OMP may surface an unresolved placeholder; fall back - // to the env/auth-file resolver so we never send a literal placeholder as a - // Bearer token (which caused a 401 on /alpha/whoami). - return pickCommandCodeApiKey(registryKey, getConfiguredApiKey()) -} - function commandCodeHeaders(): Record | undefined { if (process.env.COMMANDCODE_ZDR === "1") { return { "x-cmd-zdr": "1" } @@ -136,41 +125,9 @@ export default async function (pi: ExtensionAPI) { return normalized ? { message: normalized.message } : undefined }) - pi.registerCommand("commandcode-quota", { - description: "Show Command Code account usage and quota", - handler: async (_args, ctx) => { - await ctx.waitForIdle?.() - - // Resolve the key in a host-agnostic way so the command also works on - // OMP (which passes an unresolved "$COMMANDCODE_API_KEY" placeholder - // through the registry): filter placeholders and fall back to the - // env/auth-file resolver, mirroring src/core.ts. - const apiKey = await resolveCommandCodeApiKey(ctx) - if (!apiKey) { - ctx.ui.notify( - "Command Code quota requires an API key. Run /login and select Command Code, or set the COMMANDCODE_API_KEY env var.", - "warning", - ) - return - } - - const result = await fetchCommandCodeQuota({ - apiKey, - // Alpha endpoints live under the legacy base (no /provider/v1), - // same as the fallback generate transport. - baseUrl: legacyApiBase(apiBase), - // Respect the user's zero-data-retention preference on usage/account - // calls too, matching the provider stream path. - extraHeaders: commandCodeHeaders(), - }) - - if (!result.ok) { - ctx.ui.notify(redactValue(result.error.message), "error") - return - } - - ctx.ui.notify(formatQuota(result.quota), "info") - }, + registerCommandCodeQuota(pi, { + apiBase: legacyApiBase(apiBase), + headers: commandCodeHeaders(), }) const runtime = createCommandCodeRuntime(pi, { diff --git a/package.json b/package.json index e00b16f..6e07011 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "LICENSE" ], "scripts": { - "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", + "test": "npm run typecheck && tsx tests/test-package-manifest.ts && tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts && node tests/test-pi-isolated.mjs && node tests/test-pi-authenticated.mjs && node tests/test-pi-local.mjs && node tests/test-omp-compat.mjs", "typecheck": "tsc --noEmit", "format:check": "prettier --check '**/*.{ts,mjs,json,md}'", "format": "prettier --write '**/*.{ts,mjs,json,md}'", "pi:isolated": "node scripts/pi-isolated.mjs", "pi:authenticated": "node scripts/pi-authenticated.mjs", - "test:quota": "tsx tests/test-quota.ts", - "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts", + "test:quota": "tsx tests/test-quota.ts && tsx tests/test-quota-command.ts", + "test:unit": "tsx tests/test-api-key.ts && tsx tests/test-pure-functions.ts && tsx tests/test-models.ts && tsx tests/test-runtime.ts && tsx tests/test-pricing.ts && tsx tests/test-cost.ts && tsx tests/test-oauth.ts && tsx tests/test-abort.ts && tsx tests/test-overflow.ts && tsx tests/test-stream.ts && tsx tests/test-quota.ts && tsx tests/test-quota-command.ts && tsx tests/test-retry.ts && tsx tests/test-transport.ts", "test:api-key": "tsx tests/test-api-key.ts", "test:models": "tsx tests/test-models.ts", "test:runtime": "tsx tests/test-runtime.ts", diff --git a/src/quota-command.ts b/src/quota-command.ts new file mode 100644 index 0000000..47f24d9 --- /dev/null +++ b/src/quota-command.ts @@ -0,0 +1,66 @@ +import { getConfiguredApiKey } from "./api-key.ts" +import { pickCommandCodeApiKey } from "./converters.ts" +import { fetchCommandCodeQuota, redactValue } from "./quota.ts" +import { formatQuota } from "./quota-format.ts" + +export interface QuotaCommandContext { + waitForIdle?: () => Promise + modelRegistry?: { + getApiKeyForProvider?: (provider: string) => Promise + } + ui: { + notify(message: string, type?: "info" | "warning" | "error"): void + } +} + +interface QuotaCommandApi { + registerCommand( + name: string, + options: { + description: string + handler: (args: string, ctx: QuotaCommandContext) => Promise + }, + ): void +} + +interface RegisterQuotaCommandOptions { + apiBase: string + headers?: Record + getConfiguredKey?: () => string | undefined + fetchQuota?: typeof fetchCommandCodeQuota +} + +export function registerCommandCodeQuota( + pi: QuotaCommandApi, + options: RegisterQuotaCommandOptions, +): void { + const getConfiguredKey = options.getConfiguredKey ?? getConfiguredApiKey + const fetchQuota = options.fetchQuota ?? fetchCommandCodeQuota + + pi.registerCommand("commandcode-quota", { + description: "Show Command Code account usage and quota", + handler: async (_args, ctx) => { + await ctx.waitForIdle?.() + const registryKey = await ctx.modelRegistry?.getApiKeyForProvider?.("commandcode") + const apiKey = pickCommandCodeApiKey(registryKey, getConfiguredKey()) + if (!apiKey) { + ctx.ui.notify( + "Command Code quota requires an API key. Run /login and select Command Code, or set COMMANDCODE_API_KEY.", + "warning", + ) + return + } + + const result = await fetchQuota({ + apiKey, + baseUrl: options.apiBase, + extraHeaders: options.headers, + }) + if (!result.ok) { + ctx.ui.notify(redactValue(result.error.message), "error") + return + } + ctx.ui.notify(formatQuota(result.quota), "info") + }, + }) +} diff --git a/src/quota-format.ts b/src/quota-format.ts new file mode 100644 index 0000000..9bed62c --- /dev/null +++ b/src/quota-format.ts @@ -0,0 +1,111 @@ +import type { + CommandCodeCredits, + CommandCodeQuota, + CommandCodeSubscription, + CommandCodeWindowLimit, +} from "./quota-types.ts" + +export function formatWindowLimits( + limits: readonly CommandCodeWindowLimit[], + now: () => number = Date.now, +): string[] { + const labels: Record = { + fiveHour: "5-hour", + weekly: "Weekly", + } + + return limits.map((limit) => { + const used = limit.used.toFixed(2) + const cap = limit.cap.toFixed(2) + const percent = limit.cap > 0 ? Math.round((limit.used / limit.cap) * 100) : 0 + const reset = limit.resetAt === null ? "" : ` (resets ${formatResetClock(limit.resetAt, now)})` + return `${labels[limit.window]}: ${used} / ${cap} credits (${percent}% used)${reset}` + }) +} + +function formatResetClock(resetAtSeconds: number, now: () => number): string { + const date = new Date(resetAtSeconds * 1000) + if (Number.isNaN(date.getTime())) return "unknown" + const diffMs = date.getTime() - now() + if (diffMs <= 0) return "soon" + const minutes = Math.ceil(diffMs / 60_000) + if (minutes < 60) return `in ${minutes}m` + const hours = Math.floor(minutes / 60) + const remainingMinutes = minutes % 60 + if (hours < 24) { + return remainingMinutes > 0 ? `in ${hours}h ${remainingMinutes}m` : `in ${hours}h` + } + const days = Math.floor(hours / 24) + return days === 1 ? "in 1 day" : `in ${days} days` +} + +function creditsDetail(credits: CommandCodeCredits | null): string | undefined { + if (!credits) return undefined + const parts = [ + `monthly $${credits.monthlyCredits.toFixed(2)}`, + `purchased $${credits.purchasedCredits.toFixed(2)}`, + ] + if (credits.freeCredits > 0) parts.push(`free $${credits.freeCredits.toFixed(2)}`) + return `Sources: ${parts.join(" / ")}` +} + +function subscriptionLine(subscription: CommandCodeSubscription): string { + const plan = (subscription.planId ?? "Unknown").replace(/[_-]+/g, " ").trim() + const status = subscription.status ? ` (${subscription.status})` : "" + return `Plan: ${plan}${status}` +} + +function formatTokens(tokens: number): string { + if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B` + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` + return String(tokens) +} + +export function formatQuota(quota: CommandCodeQuota, now: () => number = Date.now): string { + const lines: string[] = [] + const remaining = quota.credits?.remainingCredits ?? 0 + const spent = quota.summary?.totalCost ?? 0 + const pool = remaining + spent + + if (quota.credits || quota.summary) { + lines.push("Credits") + lines.push(` Remaining: $${remaining.toFixed(2)} of $${pool.toFixed(2)}`) + lines.push(` Used: $${spent.toFixed(2)}`) + lines.push(` ${pool > 0 ? Math.round((spent / pool) * 100) : 0}% used`) + } + + const detail = creditsDetail(quota.credits) + if (detail) lines.push(detail) + if (quota.subscription) lines.push(subscriptionLine(quota.subscription)) + + if (quota.summary) { + lines.push("") + lines.push(quota.subscription?.currentPeriodStart ? "Usage (billing period)" : "Usage") + lines.push(` Cost: $${quota.summary.totalCost.toFixed(2)}`) + lines.push(` Requests: ${quota.summary.totalCount.toLocaleString("en-US")}`) + if (quota.summary.totalTokens !== undefined) { + lines.push(` Tokens: ${formatTokens(quota.summary.totalTokens)}`) + } + } + + lines.push("") + lines.push("Account") + lines.push(` ${quota.account.keyName ?? quota.account.login}`) + + const limits = quota.credits?.windowLimits ?? [] + if (limits.length > 0) { + lines.push("") + lines.push("Usage windows:") + lines.push(...formatWindowLimits(limits, now).map((line) => ` ${line}`)) + } + + if ((quota.unavailable?.length ?? 0) > 0) { + lines.push("") + lines.push(`Unavailable: ${quota.unavailable?.join(", ")}`) + } + + lines.push("") + lines.push("Full detail: https://commandcode.ai/usage") + return lines.join("\n") +} diff --git a/src/quota-types.ts b/src/quota-types.ts new file mode 100644 index 0000000..07f670e --- /dev/null +++ b/src/quota-types.ts @@ -0,0 +1,47 @@ +export interface CommandCodeWindowLimit { + window: "fiveHour" | "weekly" + used: number + cap: number + resetAt: number | null +} + +export interface CommandCodeCredits { + monthlyCredits: number + purchasedCredits: number + freeCredits: number + remainingCredits: number + windowLimits: CommandCodeWindowLimit[] +} + +export interface CommandCodeSubscription { + planId: string | null + status: string | null + currentPeriodStart: string | null + currentPeriodEnd: string | null +} + +export interface CommandCodeUsageSummary { + totalCost: number + totalCount: number + totalTokens?: number +} + +export type CommandCodeQuotaSection = "credits" | "subscription" | "usage" + +export interface CommandCodeQuota { + account: { + login: string + orgId: string | null + keyName?: string + } + credits: CommandCodeCredits | null + subscription: CommandCodeSubscription | null + summary: CommandCodeUsageSummary | null + unavailable?: readonly CommandCodeQuotaSection[] +} + +export type CommandCodeQuotaErrorKind = "config" | "http" | "network" | "timeout" + +export type CommandCodeQuotaResult = + | { ok: true; quota: CommandCodeQuota } + | { ok: false; error: { message: string; kind: CommandCodeQuotaErrorKind } } diff --git a/src/quota.ts b/src/quota.ts index 15a0263..e595b08 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -1,83 +1,34 @@ -/** - * Command Code usage/quota fetch layer for the `/commandcode-quota` command. - * - * Command Code exposes account usage through a set of authenticated alpha - * endpoints (the same ones the `cmd` CLI `/usage` command uses): - * - * - `/alpha/whoami` -> resolved account + optional org id - * - `/alpha/billing/credits` -> monthly/purchased/free credits + window limits - * - `/alpha/billing/subscriptions`-> plan id, status, billing period - * - `/alpha/usage/summary` -> period totals (cost, request count, optional tokens) - * - * These endpoints are not part of the documented public Provider API - * (`/provider/v1/*`) but are shipped with every `command-code` CLI release and - * authenticate with the same API key the provider already uses. Fetches are - * wrapped defensively so the quota command degrades to a readable error rather - * than surfacing raw transport details. - */ - import { redactCommandCodeErrorText } from "./overflow.ts" +import type { + CommandCodeCredits, + CommandCodeQuotaResult, + CommandCodeQuotaSection, + CommandCodeSubscription, + CommandCodeUsageSummary, + CommandCodeWindowLimit, +} from "./quota-types.ts" export const DEFAULT_API_BASE = "https://api.commandcode.ai" - export const QUOTA_TIMEOUT_MS = 15_000 -/** - * A single rolling usage window (the 5-hour or weekly cap on a plan's monthly - * credits). Values are measured in credit value, not request count. - */ -export interface CommandCodeWindowLimit { - window: "fiveHour" | "weekly" - used: number - cap: number - /** Unix epoch seconds when this window resets, normalized from seconds or ms. */ - resetAt: number | null +interface FetchOptions { + apiKey: string + baseUrl?: string + fetchImpl?: typeof fetch + timeoutMs?: number + extraHeaders?: Record } -/** Credits exposed by the `/alpha/billing/credits` endpoint. */ -export interface CommandCodeCredits { - monthlyCredits: number - purchasedCredits: number - freeCredits: number - remainingCredits: number - windowLimits: CommandCodeWindowLimit[] +interface HttpErrorShape { + __httpError: true + message: string + status: number + body: string } -/** Subscription/plan info exposed by `/alpha/billing/subscriptions`. */ -export interface CommandCodeSubscription { - planId: string | null - status: string | null - currentPeriodStart: string | null - currentPeriodEnd: string | null -} - -/** Period totals exposed by `/alpha/usage/summary`. */ -export interface CommandCodeUsageSummary { - totalCost: number - totalCount: number - /** Optional aggregate token count; only shown when the endpoint reports it. */ - totalTokens?: number -} - -/** Fully normalized quota snapshot for display. */ -export interface CommandCodeQuota { - account: { - login: string - orgId: string | null - /** Optional API key / account display name; falls back to login. */ - keyName?: string - } - credits: CommandCodeCredits | null - subscription: CommandCodeSubscription | null - summary: CommandCodeUsageSummary | null -} - -export type CommandCodeQuotaResult = - | { ok: true; quota: CommandCodeQuota } - | { ok: false; error: { message: string; kind: "config" | "http" | "network" | "timeout" } } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) +interface QuotaErrorShape { + __quotaError: true + kind: "timeout" | "network" } function isRecord(value: unknown): value is Record { @@ -85,442 +36,275 @@ function isRecord(value: unknown): value is Record { } function numberValue(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) return undefined - return value >= 0 ? value : undefined + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined } -interface FetchOptions { - apiKey: string - baseUrl?: string - fetchImpl?: typeof fetch - timeoutMs?: number - /** Extra HTTP headers merged after Content-Type/Authorization (e.g. ZDR). */ - extraHeaders?: Record +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) } -function buildUrl(path: string, params: Record): string { - const search = new URLSearchParams() - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null && value !== "") search.set(key, value) +function normalizeResetAt(value: unknown): number | null { + let timestamp: number | undefined + if (typeof value === "number" && Number.isFinite(value)) timestamp = value + if (typeof value === "string" && value.length > 0) { + const trimmed = value.trim() + timestamp = /^\d+$/.test(trimmed) ? Number(trimmed) : Date.parse(trimmed) } - const query = search.toString() - return `${path}${query ? `?${query}` : ""}` + if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp < 0) return null + return timestamp >= 1e12 ? Math.round(timestamp / 1000) : timestamp } -/** Extract the WindowLimit array from the top-level `windowLimits` object. */ export function windowLimitsFromCredits(value: unknown): CommandCodeWindowLimit[] { if (!isRecord(value)) return [] const limits: CommandCodeWindowLimit[] = [] - for (const [window, entry] of [ ["fiveHour", value.fiveHour], ["weekly", value.weekly], ] as const) { if (!isRecord(entry)) continue - const used = numberValue(entry.used) ?? 0 - const cap = numberValue(entry.cap) ?? 0 - if (cap <= 0 && used <= 0) continue - limits.push({ - window, - used, - cap, - resetAt: normalizeResetAt(entry.resetAt), - }) + const used = numberValue(entry.used) + const cap = numberValue(entry.cap) + if (used === undefined || cap === undefined || (used === 0 && cap === 0)) continue + limits.push({ window, used, cap, resetAt: normalizeResetAt(entry.resetAt) }) } - return limits } -/** - * Normalize a `resetAt` value to epoch seconds. Accepts seconds (10-digit), - * milliseconds (13-digit, live API), a numeric string, or an ISO timestamp - * string, then converts ms -> s consistently. Invalid/negative values -> null. - */ -function normalizeResetAt(value: unknown): number | null { - let num: number | undefined - if (typeof value === "number" && Number.isFinite(value)) { - num = value - } else if (typeof value === "string" && value.length > 0) { - const trimmed = value.trim() - if (/^\d+$/.test(trimmed)) { - num = Number(trimmed) - } else { - const parsed = Date.parse(trimmed) - if (!Number.isNaN(parsed)) num = Math.round(parsed / 1000) - } - } - if (num === undefined || num < 0) return null - return num >= 1e12 ? Math.round(num / 1000) : num -} - function parseCredits(value: unknown): CommandCodeCredits | null { - const credits = isRecord(value) ? value.credits : undefined - if (!isRecord(credits)) return null - - // `windowLimits` is a top-level sibling of `credits` in the - // `/alpha/billing/credits` response, not nested inside it. - const windowLimits = isRecord(value) ? value.windowLimits : undefined - - const monthlyCredits = numberValue(credits.monthlyCredits) ?? 0 - const purchasedCredits = numberValue(credits.purchasedCredits) ?? 0 - const freeCredits = numberValue(credits.freeCredits) ?? 0 - + if (!isRecord(value) || !isRecord(value.credits)) return null + const credits = value.credits + const monthlyCredits = numberValue(credits.monthlyCredits) + const purchasedCredits = numberValue(credits.purchasedCredits) + const freeCredits = numberValue(credits.freeCredits) + if (monthlyCredits === undefined && purchasedCredits === undefined && freeCredits === undefined) { + return null + } + const monthly = monthlyCredits ?? 0 + const purchased = purchasedCredits ?? 0 + const free = freeCredits ?? 0 return { - monthlyCredits, - purchasedCredits, - freeCredits, - remainingCredits: monthlyCredits + purchasedCredits + freeCredits, - windowLimits: windowLimitsFromCredits(windowLimits), + monthlyCredits: monthly, + purchasedCredits: purchased, + freeCredits: free, + remainingCredits: monthly + purchased + free, + windowLimits: windowLimitsFromCredits(value.windowLimits), } } function parseSubscription(value: unknown): CommandCodeSubscription | null { - const data = isRecord(value) ? value.data : undefined - if (!isRecord(data)) return null - + if (!isRecord(value) || !isRecord(value.data)) return null + const data = value.data + const planId = stringValue(data.planId) + const status = stringValue(data.status) + const currentPeriodStart = stringValue(data.currentPeriodStart) + const currentPeriodEnd = stringValue(data.currentPeriodEnd) + if (!planId && !status && !currentPeriodStart && !currentPeriodEnd) return null return { - planId: stringValue(data.planId) ?? null, - status: stringValue(data.status) ?? null, - currentPeriodStart: stringValue(data.currentPeriodStart) ?? null, - currentPeriodEnd: stringValue(data.currentPeriodEnd) ?? null, + planId: planId ?? null, + status: status ?? null, + currentPeriodStart: currentPeriodStart ?? null, + currentPeriodEnd: currentPeriodEnd ?? null, } } function parseSummary(value: unknown): CommandCodeUsageSummary | null { if (!isRecord(value)) return null + const totalCost = numberValue(value.totalCost) + const totalCount = numberValue(value.totalCount) + if (totalCost === undefined || totalCount === undefined) return null const totalTokens = numberValue(value.totalTokens) ?? numberValue(value.tokens) - return { - totalCost: numberValue(value.totalCost) ?? 0, - totalCount: numberValue(value.totalCount) ?? 0, - ...(totalTokens === undefined ? {} : { totalTokens }), - } + return { totalCost, totalCount, ...(totalTokens === undefined ? {} : { totalTokens }) } } function parseWhoami(value: unknown): { login: string orgId: string | null keyName?: string -} { - const org = isRecord(value) ? value.org : undefined - const user = isRecord(value) ? value.user : undefined +} | null { + if (!isRecord(value)) return null + const org = isRecord(value.org) ? value.org : undefined + const user = isRecord(value.user) ? value.user : undefined + const login = + (org ? stringValue(org.login) : undefined) ?? + (user ? (stringValue(user.userName) ?? stringValue(user.name)) : undefined) + if (!login) return null + const orgId = org ? stringValue(org.id) : undefined + const keyName = user ? (stringValue(user.keyName) ?? stringValue(user.displayName)) : undefined + return { login, orgId: orgId ?? null, ...(keyName ? { keyName } : {}) } +} - const orgLogin = isRecord(org) ? stringValue(org.login) : undefined - const orgId = isRecord(org) ? stringValue(org.id) : undefined - const userLogin = - (isRecord(user) ? stringValue(user.userName) : undefined) ?? - (isRecord(user) ? stringValue(user.name) : undefined) +function buildUrl(path: string, params: Record): string { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value) + } + const query = search.toString() + return `${path}${query ? `?${query}` : ""}` +} - const keyName = - stringValue(isRecord(user) ? user.keyName : undefined) ?? - stringValue(isRecord(user) ? user.displayName : undefined) +function isHttpError(value: unknown): value is HttpErrorShape { + return ( + isRecord(value) && + value.__httpError === true && + typeof value.message === "string" && + typeof value.status === "number" && + typeof value.body === "string" + ) +} +function isQuotaError(value: unknown): value is QuotaErrorShape { + return ( + isRecord(value) && + value.__quotaError === true && + (value.kind === "timeout" || value.kind === "network") + ) +} + +function isBlockingHttpError(error: HttpErrorShape): boolean { + return error.status === 401 || error.status === 403 +} + +function httpFailure(error: HttpErrorShape, context: string): CommandCodeQuotaResult { + const detail = error.body.trim().slice(0, 200) return { - login: orgLogin ?? userLogin ?? "Unknown account", - orgId: orgId ?? null, - ...(keyName === undefined ? {} : { keyName }), + ok: false, + error: { + kind: "http", + message: redactValue( + `${context} request failed (${error.status}): ${detail || error.message}`, + ), + }, } } -/** - * Parse the `windowLimits` into a human-readable, header-safe line list that - * the formatting layer appends. Split out so the pure shape is independently - * testable. - */ -export function formatWindowLimits( - limits: readonly CommandCodeWindowLimit[], - now: () => number = Date.now, -): string[] { - const labels: Record = { - fiveHour: "5-hour", - weekly: "Weekly", - } +class QuotaTimeoutError extends Error {} - return limits.map((limit) => { - const label = labels[limit.window] ?? limit.window - const used = limit.used.toFixed(2) - const cap = limit.cap.toFixed(2) - const pct = limit.cap > 0 ? Math.round((limit.used / limit.cap) * 100) : 0 - const reset = limit.resetAt === null ? "" : ` (resets ${formatResetClock(limit.resetAt, now)})` - return `${label}: ${used} / ${cap} credits (${pct}% used)${reset}` - }) -} - -function formatResetClock(resetAtSeconds: number, now: () => number = Date.now): string { - const date = new Date(resetAtSeconds * 1000) - if (Number.isNaN(date.getTime())) return "unknown" - const nowMs = now() - const diffMs = date.getTime() - nowMs - if (diffMs <= 0) return "soon" - const minutes = Math.ceil(diffMs / 60_000) - if (minutes < 60) return `in ${minutes}m` - const hours = Math.floor(minutes / 60) - const rem = minutes % 60 - if (hours < 24) return rem > 0 ? `in ${hours}h ${rem}m` : `in ${hours}h` - const days = Math.floor(hours / 24) - return days === 1 ? "in 1 day" : `in ${days} days` -} - -/** Derived credits view for the Remaining/Used layout. */ -interface CreditView { - /** Credits remaining (monthly + purchased + free). */ - remaining: number - /** Dollars spent this period (totalCost). */ - spent: number - /** Total pool used as the percentage denominator: remaining + spent. */ - pool: number - /** Percent of the pool used, 0-100. */ - usedPercent: number - hasCreditsInfo: boolean -} - -function creditView(quota: CommandCodeQuota): CreditView { - const credits = quota.credits - const remaining = credits ? credits.remainingCredits : 0 - const spent = quota.summary?.totalCost ?? 0 - const pool = remaining + spent - const hasCreditsInfo = Boolean(credits) || spent > 0 - return { - remaining, - spent, - pool, - usedPercent: hasCreditsInfo ? Math.round((pool > 0 ? spent / pool : 0) * 100) : 0, - hasCreditsInfo, - } -} - -function creditDetailLine(credits: CommandCodeCredits | null): string { - if (!credits) return "" - const parts = [`monthly $${credits.monthlyCredits.toFixed(2)}`] - parts.push(`purchased $${credits.purchasedCredits.toFixed(2)}`) - if (credits.freeCredits > 0) parts.push(`free $${credits.freeCredits.toFixed(2)}`) - return `Sources: ${parts.join(" / ")}` -} - -function subscriptionLine(subscription: CommandCodeSubscription): string { - const rank = subscription.planId ?? "Unknown" - const plan = rank.replace(/[_-]+/g, " ").trim() - const status = subscription.status ? ` (${subscription.status})` : "" - return `Plan: ${plan}${status}` -} - -function accountName(account: CommandCodeQuota["account"]): string { - return account.keyName ?? account.login -} - -/** - * Render a normalized quota snapshot as clean, aligned, dashboard-style text - * suitable for `ui.notify`. Pure so it can be unit tested without a runtime. - */ -export function formatQuota(quota: CommandCodeQuota, now: () => number = Date.now): string { - const lines: string[] = [] - - const credit = creditView(quota) - if (credit.hasCreditsInfo) { - lines.push("") - lines.push("Credits") - lines.push(padValue(`Remaining: $${credit.remaining.toFixed(2)} of $${credit.pool.toFixed(2)}`)) - lines.push(padValue(`Used: $${credit.spent.toFixed(2)}`)) - lines.push(` ${credit.usedPercent}% used`) - } - - const detail = creditDetailLine(quota.credits) - if (detail) lines.push(detail) - - if (quota.subscription) lines.push(subscriptionLine(quota.subscription)) - - if (quota.summary) { - lines.push("") - lines.push("Usage (this month)") - lines.push(padValue(`Cost: $${quota.summary.totalCost.toFixed(2)}`)) - lines.push(padValue(`Requests: ${quota.summary.totalCount.toLocaleString("en-US")}`)) - if (quota.summary.totalTokens && quota.summary.totalTokens > 0) { - lines.push(padValue(`Tokens: ${formatTokens(quota.summary.totalTokens)}`)) - } - } - - lines.push("") - lines.push("Username") - lines.push(padValue(accountName(quota.account))) - - const limits = quota.credits?.windowLimits ?? [] - if (limits.length > 0) { - lines.push("") - lines.push("Usage windows:") - lines.push(...formatWindowLimits(limits, now).map((line) => ` ${line}`)) - } - - lines.push("") - lines.push(`Full detail: https://commandcode.ai/usage`) - - // Trim leading/trailing blank lines so sections stay cleanly separated. - while (lines.length > 0 && lines[0].length === 0) lines.shift() - while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop() - return lines.join("\n") -} - -function padValue(value: string): string { - return ` ${value}` -} - -function formatTokens(tokens: number): string { - if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(1)}B` - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k` - return String(tokens) -} - -/** - * Fetch the current account quota from Command Code. - * - * Resolution chain: whoami -> org id -> credits + subscription (parallel) -> - * summary (needs the billing period start). Any individual endpoint failing - * degrades gracefully: the remaining data is still reported, and a hard - * failure (auth/config, network) is surfaced as a typed error. - */ export async function fetchCommandCodeQuota( options: FetchOptions, ): Promise { if (!options.apiKey) { - return { - ok: false, - error: { message: "No Command Code API key found", kind: "config" }, - } + return { ok: false, error: { message: "No Command Code API key found", kind: "config" } } } const baseUrl = options.baseUrl ?? DEFAULT_API_BASE const fetchImpl = options.fetchImpl ?? fetch const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS - + const overallController = new AbortController() + const overallTimer = setTimeout(() => overallController.abort(), timeoutMs) const headers = { - "Content-Type": "application/json", + accept: "application/json", Authorization: `Bearer ${options.apiKey}`, ...options.extraHeaders, } - // One overall deadline shared across the sequential phases (whoami -> billing - // -> summary) so a slow or blackholed dependency cannot compound per-request - // timeouts into a ~45s stall; the command reports within QUOTA_TIMEOUT_MS. - const overallController = new AbortController() - const overallTimer = setTimeout(() => overallController.abort(), timeoutMs) - const request = async (path: string): Promise => { - // The overall deadline may already have fired (e.g. a prior phase consumed - // the budget) — AbortSignal does not replay past abort events to listeners - // added afterward, so check synchronously instead of relying on the listener. - if (overallController.signal.aborted) { - throw new QuotaTimeoutError() - } - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - const onOverallAbort = () => controller.abort() - overallController.signal.addEventListener("abort", onOverallAbort) + if (overallController.signal.aborted) throw new QuotaTimeoutError() try { const response = await fetchImpl(`${baseUrl}${path}`, { method: "GET", headers, - signal: controller.signal, + signal: overallController.signal, }) - if (!response.ok) { - let message = response.statusText - if (response.status === 401 || response.status === 403) { - message = "Command Code rejected the API key (401/403)" - } return { __httpError: true, - message, + message: + response.status === 401 || response.status === 403 + ? "Command Code rejected the API key" + : response.statusText, status: response.status, body: await response.text().catch(() => ""), - } + } satisfies HttpErrorShape } return await response.json() } catch (error) { - if (controller.signal.aborted) { - throw new QuotaTimeoutError() - } + if (overallController.signal.aborted) throw new QuotaTimeoutError() throw error - } finally { - clearTimeout(timer) - overallController.signal.removeEventListener("abort", onOverallAbort) } } - /** Optional-endpoint wrapper: never throws. Transport/timeout/parse failures - * become a sentinel so the rest of the dashboard still renders, matching the - * existing graceful degradation for HTTP 5xx responses. */ const safeRequest = async (path: string): Promise => { try { return await request(path) } catch (error) { return { __quotaError: true, - message: errorMessage(error), kind: error instanceof QuotaTimeoutError ? "timeout" : "network", - } + } satisfies QuotaErrorShape } } try { - const whoami = await request("/alpha/whoami") - if (isHttpError(whoami)) return httpFailure(whoami, "whoami") - const account = parseWhoami(whoami) + const whoamiRaw = await request("/alpha/whoami") + if (isHttpError(whoamiRaw)) return httpFailure(whoamiRaw, "whoami") + const account = parseWhoami(whoamiRaw) + if (!account) { + return { + ok: false, + error: { kind: "http", message: "Command Code returned an unrecognized account response" }, + } + } const orgId = account.orgId ?? undefined - const creditsPath = buildUrl("/alpha/billing/credits", { orgId }) - const subPath = buildUrl("/alpha/billing/subscriptions", { orgId }) - - const [creditsRaw, subRaw] = await Promise.all([safeRequest(creditsPath), safeRequest(subPath)]) - - // Hard auth/permission failures abort; everything else (including thrown - // network/timeout/parse failures) degrades to a null section. - if (isHttpError(creditsRaw) && isBlockingQuotaHttpError(creditsRaw)) { + const [creditsRaw, subscriptionRaw] = await Promise.all([ + safeRequest(buildUrl("/alpha/billing/credits", { orgId })), + safeRequest(buildUrl("/alpha/billing/subscriptions", { orgId })), + ]) + if (isHttpError(creditsRaw) && isBlockingHttpError(creditsRaw)) { return httpFailure(creditsRaw, "credits") } - if (isHttpError(subRaw) && isBlockingQuotaHttpError(subRaw)) { - return httpFailure(subRaw, "subscription") + if (isHttpError(subscriptionRaw) && isBlockingHttpError(subscriptionRaw)) { + return httpFailure(subscriptionRaw, "subscription") } + const unavailable: CommandCodeQuotaSection[] = [] const credits = - creditsRaw && !isHttpError(creditsRaw) && !isQuotaError(creditsRaw) - ? parseCredits(creditsRaw) - : null + isHttpError(creditsRaw) || isQuotaError(creditsRaw) ? null : parseCredits(creditsRaw) + if (!credits) unavailable.push("credits") const subscription = - subRaw && !isHttpError(subRaw) && !isQuotaError(subRaw) ? parseSubscription(subRaw) : null + isHttpError(subscriptionRaw) || isQuotaError(subscriptionRaw) + ? null + : parseSubscription(subscriptionRaw) + if (!subscription) unavailable.push("subscription") - const since = subscription?.currentPeriodStart ?? undefined - const summaryPath = buildUrl("/alpha/usage/summary", { orgId, since }) - const summaryRaw = await safeRequest(summaryPath) - if (isHttpError(summaryRaw) && isBlockingQuotaHttpError(summaryRaw)) { + const summaryRaw = await safeRequest( + buildUrl("/alpha/usage/summary", { + orgId, + since: subscription?.currentPeriodStart ?? undefined, + }), + ) + if (isHttpError(summaryRaw) && isBlockingHttpError(summaryRaw)) { return httpFailure(summaryRaw, "summary") } const summary = - summaryRaw && !isHttpError(summaryRaw) && !isQuotaError(summaryRaw) - ? parseSummary(summaryRaw) - : null + isHttpError(summaryRaw) || isQuotaError(summaryRaw) ? null : parseSummary(summaryRaw) + if (!summary) unavailable.push("usage") - if (credits === null && subscription === null && summary === null) { - if (overallController.signal.aborted) { - return { - ok: false, - error: { message: "Command Code quota request timed out", kind: "timeout" }, - } - } + if (!credits && !subscription && !summary) { return { ok: false, error: { - message: "Command Code returned no usage data for the account", - kind: "http", + kind: overallController.signal.aborted ? "timeout" : "http", + message: overallController.signal.aborted + ? "Command Code quota request timed out" + : "Command Code returned no recognized usage data for the account", }, } } return { ok: true, - quota: { account, credits, subscription, summary }, + quota: { + account, + credits, + subscription, + summary, + ...(unavailable.length > 0 ? { unavailable } : {}), + }, } } catch (error) { if (error instanceof QuotaTimeoutError || overallController.signal.aborted) { @@ -541,69 +325,7 @@ export async function fetchCommandCodeQuota( } } -class QuotaTimeoutError extends Error { - constructor() { - super("Command Code quota request timed out") - this.name = "QuotaTimeoutError" - } -} - -interface HttpErrorShape { - __httpError: true - message: string - status: number - body: string -} - -function isHttpError(value: unknown): value is HttpErrorShape { - if (!isRecord(value) || value.__httpError !== true) return false - return ( - typeof value.status === "number" && - typeof value.message === "string" && - typeof value.body === "string" - ) -} - -/** Sentinel produced by safeRequest for thrown transport/timeout/parse failures. */ -interface QuotaErrorShape { - __quotaError: true - message: string - kind: "timeout" | "network" -} - -function isQuotaError(value: unknown): value is QuotaErrorShape { - if (!isRecord(value) || value.__quotaError !== true) return false - return typeof value.message === "string" && (value.kind === "timeout" || value.kind === "network") -} - -/** - * Hard-failure HTTP statuses for the quota dashboard: authentication and - * permission failures. Rate limiting (429) is deliberately NOT included — it - * is a transient dependency condition that should degrade like other non-auth - * endpoint failures, not abort the whole command. - */ -function isBlockingQuotaHttpError(error: HttpErrorShape): boolean { - return error.status === 401 || error.status === 403 -} - -function httpFailure(error: HttpErrorShape, context: string): CommandCodeQuotaResult { - const detail = error.body.trim().slice(0, 200) - const message = detail - ? `${context} request failed (${error.status}): ${detail}` - : `${context} request failed (${error.status}): ${error.message}` - return { - ok: false, - error: { message: redactValue(message), kind: "http" }, - } -} - -/** Best-effort scrub of values that look like tokens/secrets from a message. */ export function redactValue(value: string): string { - // Reuse the broader Command Code redaction (Bearer, credential key-value - // fields, user_/cc_ tokens, query-string secrets, standalone keys) so quota - // errors get the same protection as stream errors. Additionally catch - // JSON-quoted credential fields ({"apiKey":"..."}) that the upstream pattern - // requires to be adjacent to `=`/`:`. return redactCommandCodeErrorText(value) .replace( /("\s*(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*"\s*:\s*")([^"]{8,})/gi, diff --git a/tests/test-quota-command.ts b/tests/test-quota-command.ts new file mode 100644 index 0000000..95692cd --- /dev/null +++ b/tests/test-quota-command.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { registerCommandCodeQuota, type QuotaCommandContext } from "../src/quota-command.ts" +import type { CommandCodeQuotaResult } from "../src/quota-types.ts" + +class CommandApiDouble { + handler?: (args: string, ctx: QuotaCommandContext) => Promise + + registerCommand( + name: string, + options: { + description: string + handler: (args: string, ctx: QuotaCommandContext) => Promise + }, + ): void { + assert.equal(name, "commandcode-quota") + assert.match(options.description, /usage and quota/) + this.handler = options.handler + } +} + +function context(registryKey: string | undefined) { + const notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [] + let waited = false + const value = { + async waitForIdle() { + waited = true + }, + modelRegistry: { + async getApiKeyForProvider(provider: string) { + assert.equal(provider, "commandcode") + return registryKey + }, + }, + ui: { + notify(message: string, type?: "info" | "warning" | "error") { + notifications.push({ message, type }) + }, + }, + } satisfies QuotaCommandContext + return { value, notifications, waited: () => waited } +} + +const quotaResult: CommandCodeQuotaResult = { + ok: true, + quota: { + account: { login: "alice", orgId: null }, + credits: null, + subscription: null, + summary: { totalCost: 1, totalCount: 2 }, + }, +} + +describe("commandcode-quota command", () => { + it("registers the command and resolves OMP placeholders through the fallback key", async () => { + const pi = new CommandApiDouble() + let requestKey = "" + let requestBase = "" + registerCommandCodeQuota(pi, { + apiBase: "https://api.commandcode.ai", + getConfiguredKey: () => "fallback-key", + fetchQuota: async (options) => { + requestKey = options.apiKey + requestBase = options.baseUrl ?? "" + return quotaResult + }, + }) + + assert.ok(pi.handler) + const ctx = context("$COMMANDCODE_API_KEY") + await pi.handler("", ctx.value) + assert.equal(ctx.waited(), true) + assert.equal(requestKey, "fallback-key") + assert.equal(requestBase, "https://api.commandcode.ai") + assert.equal(ctx.notifications.at(-1)?.type, "info") + assert.match(ctx.notifications.at(-1)?.message ?? "", /Requests: 2/) + }) + + it("warns without calling the endpoint when no API key is available", async () => { + const pi = new CommandApiDouble() + let called = false + registerCommandCodeQuota(pi, { + apiBase: "https://api.commandcode.ai", + getConfiguredKey: () => undefined, + fetchQuota: async () => { + called = true + return quotaResult + }, + }) + + assert.ok(pi.handler) + const ctx = context(undefined) + await pi.handler("", ctx.value) + assert.equal(called, false) + assert.equal(ctx.notifications.at(-1)?.type, "warning") + assert.match(ctx.notifications.at(-1)?.message ?? "", /requires an API key/) + }) + + it("redacts endpoint failures before notifying the host", async () => { + const pi = new CommandApiDouble() + registerCommandCodeQuota(pi, { + apiBase: "https://api.commandcode.ai", + getConfiguredKey: () => "real-key", + fetchQuota: async () => ({ + ok: false, + error: { kind: "http", message: "api_key=supersecretvalue123456 failed" }, + }), + }) + + assert.ok(pi.handler) + const ctx = context("real-key") + await pi.handler("", ctx.value) + assert.equal(ctx.notifications.at(-1)?.type, "error") + assert.doesNotMatch(ctx.notifications.at(-1)?.message ?? "", /supersecret/) + }) +}) diff --git a/tests/test-quota.ts b/tests/test-quota.ts index 5eea0d8..30f5a9c 100644 --- a/tests/test-quota.ts +++ b/tests/test-quota.ts @@ -8,15 +8,18 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { formatQuota, formatWindowLimits } from "../src/quota-format.ts" import { DEFAULT_API_BASE, fetchCommandCodeQuota, - formatQuota, - formatWindowLimits, redactValue, windowLimitsFromCredits, } from "../src/quota.ts" -import type { CommandCodeQuota, CommandCodeCredits, CommandCodeWindowLimit } from "../src/quota.ts" +import type { + CommandCodeCredits, + CommandCodeQuota, + CommandCodeWindowLimit, +} from "../src/quota-types.ts" function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -71,7 +74,7 @@ describe("Command Code quota", () => { assert.equal(limits[1]?.resetAt, 1_700_000_000) }) - it("renders a zero request count instead of dropping the Requests line", () => { + it("renders valid zero usage without claiming an unknown billing period", () => { const quota: CommandCodeQuota = { account: { login: "alice", orgId: null }, credits: null, @@ -79,6 +82,8 @@ describe("Command Code quota", () => { summary: { totalCost: 0, totalCount: 0 }, } const output = formatQuota(quota, () => 1_700_000_000_000) + assert.match(output, /Usage\n/) + assert.doesNotMatch(output, /billing period/) assert.match(output, /Requests: 0/) }) @@ -158,6 +163,20 @@ describe("Command Code quota", () => { } }) + it("rejects unrecognized successful endpoint schemas instead of displaying zero usage", async () => { + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = String(input) + if (url.includes("whoami")) return jsonResponse({ user: { userName: "alice" }, org: null }) + return jsonResponse({ changed: "schema" }) + } + + const result = await fetchCommandCodeQuota({ apiKey: "cc_test_key", fetchImpl }) + assert.equal(result.ok, false) + if (result.ok) return + assert.equal(result.error.kind, "http") + assert.match(result.error.message, /no recognized usage data/i) + }) + it("degrades gracefully when individual billing endpoints fail", async () => { const fetchImpl = async (input: RequestInfo | URL): Promise => { const url = String(input) @@ -174,6 +193,8 @@ describe("Command Code quota", () => { if (!result.ok) return assert.equal(result.quota.credits, null) assert.equal(result.quota.summary?.totalCost, 3.0) + assert.deepEqual(result.quota.unavailable, ["credits", "subscription"]) + assert.match(formatQuota(result.quota), /Unavailable: credits, subscription/) // Optional aggregate tokens are parsed when the summary reports them. assert.equal(result.quota.summary?.totalTokens, undefined) }) @@ -194,6 +215,7 @@ describe("Command Code quota", () => { assert.equal(result.quota.credits, null) assert.equal(result.quota.subscription?.planId, "pro") assert.equal(result.quota.summary?.totalCost, 3.0) + assert.deepEqual(result.quota.unavailable, ["credits"]) }) it("fails the command when the summary endpoint rejects auth/permission", async () => { @@ -299,8 +321,8 @@ describe("Command Code quota", () => { subscription: { planId: "pro", status: "active", - currentPeriodStart: "", - currentPeriodEnd: "", + currentPeriodStart: "2026-01-01T00:00:00Z", + currentPeriodEnd: "2026-02-01T00:00:00Z", }, summary: { totalCost: 12.34, totalCount: 1500 }, } @@ -312,10 +334,10 @@ describe("Command Code quota", () => { assert.match(output, /Used: \$12\.34/) assert.match(output, /Sources: monthly \$40\.00 \/ purchased \$10\.00 \/ free \$5\.00/) assert.match(output, /Plan: pro \(active\)/) - assert.match(output, /Usage \(this month\)/) + assert.match(output, /Usage \(billing period\)/) assert.match(output, /Cost: \$12\.34/) assert.match(output, /Requests: 1,500/) - assert.match(output, /Username/) + assert.match(output, /Account/) assert.match(output, /alice-inc/) assert.match(output, /5-hour: 8\.00 \/ 16\.00 credits/) assert.match(output, /Weekly: 20\.00 \/ 40\.00 credits/)