diff --git a/changelog.d/features/11190-usage-command-json.md b/changelog.d/features/11190-usage-command-json.md new file mode 100644 index 0000000000..d7655f04c5 --- /dev/null +++ b/changelog.d/features/11190-usage-command-json.md @@ -0,0 +1 @@ +- **feat(api):** `/api/usage/om-usage` gains a structured form — `?format=json` returns the key's own usage as `ApiKeyUsageLimitStatus` + `UsageSnapshot` instead of `text/plain`. This is the surface a UI (the OmniCopilot panel) consumes to show a key holder their daily/weekly spend and quota reset. The route is self-service (the caller's own key, gated by `allowUsageCommand`), not the management surface; refusals come back as a discriminated `{ "allowed": false, "error": … }` so a UI can tell "not allowed" apart from "allowed but nothing cached yet". The endpoint was previously undocumented in `API_REFERENCE.md`; it now has a section ([#11190](https://github.com/diegosouzapw/OmniRoute/pull/11190)) diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 359ae4750e..3e39f35eba 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -636,6 +636,47 @@ completion. --- +## Self-service usage (`/api/usage/om-usage`) + +Any API key can read **its own** usage and quotas — no management auth. This is the endpoint a +client (CLI, the OmniCopilot panel) uses to show a key holder their spend. + +```bash +# Text form (the historical contract — plain text for a terminal) +curl -H "Authorization: Bearer " \ + http://localhost:20128/api/usage/om-usage + +# Structured form — what a UI consumes +curl -H "Authorization: Bearer " \ + "http://localhost:20128/api/usage/om-usage?format=json" +``` + +The key must have **`allowUsageCommand`** enabled (off by default — the dashboard's API-key +manager toggles it per key). Without it the endpoint answers `403`. + +`?format=json` returns a discriminated shape so a caller never reads a data field off a +refusal. On success: + +```jsonc +{ + "allowed": true, + // present only when the key opted into per-key usage limits (daily/weekly USD): + "personal": { "dailySpentUsd": 1.25, "dailyLimitUsd": 5, "dailyResetAtIso": "…", "weeklySpentUsd": 8, "weeklyLimitUsd": 20, "weeklyResetAtIso": "…" /* … */ }, + // the selected provider quota snapshot, or null when nothing is cached yet: + "provider": { "connectionId": "…", "provider": "claude", "plan": "…", "quotas": { /* … */ } } +} +``` + +On refusal (`401` bad key / `403` not allowed) the same route returns +`{ "allowed": false, "error": { "message": "…" } }` — a present-but-empty `personal`/`provider` +(key allowed, nothing learned yet) is a different state from a refusal, and only the JSON form +distinguishes them. + +**Auth:** the caller's own Bearer API key, validated with `isValidApiKey` — this is *not* the +management surface (`/api/keys/…`), which stays behind `requireManagementAuth`. + +--- + ## Semantic Cache ```bash diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts index 257b8f9c15..76d55315d9 100644 --- a/src/lib/usage/internalUsageCommand.ts +++ b/src/lib/usage/internalUsageCommand.ts @@ -13,7 +13,7 @@ const TEXT_PLAIN_HEADERS = { "Content-Type": "text/plain; charset=utf-8" } as co type JsonRecord = Record; -interface UsageCommandApiKeyMetadata { +export interface UsageCommandApiKeyMetadata { id: string; name?: string; allowedConnections?: string[] | null; @@ -31,7 +31,7 @@ interface ProviderConnectionLike { quotaWindowThresholds?: Record | null; } -interface UsageSnapshot { +export interface UsageSnapshot { connectionId: string; provider: string; plan: unknown; @@ -39,7 +39,7 @@ interface UsageSnapshot { quotaWindowThresholds?: Record | null; } -interface UsageCommandSelection { +export interface UsageCommandSelection { preferredProvider?: string | null; preferredConnectionId?: string | null; } @@ -258,7 +258,7 @@ function snapshotFromConnection( }; } -async function collectUsageSnapshots( +export async function collectUsageSnapshots( metadata: UsageCommandApiKeyMetadata, deps: RequiredDeps ): Promise { @@ -525,6 +525,52 @@ function appendQuotaBlock( lines.push(`⏱ reset in ${formatResetIn(getResetAt(match?.quota ?? null), now)}`); } +/** + * Structured form of the usage command — what {@link buildUsageCommandText} + * renders as text, exposed as data for API consumers (the OmniCopilot panel + * asks for it via `?format=json`). Text and JSON share the exact same + * collectors, so the two can never disagree about a number. + * + * The key design constraint is the 403 case: a key without `allowUsageCommand` + * must reach the client as a *structured* reason, not a bare text error — a + * caller rendering a usage panel has to be able to tell "the server does not + * know your limits yet" apart from "this key may not ask". + */ +/** Discriminated so the caller never reads a data field off a refusal: + * `allowed:false` carries only `error`; `allowed:true` carries the data. */ +export type UsageCommandJson = + | { allowed: false; error: { message: string } } + | { + allowed: true; + /** Present only when the key opted into per-key usage limits. */ + personal: unknown | null; + /** The selected provider snapshot, or null when nothing is cached. */ + provider: UsageSnapshot | null; + }; + +export async function buildUsageCommandJson( + metadata: UsageCommandApiKeyMetadata, + deps: InternalUsageCommandDeps = {}, + selection: UsageCommandSelection = {} +): Promise { + const resolvedDeps = await normalizeDeps(deps); + const personal = + metadata.usageLimitEnabled === true + ? await resolvedDeps.getApiKeyUsageLimitStatus( + { + ...metadata, + preferredProvider: selection.preferredProvider ?? metadata.preferredProvider ?? null, + }, + { now: resolvedDeps.now } + ) + : null; + const provider = selectUsageSnapshot( + await collectUsageSnapshots(metadata, resolvedDeps), + selection + ); + return { allowed: true, personal, provider }; +} + export async function buildUsageCommandText( metadata: UsageCommandApiKeyMetadata, deps: InternalUsageCommandDeps = {}, @@ -588,6 +634,17 @@ function inferHttpUsageCommandSelection(request: Request): UsageCommandSelection } } +/** `?format=json` (or `?format=JSON`) — anything else falls back to the text + * form, which is the historical contract of this endpoint. */ +function wantsUsageCommandJson(request: Request): boolean { + try { + const format = new URL(request.url, "http://localhost").searchParams.get("format"); + return format !== null && format.trim().toLowerCase() === "json"; + } catch { + return false; + } +} + function createPlainUsageCommandResponse(text: string, status = 200): Response { return new Response(text, { status, headers: TEXT_PLAIN_HEADERS }); } @@ -764,22 +821,45 @@ export async function handleInternalUsageCommandHttpRequest( ): Promise { try { const resolvedDeps = await normalizeDeps(deps); + const json = wantsUsageCommandJson(request); const apiKey = extractUsageCommandApiKey(request); if (!apiKey || !(await resolvedDeps.isValidApiKey(apiKey))) { + if (json) { + return Response.json( + { allowed: false, error: { message: USAGE_COMMAND_AUTH_REQUIRED_MESSAGE } } satisfies UsageCommandJson, + { status: 401 } + ); + } return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401); } const metadata = await resolvedDeps.getApiKeyMetadata(apiKey); if (!metadata?.id) { + if (json) { + return Response.json( + { allowed: false, error: { message: USAGE_COMMAND_AUTH_REQUIRED_MESSAGE } } satisfies UsageCommandJson, + { status: 401 } + ); + } return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401); } if (metadata.allowUsageCommand !== true) { + if (json) { + return Response.json( + { allowed: false, error: { message: USAGE_COMMAND_DISABLED_MESSAGE } } satisfies UsageCommandJson, + { status: 403 } + ); + } return createPlainUsageCommandResponse(USAGE_COMMAND_DISABLED_MESSAGE, 403); } + const selection = inferHttpUsageCommandSelection(request); + if (json) { + return Response.json(await buildUsageCommandJson(metadata, resolvedDeps, selection)); + } return createPlainUsageCommandResponse( - await buildUsageCommandText(metadata, resolvedDeps, inferHttpUsageCommandSelection(request)) + await buildUsageCommandText(metadata, resolvedDeps, selection) ); } catch (err) { const body = buildErrorBody(500, err instanceof Error ? err.message : String(err)); diff --git a/tests/unit/usage-command-json-format.test.ts b/tests/unit/usage-command-json-format.test.ts new file mode 100644 index 0000000000..f7d7b286c4 --- /dev/null +++ b/tests/unit/usage-command-json-format.test.ts @@ -0,0 +1,130 @@ +/** + * #8 (OmniCopilot) — the usage command answered `text/plain`, which a UI cannot + * parse safely. The structured form (`?format=json`) returns the same + * `ApiKeyUsageLimitStatus` + `UsageSnapshot` the text is rendered from. + * + * These tests pin the contract the extension depends on: JSON when asked, + * text by default, the 403 as a structured reason rather than a bare string, + * and an error body that never carries a stack trace. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { handleInternalUsageCommandHttpRequest } from "../../src/lib/usage/internalUsageCommand"; + +const NOW = Date.parse("2026-08-19T12:00:00.000Z"); + +const LIMIT_STATUS = { + enabled: true, + dailyLimitUsd: 5, + weeklyLimitUsd: 20, + dailySpentUsd: 1.25, + weeklySpentUsd: 8, + dailyWindowStartIso: "2026-08-19T03:00:00.000Z", + dailyResetAtIso: "2026-08-20T03:00:00.000Z", + weeklyWindowStartIso: "2026-08-16T03:00:00.000Z", + weeklyResetAtIso: "2026-08-23T03:00:00.000Z", + dailyExceeded: false, + weeklyExceeded: false, +}; + +function allowedDeps(overrides: Record = {}) { + return { + now: () => NOW, + isValidApiKey: async (apiKey: string) => apiKey === "sk-allowed", + getApiKeyMetadata: async () => ({ + id: "key-allowed", + name: "panel key", + allowUsageCommand: true, + usageLimitEnabled: true, + }), + getProviderConnections: async () => [ + { id: "conn-claude", provider: "claude", isActive: true }, + ], + getAllProviderLimitsCache: () => ({ + "conn-claude": { + plan: "Claude Max", + quotas: { + weekly: { used: 25, total: 100, remaining: 75, resetAt: "2026-08-25T03:00:00.000Z" }, + }, + message: null, + fetchedAt: new Date(NOW).toISOString(), + }, + }), + getProviderConnectionById: async () => null, + getProviderLimitsCache: () => null, + getQuotaPolicy: async () => ({ defaultThresholdPercent: 0, providerWindowDefaults: {} }), + getApiKeyUsageLimitStatus: async () => LIMIT_STATUS, + ...overrides, + }; +} + +test("om-usage ?format=json returns the structured personal + provider quota", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage?format=json", { + headers: { Authorization: "Bearer sk-allowed" }, + }), + allowedDeps() + ); + + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") ?? "", /application\/json/); + const body = (await response.json()) as { + allowed: boolean; + personal: { dailySpentUsd: number } | null; + provider: { provider: string; connectionId: string } | null; + }; + assert.equal(body.allowed, true); + assert.equal(body.personal?.dailySpentUsd, 1.25); + assert.equal(body.provider?.provider, "claude"); + assert.equal(body.provider?.connectionId, "conn-claude"); +}); + +test("om-usage without ?format stays text/plain (the historical contract)", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage", { + headers: { Authorization: "Bearer sk-allowed" }, + }), + allowedDeps() + ); + + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") ?? "", /text\/plain/); + const text = await response.text(); + assert.match(text, /Personal quota/); + assert.match(text, /Provider quota/); +}); + +test("om-usage ?format=json reports a disallowed key as structured allowed:false", async () => { + // A usage panel must tell "this key may not ask" apart from "no data yet", + // which a bare 403 text body cannot express. + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage?format=json", { + headers: { Authorization: "Bearer sk-allowed" }, + }), + allowedDeps({ + getApiKeyMetadata: async () => ({ id: "key-off", allowUsageCommand: false }), + }) + ); + + assert.equal(response.status, 403); + const body = (await response.json()) as { allowed: boolean }; + assert.equal(body.allowed, false); +}); + +test("om-usage ?format=json rejects an invalid key and never leaks a stack trace", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage?format=json", { + headers: { Authorization: "Bearer sk-wrong" }, + }), + allowedDeps() + ); + + assert.equal(response.status, 401); + const body = (await response.json()) as { allowed: boolean; error?: { message?: string } }; + assert.equal(body.allowed, false); + assert.ok( + !body.error?.message?.includes("at /"), + "error bodies must not carry stack frames (ERROR_SANITIZATION)" + ); +});