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>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
import type { ProviderFailure } from "@/lib/resilience/failureClassification";
|
|
import type { ProviderQuotaStatus } from "@/lib/quota/providerQuotaTelemetry";
|
|
|
|
export type AllocationDecision = "allow" | "warn" | "deny";
|
|
export type CircuitState = "closed" | "open" | "half_open";
|
|
|
|
export interface RoutingCandidate {
|
|
providerId: string;
|
|
modelId: string;
|
|
capabilityScore: number;
|
|
allocation: AllocationDecision;
|
|
healthScore: number;
|
|
circuit: CircuitState;
|
|
quota: ProviderQuotaStatus;
|
|
latencyMs?: number;
|
|
errorRate?: number;
|
|
modelPreference?: number;
|
|
costPreference?: number;
|
|
}
|
|
|
|
export interface RoutingExplanation {
|
|
providerId: string;
|
|
modelId: string;
|
|
score: number;
|
|
eligible: boolean;
|
|
reasons: string[];
|
|
factors: Record<string, number | string>;
|
|
}
|
|
|
|
export interface RankedRoutingResult {
|
|
selected: RoutingExplanation | null;
|
|
candidates: RoutingExplanation[];
|
|
}
|
|
|
|
function clamp(value: number, fallback = 0): number {
|
|
return Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback;
|
|
}
|
|
|
|
function quotaFactor(quota: ProviderQuotaStatus): number {
|
|
switch (quota) {
|
|
case "exhausted":
|
|
return 0;
|
|
case "approaching_limit":
|
|
return 0.65;
|
|
case "unavailable":
|
|
return 0.9;
|
|
case "unknown":
|
|
return 1;
|
|
case "healthy":
|
|
return 1;
|
|
default:
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
function latencyFactor(latencyMs?: number): number {
|
|
if (!Number.isFinite(latencyMs) || latencyMs === undefined) return 1;
|
|
return Math.max(0.4, 1 - Math.min(latencyMs, 30_000) / 50_000);
|
|
}
|
|
|
|
export function scoreCandidate(candidate: RoutingCandidate): RoutingExplanation {
|
|
const capability = clamp(candidate.capabilityScore);
|
|
const allocation =
|
|
candidate.allocation === "deny" ? 0 : candidate.allocation === "warn" ? 0.85 : 1;
|
|
const health = clamp(candidate.healthScore, 0.5);
|
|
const reliability = 1 - clamp(candidate.errorRate ?? 0);
|
|
const latency = latencyFactor(candidate.latencyMs);
|
|
const preference = clamp(candidate.modelPreference, 0.5);
|
|
const cost = clamp(candidate.costPreference, 1);
|
|
const quota = quotaFactor(candidate.quota);
|
|
const circuit = candidate.circuit === "open" ? 0 : candidate.circuit === "half_open" ? 0.5 : 1;
|
|
const score = Number(
|
|
(
|
|
capability *
|
|
allocation *
|
|
health *
|
|
reliability *
|
|
latency *
|
|
preference *
|
|
cost *
|
|
quota *
|
|
circuit
|
|
).toFixed(6)
|
|
);
|
|
const reasons = [
|
|
capability >= 0.8 ? "capability match" : "partial capability match",
|
|
candidate.allocation === "allow"
|
|
? "allocation permitted"
|
|
: candidate.allocation === "warn"
|
|
? "allocation permitted with warning"
|
|
: "allocation denied",
|
|
health >= 0.8 ? "provider healthy" : "provider health degraded",
|
|
`circuit ${candidate.circuit}`,
|
|
candidate.quota === "unknown"
|
|
? "quota state unknown but not exhausted"
|
|
: `quota ${candidate.quota}`,
|
|
];
|
|
if (candidate.latencyMs !== undefined)
|
|
reasons.push(`latency ${Math.round(candidate.latencyMs)}ms`);
|
|
if (candidate.errorRate !== undefined)
|
|
reasons.push(`${Math.round(candidate.errorRate * 100)}% recent errors`);
|
|
return {
|
|
providerId: candidate.providerId,
|
|
modelId: candidate.modelId,
|
|
score,
|
|
eligible:
|
|
score > 0 &&
|
|
candidate.allocation !== "deny" &&
|
|
candidate.circuit !== "open" &&
|
|
candidate.quota !== "exhausted",
|
|
reasons,
|
|
factors: {
|
|
capability,
|
|
allocation,
|
|
health,
|
|
reliability,
|
|
latency,
|
|
preference,
|
|
cost,
|
|
quota,
|
|
circuit,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function rankCandidates(candidates: RoutingCandidate[]): RankedRoutingResult {
|
|
const ranked = candidates.map(scoreCandidate).sort((a, b) => b.score - a.score);
|
|
return { selected: ranked.find((candidate) => candidate.eligible) ?? null, candidates: ranked };
|
|
}
|
|
|
|
export interface FailoverPolicy {
|
|
maxProviderAttempts: number;
|
|
allowCrossProviderFallback: boolean;
|
|
retryRateLimited: boolean;
|
|
retryTimeouts: boolean;
|
|
}
|
|
|
|
export const DEFAULT_FAILOVER_POLICY: FailoverPolicy = {
|
|
maxProviderAttempts: 3,
|
|
allowCrossProviderFallback: true,
|
|
retryRateLimited: true,
|
|
retryTimeouts: true,
|
|
};
|
|
|
|
export function shouldFailover(
|
|
failure: ProviderFailure,
|
|
policy = DEFAULT_FAILOVER_POLICY
|
|
): boolean {
|
|
if (!policy.allowCrossProviderFallback || !failure.retryable) return false;
|
|
if (failure.type === "rate_limit") return policy.retryRateLimited;
|
|
if (failure.type === "timeout") return policy.retryTimeouts;
|
|
return failure.type === "network_error" || failure.type === "provider_5xx";
|
|
}
|