mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +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
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