feat(api): structured ?format=json for the self-service usage endpoint (#11190)

* feat(api): structured ?format=json for the self-service usage endpoint

GET /api/usage/om-usage already let any key read its own usage — personal
daily/weekly USD limits and the provider quota snapshot — but only as
text/plain, which a UI cannot parse safely. OmniCopilot issue #8 asks exactly
for this surface.

Adds ?format=json, returning the ApiKeyUsageLimitStatus + UsageSnapshot the
text is rendered from. Text and JSON share the same collectors
(collectUsageSnapshots, getApiKeyUsageLimitStatus), so the two can never
disagree about a number. The response is a discriminated union: a key without
allowUsageCommand (403) or an invalid key (401) returns
{ allowed:false, error:{message} }, distinct from allowed:true with empty
sections — the state a panel must render as "nothing learned yet", not a
refusal. Text form unchanged; without ?format the contract is untouched.

The endpoint was previously missing from API_REFERENCE.md; it now has a
section documenting both forms, the allowUsageCommand gate, and the
self-service auth model (caller's own key, not requireManagementAuth).

Regression guards in tests/unit/usage-command-json-format.test.ts (4 tests:
json shape, text default preserved, structured 403, sanitized 401 with no
stack trace). Existing internal-usage-command suite still 12/12.

* chore(changelog): correct the fragment to the real PR number (#11190)

---------

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-22 22:23:17 -03:00
committed by GitHub
parent 62ab93d789
commit eb9fa33ee7
4 changed files with 257 additions and 5 deletions

View File

@@ -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))

View File

@@ -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 <your-api-key>" \
http://localhost:20128/api/usage/om-usage
# Structured form — what a UI consumes
curl -H "Authorization: Bearer <your-api-key>" \
"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

View File

@@ -13,7 +13,7 @@ const TEXT_PLAIN_HEADERS = { "Content-Type": "text/plain; charset=utf-8" } as co
type JsonRecord = Record<string, unknown>;
interface UsageCommandApiKeyMetadata {
export interface UsageCommandApiKeyMetadata {
id: string;
name?: string;
allowedConnections?: string[] | null;
@@ -31,7 +31,7 @@ interface ProviderConnectionLike {
quotaWindowThresholds?: Record<string, number> | null;
}
interface UsageSnapshot {
export interface UsageSnapshot {
connectionId: string;
provider: string;
plan: unknown;
@@ -39,7 +39,7 @@ interface UsageSnapshot {
quotaWindowThresholds?: Record<string, number> | 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<UsageSnapshot[]> {
@@ -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<UsageCommandJson> {
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<Response> {
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));

View File

@@ -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<string, unknown> = {}) {
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)"
);
});