mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 05:32:19 +03:00
* feat(api): add provider quota telemetry, adaptive routing, and status inventory Adds a read-only OmniRoute status/inventory surface plus supporting resilience and usage-tracking infrastructure: - src/lib/quota/providerQuotaTelemetry.ts, providerCapabilities.ts: provider quota state and capability signals, sourced from configured metadata rather than invented values; unknown stays unknown. - src/lib/resilience/adaptiveCircuit.ts, failureClassification.ts: circuit state with lazy recovery and explicit failure classification. - src/lib/usage/usageLedger.ts, budgetGuard.ts, modelPricingRegistry.ts: internal usage tracking and budget allow/warn/deny decisions, kept separate from upstream-reported quota (never conflated). - src/lib/routing/adaptiveRouting.ts: excludes exhausted-quota and open-circuit candidates from routing, penalizes approaching-limit. - src/lib/omnirouteStatus.ts + src/app/api/omniroute/status, route/preview: read-only status endpoint; never issues a live upstream model request (asserted via liveRequestExecuted: false). - src/lib/db/quotaPools.ts: adds ensurePool() for idempotent pool management by automation/CLI callers, following the existing group-demo default-group convention. - scripts/omniroute-verify.mjs (+ omniroute:verify script): local verification against the running gateway. 9 new unit tests, all passing. typecheck:core clean relative to base (release/v3.8.50) -- the 2 pre-existing gateways.ts errors are tracked separately in #9985 and untouched by this change. * test(cli): align cli-machine-token assertions with HMAC-SHA256 64-char format The quota-telemetry feature hardens cliToken to HMAC-SHA256(machineId, SALT) (64-char hex, pristine machine id). Update the regression test to the new format and mirror the production derivation in the different-machine-id check. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: adevwithpurpose <adevwithpurpose@users.noreply.github.com> Co-authored-by: desamours-hub <desamours-hub@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
155 lines
4.5 KiB
JavaScript
155 lines
4.5 KiB
JavaScript
import { apiFetch, isServerUp } from "../api.mjs";
|
|
import { t } from "../i18n.mjs";
|
|
|
|
export function registerQuota(program) {
|
|
const quota = program
|
|
.command("quota")
|
|
.description(t("quota.description"))
|
|
.option("--provider <id>", "Filter by provider")
|
|
.option("--json", "Output as JSON")
|
|
.action(async (opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output });
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
});
|
|
|
|
quota
|
|
.command("status")
|
|
.description("Show truthful OmniRoute gateway, quota, pool, and circuit state")
|
|
.action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals()));
|
|
|
|
quota
|
|
.command("preview")
|
|
.description("Preview allocation enforcement without an upstream request")
|
|
.requiredOption("--api-key-id <id>", "API key id")
|
|
.requiredOption("--pool-id <id>", "quota pool id")
|
|
.option("--tokens <n>", "estimated token usage")
|
|
.action(async (opts, cmd) => {
|
|
const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId });
|
|
if (opts.tokens != null) params.set("estimatedTokens", opts.tokens);
|
|
await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals());
|
|
});
|
|
|
|
quota
|
|
.command("ensure <json>")
|
|
.description("Idempotently create or update a quota pool from a JSON object")
|
|
.action(async (json, opts, cmd) => {
|
|
let body;
|
|
try {
|
|
body = JSON.parse(json);
|
|
} catch {
|
|
console.error("Invalid pool JSON");
|
|
process.exit(2);
|
|
}
|
|
await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), {
|
|
method: "POST",
|
|
body,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runBoundedJson(path, opts, request = {}) {
|
|
const started = performance.now();
|
|
const res = await apiFetch(path, {
|
|
...request,
|
|
retry: false,
|
|
timeout: Math.min(opts.timeout ?? 5000, 5000),
|
|
acceptNotOk: true,
|
|
});
|
|
const elapsed = Math.round(performance.now() - started);
|
|
if (process.env.OMNIROUTE_DEBUG === "1") {
|
|
console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`);
|
|
}
|
|
const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
|
if (!res.ok) {
|
|
console.error(JSON.stringify(payload));
|
|
process.exit(res.exitCode ?? 1);
|
|
}
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
}
|
|
|
|
export async function runQuotaCommand(opts = {}) {
|
|
const serverUp = await isServerUp();
|
|
if (!serverUp) {
|
|
console.error(t("quota.noServer"));
|
|
return 1;
|
|
}
|
|
|
|
let quotaData = null;
|
|
|
|
try {
|
|
const res = await apiFetch("/api/quota", { retry: false, timeout: 5000, acceptNotOk: true });
|
|
if (res.ok) quotaData = await res.json();
|
|
} catch {}
|
|
|
|
if (!quotaData) {
|
|
try {
|
|
const res = await apiFetch("/api/v1/providers", {
|
|
retry: false,
|
|
timeout: 5000,
|
|
acceptNotOk: true,
|
|
});
|
|
if (res.ok) {
|
|
const providers = await res.json();
|
|
quotaData = {
|
|
providers: providers.map((p) => ({
|
|
provider: p.name || p.id,
|
|
quota: p.quota || p.remaining || "N/A",
|
|
used: p.used || 0,
|
|
reset: p.resetAt || "N/A",
|
|
})),
|
|
};
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
if (opts.json || opts.output === "json") {
|
|
console.log(JSON.stringify(quotaData || { error: "No quota data" }, null, 2));
|
|
return 0;
|
|
}
|
|
|
|
if (!quotaData?.providers) {
|
|
console.log(t("quota.noData"));
|
|
return 0;
|
|
}
|
|
|
|
let providers = quotaData.providers;
|
|
if (opts.provider) {
|
|
const filter = opts.provider.toLowerCase();
|
|
providers = providers.filter((p) => p.provider.toLowerCase().includes(filter));
|
|
}
|
|
|
|
console.log(`\n\x1b[1m\x1b[36mProvider Quota Usage\x1b[0m\n`);
|
|
console.log(
|
|
"\x1b[36m" +
|
|
" Provider".padEnd(25) +
|
|
"Used".padEnd(15) +
|
|
"Remaining".padEnd(20) +
|
|
"Reset\x1b[0m"
|
|
);
|
|
console.log(
|
|
"\x1b[2m " +
|
|
"─".repeat(24) +
|
|
" " +
|
|
"─".repeat(14) +
|
|
" " +
|
|
"─".repeat(19) +
|
|
" " +
|
|
"─".repeat(15) +
|
|
"\x1b[0m"
|
|
);
|
|
|
|
for (const p of providers) {
|
|
const provider = (p.provider || "unknown").slice(0, 23).padEnd(25);
|
|
const used = String(p.used || 0).padEnd(15);
|
|
const remaining = String(p.quota || p.remaining || "N/A")
|
|
.slice(0, 18)
|
|
.padEnd(20);
|
|
const reset = p.reset || "N/A";
|
|
console.log(` ${provider}${used}${remaining}${reset}`);
|
|
}
|
|
|
|
console.log(`\n \x1b[32mTotal: ${providers.length} providers\x1b[0m`);
|
|
return 0;
|
|
}
|