diff --git a/changelog.d/features/11192-usage-command-providers-array.md b/changelog.d/features/11192-usage-command-providers-array.md new file mode 100644 index 0000000000..b7ef421109 --- /dev/null +++ b/changelog.d/features/11192-usage-command-providers-array.md @@ -0,0 +1 @@ +- **feat(api):** `/api/usage/om-usage?format=json` now returns `providers[]` — every connection's quota snapshot, not just the single selected one — so a panel can render Codex / Claude / OpenCode side by side. The collector already gathered all of them; the single-pick `provider` field (kept) is a terminal presentation choice. Closes the per-connection gap from OmniCopilot #8 ([#11192](https://github.com/diegosouzapw/OmniRoute/pull/11192)) diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 3e39f35eba..1b71551e37 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -663,7 +663,9 @@ refusal. On success: // 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": { /* … */ } } + "provider": { "connectionId": "…", "provider": "claude", "plan": "…", "quotas": { /* … */ } }, + // every connection's snapshot, so a UI can render several providers side by side: + "providers": [ { "connectionId": "…", "provider": "claude", /* … */ }, { "provider": "codex", /* … */ } ] } ``` diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts index 76d55315d9..37f5f009d1 100644 --- a/src/lib/usage/internalUsageCommand.ts +++ b/src/lib/usage/internalUsageCommand.ts @@ -546,6 +546,11 @@ export type UsageCommandJson = personal: unknown | null; /** The selected provider snapshot, or null when nothing is cached. */ provider: UsageSnapshot | null; + /** Every connection's snapshot, so a panel can render Codex / Claude / + * OpenCode side by side instead of only the selected one (#11191). The + * single-pick in `provider` is a presentation choice for a terminal; the + * collector already gathered all of them. */ + providers: UsageSnapshot[]; }; export async function buildUsageCommandJson( @@ -564,11 +569,9 @@ export async function buildUsageCommandJson( { now: resolvedDeps.now } ) : null; - const provider = selectUsageSnapshot( - await collectUsageSnapshots(metadata, resolvedDeps), - selection - ); - return { allowed: true, personal, provider }; + const snapshots = await collectUsageSnapshots(metadata, resolvedDeps); + const provider = selectUsageSnapshot(snapshots, selection); + return { allowed: true, personal, provider, providers: snapshots }; } export async function buildUsageCommandText( diff --git a/tests/unit/usage-command-json-format.test.ts b/tests/unit/usage-command-json-format.test.ts index f7d7b286c4..9f76d6e191 100644 --- a/tests/unit/usage-command-json-format.test.ts +++ b/tests/unit/usage-command-json-format.test.ts @@ -40,6 +40,7 @@ function allowedDeps(overrides: Record = {}) { }), getProviderConnections: async () => [ { id: "conn-claude", provider: "claude", isActive: true }, + { id: "conn-codex", provider: "codex", isActive: true }, ], getAllProviderLimitsCache: () => ({ "conn-claude": { @@ -50,6 +51,14 @@ function allowedDeps(overrides: Record = {}) { 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, @@ -95,6 +104,29 @@ test("om-usage without ?format stays text/plain (the historical contract)", asyn 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.