mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +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
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user