mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 13:52:28 +03:00
* feat(routing): self-hosted unified OpenAI-compatible entry (RIC-738) Divert /v1/chat/completions through the self-hosted provider adapters when OMNIROUTE_SELF_HOSTED_PROVIDERS / OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE is set: one OpenAI-compatible contract in, auto-route to the selected provider (x-omniroute-provider header, provider/model prefix, or first provider), standard OpenAI error shape out. Optional OMNIROUTE_SELF_HOSTED_API_KEY guards the entry (D5 reserved); unset = open loopback route. Upstream credentials stay runtime-only and are stripped from echoed responses. Brings in the provider-adapters baseline from sibling branch (RIC-737) that this entry depends on. Includes 21 passing unit tests (provider selection, model-prefix forwarding, header hygiene, auth, error normalization, SSE passthrough, fall-through/misconfig), docs, env example, changelog fragment. * feat(routing): deterministic routing strategies for self-hosted entry (RIC-740) Add the M2 deterministic routing strategy engine (D3 可审计路由) to the self-hosted unified entry: a declarative `strategy:` block expressing five explainable, non-predictive policies — blacklist/whitelist hard filters, cooldown circuit breaker, cost-priority, latency-aware ordering, and an explicit fallback chain. The ordered candidate list is the fallback chain: a failed primary (network or non-2xx) falls through to the next candidate and each failure feeds the breaker. Every response carries an x-omniroute-route-decision header answering "why this model / why not that one". A pinned provider rejected by a hard filter returns 400 (never a silent re-route); no eligible providers returns 503 with the full explainable decision. No ML/predict dependency. Covers the RIC-740 acceptance: 5 strategy types with unit tests + HTTP fault-injection tests (primary down -> fallback works), config matching docs, and no predict/ML deps. Adds docs, .env.example entries, and a changelog fragment. * refactor(routing): reduce complexity-ratchet violations in new self-hosted routing files Extract cost/id validation, pin-blocked resolution, ordering, and env/file source resolution into small helpers so routingStrategies.ts and selfHostedEntry.ts stay under the complexity-ratchets cap. No behavior change — the same 51 routing-strategies/self-hosted-entry/provider-adapters tests pass unmodified. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(routing): document the 5 self-hosted env vars in ENVIRONMENT.md check:env-doc-sync failed because OMNIROUTE_SELF_HOSTED_PROVIDERS(_FILE), OMNIROUTE_SELF_HOSTED_API_KEY and OMNIROUTE_SELF_HOSTED_STRATEGY(_FILE) were present in .env.example but missing from docs/reference/ENVIRONMENT.md. Add them under "6. Tool & Routing Policies". Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Ant Rich <ant@richants.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: luyuehm <luyuehm@users.noreply.github.com>
433 lines
16 KiB
TypeScript
433 lines
16 KiB
TypeScript
/**
|
|
* Self-hosted unified OpenAI-compatible entry (D4 — 接入即用).
|
|
*
|
|
* Exposes OmniRoute's existing /v1/chat/completions route as a self-hosted
|
|
* gateway: one OpenAI-compatible contract in, auto-route to the runtime-selected
|
|
* provider, standard OpenAI error shape out.
|
|
*
|
|
* Design (KISS / RIC-738 + RIC-740):
|
|
* - Reuses the provider adapters from `providerAdapters.ts` (RIC-737) — no new
|
|
* abstraction layer. This module only orchestrates: load config → auth → pick
|
|
* provider → dispatch → normalize response.
|
|
* - Config is declarative YAML (env string or file path); credentials live in
|
|
* the file/env at runtime and are never logged or persisted (RIC-737 contract).
|
|
* - Auto-route = header override -> model-prefix match -> deterministic strategy
|
|
* (`routingStrategies.ts`, M2/RIC-740). Every decision is explainable via the
|
|
* `x-omniroute-route-decision` response header — no predictive model.
|
|
* - The API-key check is a scaffold reserved for the D5 quota-key system: when
|
|
* `OMNIROUTE_SELF_HOSTED_API_KEY` is unset the route is open (loopback /
|
|
* trusted-network deployment), exactly like the existing self-hosted local
|
|
* providers.
|
|
*/
|
|
|
|
import * as yaml from "js-yaml";
|
|
import { readFile } from "node:fs/promises";
|
|
import { errorResponse, buildErrorBody, parseUpstreamError } from "../utils/error.ts";
|
|
import { stripSensitiveResponseHeaders } from "../utils/upstreamResponseHeaders.ts";
|
|
import type { ChatRequest, ProviderConfig } from "./providerAdapters.ts";
|
|
import { ProviderRouter } from "./providerAdapters.ts";
|
|
import {
|
|
STRATEGY_ENV,
|
|
STRATEGY_FILE_ENV,
|
|
DeterministicRoutingEngine,
|
|
parseSelfHostedRoutingConfig,
|
|
parseStrategyConfig,
|
|
type StrategyConfig,
|
|
} from "./routingStrategies.ts";
|
|
|
|
/** Env var holding the inline YAML provider config (runtime-only credentials). */
|
|
export const CONFIG_ENV = "OMNIROUTE_SELF_HOSTED_PROVIDERS";
|
|
/** Env var pointing at a YAML file with the provider config. */
|
|
export const CONFIG_FILE_ENV = "OMNIROUTE_SELF_HOSTED_PROVIDERS_FILE";
|
|
/** Optional shared API key for the unified entry (D5 reserved). */
|
|
export const API_KEY_ENV = "OMNIROUTE_SELF_HOSTED_API_KEY";
|
|
/** Provider-selector header recognized by the unified entry. */
|
|
export const PROVIDER_SELECTOR_HEADER = "x-omniroute-provider";
|
|
/** Marker header added to responses routed through the unified entry. */
|
|
export const ROUTED_BY_HEADER = "x-omniroute-routed-by";
|
|
/** Explainability header carrying the deterministic route decision one-liner. */
|
|
export const ROUTE_DECISION_HEADER = "x-omniroute-route-decision";
|
|
const ROUTED_BY_VALUE = "self-hosted-openai-compat";
|
|
|
|
export interface SelfHostedOptions {
|
|
providers?: string;
|
|
providersFile?: string;
|
|
apiKey?: string;
|
|
/** Inline YAML `strategy:` document (M2/RIC-740, optional). */
|
|
strategy?: string;
|
|
/** Path to a YAML file holding the `strategy:` document (optional). */
|
|
strategyFile?: string;
|
|
}
|
|
|
|
export interface SelfHostedRuntime {
|
|
router: ProviderRouter;
|
|
engine: DeterministicRoutingEngine;
|
|
}
|
|
|
|
let cachedRuntime: SelfHostedRuntime | null = null;
|
|
let cachedRuntimeSignature = "";
|
|
let failedConfigLoad: string | null = null;
|
|
|
|
interface SelfHostedSources {
|
|
providersFile?: string;
|
|
providersSource: string;
|
|
strategySource: string;
|
|
}
|
|
|
|
/** Inline YAML text wins; otherwise read the file (if any); otherwise empty. */
|
|
async function resolveTextSource(
|
|
inline: string | undefined,
|
|
filePath: string | undefined
|
|
): Promise<string> {
|
|
if (inline) return inline;
|
|
return filePath ? await readFile(filePath, "utf8") : "";
|
|
}
|
|
|
|
/** Resolve the raw providers/strategy YAML text from env/file. `null` = unconfigured. */
|
|
async function resolveSelfHostedSources(
|
|
options: SelfHostedOptions
|
|
): Promise<SelfHostedSources | null> {
|
|
const providersYaml = options.providers ?? process.env[CONFIG_ENV] ?? undefined;
|
|
const providersFile = options.providersFile ?? process.env[CONFIG_FILE_ENV] ?? undefined;
|
|
const strategyInline = options.strategy ?? process.env[STRATEGY_ENV] ?? undefined;
|
|
const strategyFile = options.strategyFile ?? process.env[STRATEGY_FILE_ENV] ?? undefined;
|
|
|
|
if (!providersYaml && !providersFile) return null;
|
|
|
|
const providersSource = await resolveTextSource(providersYaml, providersFile);
|
|
const strategySource = await resolveTextSource(strategyInline, strategyFile);
|
|
|
|
return { providersFile, providersSource, strategySource };
|
|
}
|
|
|
|
/** Content signature used to skip re-parsing when nothing has changed. */
|
|
function signatureFor(sources: SelfHostedSources): string {
|
|
return [
|
|
sources.providersFile ?? "",
|
|
sources.providersSource.length,
|
|
sources.providersSource.slice(0, 64),
|
|
sources.strategySource.length,
|
|
sources.strategySource.slice(0, 64),
|
|
].join(":");
|
|
}
|
|
|
|
/** Parse providers + strategy and build the router/engine pair. May throw. */
|
|
function buildSelfHostedRuntime(sources: SelfHostedSources): SelfHostedRuntime {
|
|
const parsed = parseSelfHostedRoutingConfig(sources.providersSource);
|
|
// An explicit strategy env/file overrides the inline `strategy:` block per-key.
|
|
let strategy: StrategyConfig = parsed.strategy;
|
|
if (sources.strategySource.trim()) {
|
|
strategy = { ...strategy, ...parseStrategyConfig(yaml.load(sources.strategySource)) };
|
|
}
|
|
return {
|
|
router: new ProviderRouter({ providers: parsed.providers }),
|
|
engine: new DeterministicRoutingEngine(parsed.providers, strategy),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve provider config + strategy from env/file; caches by content signature.
|
|
* Returns `null` when no provider config is present at all.
|
|
*/
|
|
async function loadSelfHostedRuntime(
|
|
options: SelfHostedOptions
|
|
): Promise<SelfHostedRuntime | null> {
|
|
const sources = await resolveSelfHostedSources(options);
|
|
if (!sources) return null;
|
|
|
|
const signature = signatureFor(sources);
|
|
if (cachedRuntime && cachedRuntimeSignature === signature) {
|
|
return cachedRuntime;
|
|
}
|
|
|
|
try {
|
|
cachedRuntime = buildSelfHostedRuntime(sources);
|
|
cachedRuntimeSignature = signature;
|
|
failedConfigLoad = null;
|
|
return cachedRuntime;
|
|
} catch (error) {
|
|
failedConfigLoad = error instanceof Error ? error.message : String(error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve provider config from env/file (back-compat shim — returns the router
|
|
* only). The strategy-aware runtime is built internally by the dispatch path.
|
|
*/
|
|
export async function loadSelfHostedConfig(
|
|
options: SelfHostedOptions = {}
|
|
): Promise<ProviderRouter | null> {
|
|
const runtime = await loadSelfHostedRuntime(options);
|
|
return runtime?.router ?? null;
|
|
}
|
|
|
|
/**
|
|
* Parse the providers document. Flat `providers:` list (see providerAdapters).
|
|
* Public because tests seed config through it directly.
|
|
*/
|
|
export function parseProvidersYaml(source: string): { providers: ProviderConfig[] } {
|
|
const parsed = parseSelfHostedRoutingConfig(source);
|
|
return { providers: parsed.providers };
|
|
}
|
|
|
|
/** Runtime-only API key check. `null` = no key configured (open route). */
|
|
export function resolveSelfHostedApiKey(options: SelfHostedOptions = {}): string | null {
|
|
return options.apiKey ?? process.env[API_KEY_ENV] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Provider id normalized for auto-route matching: strips a leading provider
|
|
* prefix separated by `/` or `::` (e.g. `openai/gpt-4o` or `local::llama3`
|
|
* -> `gpt-4o` / `llama3`), matching the m2 deterministic-routing convention.
|
|
*/
|
|
export function splitProviderModel(value: unknown): { provider?: string; model?: string } {
|
|
if (typeof value !== "string") return {};
|
|
// `provider/model` (single slash) takes precedence over `provider::model`
|
|
// (double colon) — a `local::llama3` model already contains a colon.
|
|
const slash = value.indexOf("/");
|
|
if (slash !== -1 && slash > 0 && slash < value.length - 1) {
|
|
return { provider: value.slice(0, slash), model: value.slice(slash + 1) };
|
|
}
|
|
const doubleColon = value.indexOf("::");
|
|
if (doubleColon !== -1 && doubleColon > 0 && doubleColon < value.length - 2) {
|
|
return { provider: value.slice(0, doubleColon), model: value.slice(doubleColon + 2) };
|
|
}
|
|
return { model: value };
|
|
}
|
|
|
|
/**
|
|
* Select the provider to route a request to (single-attempt, no fallback).
|
|
*
|
|
* Precedence (deterministic, documented in the README):
|
|
* 1. `x-omniroute-provider` header (exact provider id) — remote client control.
|
|
* 2. `model` prefix match (`provider/model` or `provider::model`).
|
|
* 3. First configured provider (`ProviderRouter.select()` default).
|
|
*
|
|
* The dispatch path (`completeViaSelfHostedRouter`) uses the strategy engine for
|
|
* the same selection + fallback; this function is kept for compatibility/tests.
|
|
*/
|
|
export function selectSelfHostedProvider(
|
|
router: ProviderRouter,
|
|
request: Request,
|
|
body: { model?: unknown } | null
|
|
): ProviderConfig {
|
|
const headerId = request.headers.get(PROVIDER_SELECTOR_HEADER)?.trim();
|
|
if (headerId) {
|
|
try {
|
|
return router.select(headerId);
|
|
} catch {
|
|
// fall through to model-prefix / first-provider resolution
|
|
}
|
|
}
|
|
const { provider } = splitProviderModel(body?.model);
|
|
if (provider) {
|
|
try {
|
|
return router.select(provider);
|
|
} catch {
|
|
// fall through to first-provider resolution
|
|
}
|
|
}
|
|
return router.select(undefined);
|
|
}
|
|
|
|
/**
|
|
* Resolve the explicitly pinned provider id (header, then model prefix), without
|
|
* falling through to the first provider. Unknown pins yield `undefined` — the
|
|
* strategy engine then treats the request as unpinned and applies its full
|
|
* candidate pipeline.
|
|
*/
|
|
function resolvePinnedProviderId(
|
|
router: ProviderRouter,
|
|
request: Request,
|
|
body: { model?: unknown } | null
|
|
): string | undefined {
|
|
const headerId = request.headers.get(PROVIDER_SELECTOR_HEADER)?.trim();
|
|
if (headerId) {
|
|
try {
|
|
router.select(headerId);
|
|
return headerId;
|
|
} catch {
|
|
// unknown header id — fall through to model-prefix resolution
|
|
}
|
|
}
|
|
const { provider } = splitProviderModel(body?.model);
|
|
if (provider) {
|
|
try {
|
|
router.select(provider);
|
|
return provider;
|
|
} catch {
|
|
// unknown prefix — no pin
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function buildErrorResponse(statusCode: number, message: string): Response {
|
|
return errorResponse(statusCode, message, { type: "invalid_request_error" });
|
|
}
|
|
|
|
/** Headers echoed on upstream pass-through, plus the routed-by marker. */
|
|
function passthroughHeaders(upstream: Headers): Headers {
|
|
const headers = stripSensitiveResponseHeaders(upstream);
|
|
headers.set(ROUTED_BY_HEADER, ROUTED_BY_VALUE);
|
|
if (!headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Normalize an upstream Response into the OpenAI-compatible unified response.
|
|
* Non-2xx upstream bodies are parsed through `parseUpstreamError` + `buildErrorBody`
|
|
* so the client always receives a valid OpenAI-shaped JSON error (Hard Rule #12).
|
|
* `routeDecision` (when present) is echoed as the explainability header.
|
|
*/
|
|
export async function normalizeProviderResponse(
|
|
upstream: Response,
|
|
routeDecision?: string
|
|
): Promise<Response> {
|
|
const headers = passthroughHeaders(upstream.headers);
|
|
if (routeDecision) headers.set(ROUTE_DECISION_HEADER, routeDecision);
|
|
if (!upstream.ok) {
|
|
const parsed = await parseUpstreamError(upstream, null);
|
|
const errorBody = buildErrorBody(parsed.statusCode, parsed.message, parsed.responseBody);
|
|
headers.set("Content-Type", "application/json");
|
|
return new Response(JSON.stringify(errorBody), { status: parsed.statusCode, headers });
|
|
}
|
|
|
|
if (upstream.body) {
|
|
return new Response(upstream.body, { status: upstream.status, headers });
|
|
}
|
|
|
|
return new Response(upstream.body, { status: upstream.status, headers });
|
|
}
|
|
|
|
/** Build the body forwarded to the selected provider (bare model, prefix stripped). */
|
|
function buildForwardedBody(body: Record<string, unknown> | null): ChatRequest {
|
|
const baseBody = {
|
|
messages: Array.isArray(body?.messages) ? body.messages : [],
|
|
...(body ?? {}),
|
|
};
|
|
const { provider, model } = splitProviderModel(body?.model);
|
|
return provider ? { ...baseBody, model } : baseBody;
|
|
}
|
|
|
|
/**
|
|
* Dispatch a chat/completions request through the deterministic routing engine:
|
|
* build the ordered candidate list, walk it as a fallback chain, record
|
|
* success/failure/latency per candidate, and normalize the final response.
|
|
*/
|
|
export async function completeViaSelfHostedRouter(
|
|
config: SelfHostedOptions,
|
|
request: Request,
|
|
body: Record<string, unknown> | null
|
|
): Promise<Response> {
|
|
const runtime = await loadSelfHostedRuntime(config);
|
|
if (!runtime) {
|
|
const detail = failedConfigLoad ? ` (${failedConfigLoad})` : "";
|
|
return buildErrorResponse(
|
|
500,
|
|
`Self-hosted provider config unavailable${detail}. Set ${CONFIG_ENV} or ${CONFIG_FILE_ENV}.`
|
|
);
|
|
}
|
|
|
|
const pinnedId = resolvePinnedProviderId(runtime.router, request, body);
|
|
const decision = runtime.engine.candidates(pinnedId);
|
|
|
|
// An explicit pin rejected by a hard filter is an error, not a silent re-route.
|
|
if (decision.pinBlocked) {
|
|
return errorResponse(
|
|
400,
|
|
`Provider "${decision.pinBlocked.providerId}" is not usable: ${decision.pinBlocked.reason}`,
|
|
{ type: "invalid_request_error" }
|
|
);
|
|
}
|
|
|
|
if (decision.candidates.length === 0) {
|
|
const explain = runtime.engine.explainCandidates([], decision.excluded);
|
|
return errorResponse(503, `No eligible providers for this request (${explain})`, {
|
|
type: "provider_error",
|
|
code: "no_eligible_providers",
|
|
});
|
|
}
|
|
|
|
const routeDecision = runtime.engine.explainCandidates(decision.candidates, decision.excluded);
|
|
const forwardedBody = buildForwardedBody(body);
|
|
let lastError: Response | null = null;
|
|
|
|
for (const candidate of decision.candidates) {
|
|
const attemptStart = Date.now();
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await runtime.router.complete(forwardedBody, candidate.provider.id);
|
|
} catch (error) {
|
|
// Network-level failure (connection refused / DNS / TLS) never yields an HTTP
|
|
// status to normalize. Record the failure, keep a stable 502, and fall through
|
|
// to the next candidate in the chain.
|
|
runtime.engine.recordFailure(candidate.provider.id, Date.now());
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
lastError = buildErrorResponse(502, `Upstream provider unreachable: ${detail}`);
|
|
continue;
|
|
}
|
|
runtime.engine.recordLatency(candidate.provider.id, Date.now() - attemptStart);
|
|
|
|
if (upstream.ok) {
|
|
runtime.engine.recordSuccess(candidate.provider.id);
|
|
return normalizeProviderResponse(upstream, routeDecision);
|
|
}
|
|
|
|
// Upstream answered non-2xx — the client must never see a raw provider body,
|
|
// and the failure feeds the cooldown breaker for the next request.
|
|
runtime.engine.recordFailure(candidate.provider.id, Date.now());
|
|
lastError = await normalizeProviderResponse(upstream, routeDecision);
|
|
}
|
|
|
|
// Every candidate exhausted — return the last normalized error.
|
|
if (lastError) return lastError;
|
|
|
|
return buildErrorResponse(503, "No eligible providers for this request");
|
|
}
|
|
|
|
/**
|
|
* Route entry — the full pipeline the /v1/chat/completions divert branch calls.
|
|
*
|
|
* Returns `null` ONLY when no self-hosted provider config is present at all
|
|
* (caller continues with the normal cloud pipeline). When config exists but
|
|
* failed to load/parse, returns a 500 error instead — a misconfigured entry
|
|
* must never silently fall through to cloud routing.
|
|
*/
|
|
export async function handleSelfHostedCompletions(
|
|
request: Request,
|
|
body: Record<string, unknown> | null,
|
|
options: SelfHostedOptions = {}
|
|
): Promise<Response | null> {
|
|
const isConfigured = Boolean(
|
|
options.providers ??
|
|
options.providersFile ??
|
|
process.env[CONFIG_ENV] ??
|
|
process.env[CONFIG_FILE_ENV]
|
|
);
|
|
if (!isConfigured) return null;
|
|
|
|
const runtime = await loadSelfHostedRuntime(options);
|
|
if (!runtime) {
|
|
const detail = failedConfigLoad ? ` (${failedConfigLoad})` : "";
|
|
return buildErrorResponse(
|
|
500,
|
|
`Self-hosted provider config unavailable${detail}. Check ${CONFIG_ENV} or ${CONFIG_FILE_ENV}.`
|
|
);
|
|
}
|
|
|
|
const apiKey = resolveSelfHostedApiKey(options);
|
|
if (apiKey) {
|
|
const authHeader = request.headers.get("authorization") ?? "";
|
|
const expected = `Bearer ${apiKey}`;
|
|
if (authHeader !== expected) {
|
|
return errorResponse(401, "Invalid API key", { type: "authentication_error" });
|
|
}
|
|
}
|
|
|
|
return completeViaSelfHostedRouter(options, request, body);
|
|
}
|