mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 07:32:20 +03:00
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:
committed by
GitHub
parent
62ab93d789
commit
eb9fa33ee7
1
changelog.d/features/11190-usage-command-json.md
Normal file
1
changelog.d/features/11190-usage-command-json.md
Normal 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))
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
130
tests/unit/usage-command-json-format.test.ts
Normal file
130
tests/unit/usage-command-json-format.test.ts
Normal 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)"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user