diff --git a/src/app/api/usage/om-usage/route.ts b/src/app/api/usage/om-usage/route.ts new file mode 100644 index 0000000000..8253a51be7 --- /dev/null +++ b/src/app/api/usage/om-usage/route.ts @@ -0,0 +1,16 @@ +import { handleCorsOptions } from "@/shared/utils/cors"; +import { handleInternalUsageCommandHttpRequest } from "@/lib/usage/internalUsageCommand"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * GET /api/usage/om-usage + * + * Terminal-friendly equivalent of @@om-usage. Authenticates with the same + * OmniRoute API key used by Claude Code/Codex and requires allowUsageCommand. + */ +export async function GET(request: Request) { + return handleInternalUsageCommandHttpRequest(request); +} diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts index 1f1925898a..59d5f7c059 100644 --- a/src/lib/usage/internalUsageCommand.ts +++ b/src/lib/usage/internalUsageCommand.ts @@ -3,11 +3,13 @@ import { buildApiKeyUsageLimitText, type ApiKeyUsageLimitStatus, } from "@/lib/usage/apiKeyUsageLimits"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; export const INTERNAL_USAGE_COMMAND = "@@om-usage"; export const USAGE_COMMAND_DISABLED_MESSAGE = "Usage command is disabled for this API key."; const USAGE_COMMAND_AUTH_REQUIRED_MESSAGE = "Usage command requires an authenticated API key."; const LOCAL_USAGE_MODEL = "omniroute/local-usage"; +const TEXT_PLAIN_HEADERS = { "Content-Type": "text/plain; charset=utf-8" } as const; type JsonRecord = Record; @@ -34,6 +36,11 @@ interface UsageSnapshot { quotas: JsonRecord; } +interface UsageCommandSelection { + preferredProvider?: string | null; + preferredConnectionId?: string | null; +} + export interface InternalUsageCommandDeps { now?: () => number; isValidApiKey?: (apiKey: string) => Promise; @@ -350,7 +357,7 @@ function snapshotScore(snapshot: UsageSnapshot): number { return score; } -function selectUsageSnapshot(snapshots: UsageSnapshot[]): UsageSnapshot | null { +function selectBestUsageSnapshot(snapshots: UsageSnapshot[]): UsageSnapshot | null { let selected: UsageSnapshot | null = null; let bestScore = -1; for (const snapshot of snapshots) { @@ -363,6 +370,35 @@ function selectUsageSnapshot(snapshots: UsageSnapshot[]): UsageSnapshot | null { return selected; } +function normalizeProviderId(provider: string | null | undefined): string | null { + const normalized = provider?.trim().toLowerCase().replace(/_/g, "-"); + if (!normalized) return null; + if (normalized === "cc" || normalized === "claude-code" || normalized === "claudecode") { + return "claude"; + } + return normalized; +} + +function selectUsageSnapshot( + snapshots: UsageSnapshot[], + selection: UsageCommandSelection = {} +): UsageSnapshot | null { + const preferredConnectionId = selection.preferredConnectionId?.trim(); + if (preferredConnectionId) { + const snapshot = snapshots.find((entry) => entry.connectionId === preferredConnectionId); + if (snapshot) return snapshot; + } + + const preferredProvider = normalizeProviderId(selection.preferredProvider); + if (preferredProvider) { + return selectBestUsageSnapshot( + snapshots.filter((entry) => normalizeProviderId(entry.provider) === preferredProvider) + ); + } + + return selectBestUsageSnapshot(snapshots); +} + function appendQuotaBlock(lines: string[], label: string, quota: JsonRecord | null, now: number) { lines.push(label); lines.push(formatPercent(getQuotaUsedPercent(quota))); @@ -371,7 +407,8 @@ function appendQuotaBlock(lines: string[], label: string, quota: JsonRecord | nu export async function buildUsageCommandText( metadata: UsageCommandApiKeyMetadata, - deps: InternalUsageCommandDeps = {} + deps: InternalUsageCommandDeps = {}, + selection: UsageCommandSelection = {} ): Promise { const resolvedDeps = await normalizeDeps(deps); if (metadata.usageLimitEnabled === true) { @@ -381,7 +418,10 @@ export async function buildUsageCommandText( ); } - const snapshot = selectUsageSnapshot(await collectUsageSnapshots(metadata, resolvedDeps)); + const snapshot = selectUsageSnapshot( + await collectUsageSnapshots(metadata, resolvedDeps), + selection + ); if (!snapshot) { return ["Plan", "Unavailable", "", "Usage", "No cached usage data available."].join("\n"); @@ -403,6 +443,28 @@ function getResponseModel(body: unknown): string { : LOCAL_USAGE_MODEL; } +function inferHttpUsageCommandSelection(request: Request): UsageCommandSelection { + try { + const url = new URL(request.url, "http://localhost"); + return { + preferredConnectionId: + url.searchParams.get("connectionId")?.trim() || + readHeader(request, "x-omniroute-connection")?.trim() || + null, + preferredProvider: url.searchParams.get("provider")?.trim() || null, + }; + } catch { + return { + preferredConnectionId: readHeader(request, "x-omniroute-connection")?.trim() || null, + preferredProvider: null, + }; + } +} + +function createPlainUsageCommandResponse(text: string, status = 200): Response { + return new Response(text, { status, headers: TEXT_PLAIN_HEADERS }); +} + function isAnthropicRequest(request: Request): boolean { if (request.headers.has("anthropic-version")) return true; try { @@ -568,3 +630,32 @@ export async function handleInternalUsageCommand( await buildUsageCommandText(metadata, resolvedDeps) ); } + +export async function handleInternalUsageCommandHttpRequest( + request: Request, + deps: InternalUsageCommandDeps = {} +): Promise { + try { + const resolvedDeps = await normalizeDeps(deps); + const apiKey = extractUsageCommandApiKey(request); + if (!apiKey || !(await resolvedDeps.isValidApiKey(apiKey))) { + return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401); + } + + const metadata = await resolvedDeps.getApiKeyMetadata(apiKey); + if (!metadata?.id) { + return createPlainUsageCommandResponse(USAGE_COMMAND_AUTH_REQUIRED_MESSAGE, 401); + } + + if (metadata.allowUsageCommand !== true) { + return createPlainUsageCommandResponse(USAGE_COMMAND_DISABLED_MESSAGE, 403); + } + + return createPlainUsageCommandResponse( + await buildUsageCommandText(metadata, resolvedDeps, inferHttpUsageCommandSelection(request)) + ); + } catch (err) { + const body = buildErrorBody(500, err instanceof Error ? err.message : String(err)); + return Response.json(body, { status: 500 }); + } +} diff --git a/src/shared/constants/publicApiRoutes.ts b/src/shared/constants/publicApiRoutes.ts index 4319195db0..26df9df12b 100644 --- a/src/shared/constants/publicApiRoutes.ts +++ b/src/shared/constants/publicApiRoutes.ts @@ -14,6 +14,10 @@ const PUBLIC_API_ROUTE_PREFIXES = [ // access token. The handler enforces its own password check + lockout — there // is no token yet at this point, so it cannot require management auth. "/api/cli/connect", + // Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex). + // The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey + // and the allowUsageCommand flag — it must not be gated by management auth. + "/api/usage/om-usage", ]; const PUBLIC_READONLY_API_ROUTE_PREFIXES = [ diff --git a/tests/unit/authz/classify.test.ts b/tests/unit/authz/classify.test.ts index a081aefc05..c9a76bb950 100644 --- a/tests/unit/authz/classify.test.ts +++ b/tests/unit/authz/classify.test.ts @@ -176,6 +176,13 @@ const cases: Case[] = [ { name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" }, { name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" }, + { + name: "/api/usage/om-usage is PUBLIC (handler enforces its own API key auth)", + path: "/api/usage/om-usage", + method: "GET", + expectedClass: "PUBLIC", + }, + { name: "Unknown top-level path defaults MANAGEMENT (fail-closed)", path: "/totally-unknown", diff --git a/tests/unit/internal-usage-command.test.ts b/tests/unit/internal-usage-command.test.ts index 0960336f7f..4b806a587c 100644 --- a/tests/unit/internal-usage-command.test.ts +++ b/tests/unit/internal-usage-command.test.ts @@ -5,6 +5,7 @@ import { buildUsageCommandText, extractLastUserText, handleInternalUsageCommand, + handleInternalUsageCommandHttpRequest, isInternalUsageCommand, } from "../../src/lib/usage/internalUsageCommand.ts"; @@ -175,6 +176,174 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable ); }); +test("handleInternalUsageCommandHttpRequest returns terminal text for an allowed API key", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage?provider=claude", { + headers: { Authorization: "Bearer sk-allowed" }, + }), + { + now: () => NOW, + isValidApiKey: async (apiKey) => apiKey === "sk-allowed", + getApiKeyMetadata: async () => ({ + id: "key-allowed", + name: "Claude terminal", + allowedConnections: ["conn-codex", "conn-claude"], + allowUsageCommand: true, + }), + getProviderConnectionById: async (connectionId) => ({ + id: connectionId, + provider: connectionId === "conn-claude" ? "claude" : "codex", + isActive: true, + }), + getProviderConnections: async () => [], + getProviderLimitsCache: (connectionId) => + connectionId === "conn-claude" + ? { + plan: "Claude Max", + quotas: { + "session (5h)": { + used: 74, + total: 100, + remaining: 26, + resetAt: new Date(NOW + 2 * 60 * 60_000).toISOString(), + }, + "weekly (7d)": { + used: 25, + total: 100, + remaining: 75, + resetAt: new Date(NOW + 6 * 24 * 60 * 60_000).toISOString(), + }, + }, + message: null, + fetchedAt: new Date(NOW).toISOString(), + } + : { + plan: "Codex Pro", + quotas: { + weekly: { + used: 9, + total: 100, + remaining: 91, + resetAt: new Date(NOW + 5 * 24 * 60 * 60_000).toISOString(), + }, + }, + message: null, + fetchedAt: new Date(NOW).toISOString(), + }, + getAllProviderLimitsCache: () => ({}), + getApiKeyUsageLimitStatus: async () => { + throw new Error("usage limit lookup must not run for provider quota output"); + }, + } + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal( + await response.text(), + [ + "Plan", + "Claude Max", + "", + "Usage", + "Session (5hr)", + "74%", + "Resets in 2h", + "", + "Weekly (7 day)", + "25%", + "Resets in 6d", + "", + "Weekly Sonnet", + "Unavailable", + "Resets in unknown", + ].join("\n") + ); +}); + +test("handleInternalUsageCommandHttpRequest sanitizes internal errors and never leaks stack traces", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage", { + headers: { Authorization: "Bearer sk-boom" }, + }), + { + isValidApiKey: async () => { + throw new Error( + `boom at /home/diegosouzapw/dev/proxys/OmniRoute/src/lib/usage/internalUsageCommand.ts:1:1` + ); + }, + getApiKeyMetadata: async () => { + throw new Error("metadata lookup must not run when auth check throws"); + }, + getProviderConnectionById: async () => null, + getProviderConnections: async () => [], + getProviderLimitsCache: () => null, + getAllProviderLimitsCache: () => ({}), + getApiKeyUsageLimitStatus: async () => { + throw new Error("usage limit lookup must not run when auth check throws"); + }, + } + ); + + assert.equal(response.status, 500); + assert.equal(response.headers.get("content-type"), "application/json"); + const body = await response.json(); + assert.equal(typeof body.error.message, "string"); + assert.equal(body.error.message.includes("at /"), false); + assert.equal(body.error.message.includes("internalUsageCommand.ts"), false); +}); + +test("handleInternalUsageCommandHttpRequest rejects invalid API keys as plain text", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage", { + headers: { Authorization: "Bearer sk-invalid" }, + }), + { + isValidApiKey: async () => false, + getApiKeyMetadata: async () => { + throw new Error("metadata lookup must not run for invalid keys"); + }, + getProviderConnectionById: async () => null, + getProviderConnections: async () => [], + getProviderLimitsCache: () => null, + getAllProviderLimitsCache: () => ({}), + getApiKeyUsageLimitStatus: async () => { + throw new Error("usage limit lookup must not run for invalid keys"); + }, + } + ); + + assert.equal(response.status, 401); + assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal(await response.text(), "Usage command requires an authenticated API key."); +}); + +test("handleInternalUsageCommandHttpRequest rejects API keys without usage command access", async () => { + const response = await handleInternalUsageCommandHttpRequest( + new Request("http://localhost/api/usage/om-usage", { + headers: { Authorization: "Bearer sk-disabled" }, + }), + { + isValidApiKey: async () => true, + getApiKeyMetadata: async () => ({ + id: "key-disabled", + allowUsageCommand: false, + }), + getProviderConnectionById: async () => null, + getProviderConnections: async () => [], + getProviderLimitsCache: () => null, + getAllProviderLimitsCache: () => ({}), + getApiKeyUsageLimitStatus: async () => { + throw new Error("usage limit lookup must not run for disabled keys"); + }, + } + ); + + assert.equal(response.status, 403); + assert.equal(response.headers.get("content-type"), "text/plain; charset=utf-8"); + assert.equal(await response.text(), "Usage command is disabled for this API key."); +}); + test("handleInternalUsageCommand returns disabled response locally without provider routing", async () => { const response = await handleInternalUsageCommand( new Request("http://localhost/v1/chat/completions", { diff --git a/tests/unit/public-api-routes.test.ts b/tests/unit/public-api-routes.test.ts index e30b1f809e..00b2b02ecf 100644 --- a/tests/unit/public-api-routes.test.ts +++ b/tests/unit/public-api-routes.test.ts @@ -25,3 +25,9 @@ test("isPublicApiRoute rejects non-public management routes", () => { assert.equal(isPublicApiRoute("/api/settings"), false); assert.equal(isPublicApiRoute("/api/providers"), false); }); + +test("isPublicApiRoute allows /api/usage/om-usage (handler enforces its own API key auth)", () => { + assert.equal(isPublicApiRoute("/api/usage/om-usage"), true); + assert.equal(isPublicApiRoute("/api/usage/om-usage", "GET"), true); + assert.equal(isPublicApiRoute("/api/usage/om-usage", "OPTIONS"), true); +});