Files
OmniRoute/tests/unit/provider-adapters.test.ts
luyuehm 5a82da7084 feat(routing): deterministic routing strategies for self-hosted entry (RIC-740) (#13611)
* 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>
2026-09-18 11:59:15 -03:00

39 lines
1.9 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
ProviderRouter,
parseProviderConfig,
publicProviderConfigs,
} from "../../open-sse/services/providerAdapters.ts";
describe("provider adapters", () => {
it("parses YAML and redacts credentials", () => {
const config = parseProviderConfig(
"providers:\n - id: cloud\n kind: openai\n baseUrl: https://api.openai.com/v1\n model: gpt-4o\n apiKey: secret"
);
assert.equal(config.providers.length, 1);
assert.equal(publicProviderConfigs(config)[0].apiKey, undefined);
});
it("routes OpenAI, Anthropic, and local requests", async () => {
const router = new ProviderRouter(
parseProviderConfig(
"providers:\n - id: openai\n kind: openai\n baseUrl: https://api.openai.com/v1\n model: gpt-4o\n apiKey: oa-key\n - id: claude\n kind: anthropic\n baseUrl: https://api.anthropic.com/v1\n model: claude-sonnet\n apiKey: an-key\n - id: local\n kind: local\n baseUrl: http://localhost:11434/v1\n model: llama3"
)
);
const calls: Array<{ url: string; init: RequestInit }> = [];
const fakeFetch = async (url: string | URL, init?: RequestInit) => {
calls.push({ url: String(url), init: init! });
return new Response("{}");
};
const request = { messages: [{ role: "user", content: "hi" }] };
await router.complete(request, "openai", fakeFetch);
await router.complete(request, "claude", fakeFetch);
await router.complete(request, "local", fakeFetch);
assert.equal(calls[0].url, "https://api.openai.com/v1/chat/completions");
assert.equal(calls[1].url, "https://api.anthropic.com/v1/messages");
assert.equal((calls[1].init.headers as Record<string, string>)["x-api-key"], "an-key");
assert.equal(calls[2].url, "http://localhost:11434/v1/chat/completions");
});
});