mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 23:52:18 +03:00
* 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) * feat(api): return every connection's snapshot under providers[] in om-usage json Closes #11191. buildUsageCommandJson picked a single snapshot via selectUsageSnapshot, so a panel could only ever show one provider. The collector already had them all — the single-pick is a presentation choice for a terminal. The JSON form now also returns the full UsageSnapshot[] alongside the selected provider, so a UI can render Codex / Claude / OpenCode side by side. The text form is untouched. --------- Co-authored-by: Xiangzhe <bakryun0718@proton.me>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
/**
|
|
* #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 },
|
|
{ id: "conn-codex", provider: "codex", 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(),
|
|
},
|
|
"conn-codex": {
|
|
plan: "Codex Pro",
|
|
quotas: {
|
|
weekly: { used: 9, total: 100, remaining: 91, resetAt: "2026-08-24T03: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 returns every connection under providers[], not just the selected one", async () => {
|
|
// #11191 — a panel needs Codex + Claude side by side; the single `provider`
|
|
// pick is a terminal presentation choice, the collector had them all.
|
|
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);
|
|
const body = (await response.json()) as {
|
|
allowed: boolean;
|
|
provider: { provider: string } | null;
|
|
providers: Array<{ provider: string }>;
|
|
};
|
|
assert.equal(body.allowed, true);
|
|
const names = body.providers.map((s) => s.provider).sort();
|
|
assert.deepEqual(names, ["claude", "codex"]);
|
|
// the single-pick field is still present and one of them
|
|
assert.ok(["claude", "codex"].includes(body.provider?.provider ?? ""));
|
|
});
|
|
|
|
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)"
|
|
);
|
|
});
|