mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix: restore om-usage HTTP endpoint (#5859)
Integrated into release/v3.8.43
This commit is contained in:
16
src/app/api/usage/om-usage/route.ts
Normal file
16
src/app/api/usage/om-usage/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<boolean>;
|
||||
@@ -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<string> {
|
||||
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<Response> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user