mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +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 = [
|
||||
|
||||
Reference in New Issue
Block a user