-
-
Notifications
You must be signed in to change notification settings - Fork 7.4k
Adaptive Routing
This document describes the feedback-driven adaptive routing foundation added to OmniRoute. It is deliberately small: it introduces a typed routing-outcome channel, an online quality signal that feeds the existing auto-combo scorer, an optional OpenTelemetry exporter, and an explainability endpoint. It does not replace the existing resilience stack (circuit breaker, connection cooldown, model lockout, health matrix, autopilot) — it complements it.
OmniRoute is a data plane with a request hot path and a control/intelligence plane. The hot path must stay fast, memory-efficient, asynchronous, resilient and predictable. Evaluation, quality scoring, experiments and historical analysis belong to the control plane.
AI Agent / IDE
│
▼
┌─────────────────────┐
│ OmniRoute │ data plane (fast, sync, in-memory)
│ routing / failover │
│ health / guardrail │
│ cache / streaming │
└──────────┬──────────┘
│ RoutingEvent (fire-and-forget, ~0.2µs)
▼
┌─────────────────────┐
│ Feedback sinks │ control plane (async, best-effort)
│ quality tracker │
│ OTel exporter │
│ explain store │
└──────────┬──────────┘
▼ quality score
auto-combo scorer
| Concept | Existing implementation |
|---|---|
| Availability (can we send traffic?) | Circuit breaker (CLOSED/DEGRADED/OPEN/HALF_OPEN, DB-persisted), connection cooldown, model lockout |
| Health reporting |
providerHealthMatrix.ts, providerHealthAutopilot.ts
|
| Shadow traffic | open-sse/services/combo/shadowRouting.ts |
| Guardrails |
src/lib/guardrails/ (pre/post hooks) |
| Exact cache |
src/lib/semanticCache.ts (signature-based) |
| Evaluators / eval-driven routing |
src/lib/evals/, open-sse/services/evalRouting.ts
|
| Combo decision explainability | open-sse/services/combo/decisionTrace.ts |
| Dashboard real-time events |
src/lib/events/eventBus.ts (UI notification channel, unknown payloads, 100-entry history) |
The routing-event layer is not a re-implementation of eventBus: that bus is
the dashboard's real-time notification channel (typed event names, opaque
payloads, UI consumers). RoutingEvent is a typed outcome struct
(latency/tokens/cost/outcome/finish-reason) consumed by the control plane's
feedback sinks (quality tracker, OTel exporter, explain store).
- A typed routing-outcome event + sink abstraction (
RoutingEvent/RoutingEventSink).decisionTraceis combo-scoped and in-memory-only;comboMetricsare cumulative counters;call_logsis raw async persistence. None is a typed, sink-based outcome channel that a quality tracker, an OTel exporter, or a Future-AGI-style evaluator can subscribe to. - An online quality signal (EWMA) for output quality — the scorer previously proxied "quality" only through static task fitness and opt-in eval pass-rates.
- An optional, dependency-free OTel exporter using GenAI semantic conventions.
- An explainability endpoint returning the real routing decisions + quality state.
Files: open-sse/services/routing/events.ts, .../index.ts
A RoutingEvent carries only routing metadata:
interface RoutingEvent {
requestId: string;
provider: string;
model: string;
strategy: string; // "auto" | "priority" | "direct" | ...
latencyMs: number;
ttftMs: number | null;
inputTokens: number | null;
outputTokens: number | null;
cost: number | null;
retries: number;
fallbackUsed: boolean;
outcome: RoutingOutcome; // allowlisted union
status: number | null;
finishReason: string | null;
connectionId: string | null;
ts: number;
}RoutingEventSink is a Send+Sync-style trait in TypeScript:
interface RoutingEventSink {
readonly name: string;
record(event: RoutingEvent): void; // must be O(1), no sync I/O
}The hot path calls emitRoutingEvent(event) once per completed request
(the streaming-completion callback, the non-streaming success path, and the
malformed-200 failure path in handleChatCore). Dispatch is synchronous fan-out
to registered sinks, but each sink only enqueues/updates in-memory state. No
synchronous database writes, no network I/O on the hot path.
Default sinks:
-
MemoryRoutingEventStore— bounded (500) ring buffer, newest-first, for the explain endpoint. -
QualityTrackerconsumer — updates the EWMA quality estimate. -
OtlpHttpsEventSink— optional, enabled only whenOMNIROUTE_OTEL_ENDPOINT(orOTEL_EXPORTER_OTLP_ENDPOINT) is set.
npm run bench:routing-events on this workstation (100k iterations; sub-µs ops
measured as aggregate µs/op because per-op percentiles are below
performance.now() timer resolution):
| Scenario | µs/op | ops/s |
|---|---|---|
| baseline (scoring only) | ~0.045 | ~22 M |
| baseline + RoutingEvent (2 sinks) | ~0.168 | ~5.9 M |
| baseline + event + OTel enqueue | ~0.163 | ~6.1 M |
| concurrent (8 interleaved bursts) | ~0.18 | — |
The event-dispatch delta over baseline scoring is ~0.12 µs/request; the OTel sink only enqueues (O(1) buffer push), adding nothing measurable. These numbers are machine-specific and relative — not a production guarantee. The v1 "~0.2 µs" figure was an aggregate estimate; this methodology separates the scoring baseline from the event-dispatch cost.
Files: open-sse/services/routing/quality.ts
v2 separates operational from semantic quality:
-
Operational — derived from the routing hot path (HTTP 4xx/5xx, connection
failures, 429s, malformed responses, stream interruptions,
finish_reason=length, zero-output successes, latency/TTFT EWMA). A 200 is NOT treated as semantic quality. -
Semantic — the actual value of the generated output. ONLY ever produced by
an evaluator via
setSemanticQuality(). It isnulluntil one provides it and never leaks into the operational score.
Per-(provider, model) state (EWMA + bounded counters):
-
successEwma— EWMA (α=0.2) of outcome success. -
latencyEwma/ttftEwma— EWMA of latency (α=0.1). -
samples,anomalies,rateLimited,semantic,semanticConfidence. -
recencyMs— how recently the model was last observed.
confidence = clamp01(samples / 50), and the score returned to the scorer is
blended toward the neutral midpoint:
score = 0.5 + confidence * (operational - 0.5)
Consequences (verified by tests):
- A cold provider (0 samples) scores 0.5 — not unfairly penalized, but unable to dominate a provider with thousands of solid observations.
- A provider with 7 lucky successes is pulled toward 0.5 (never dominates from optimistic initialization).
- A provider with 50+ samples converges to its true operational score.
- Degradation and recovery are gradual (EWMA), and one isolated failure does not destroy a healthy provider.
ProviderQuality exposes { operational, semantic, confidence, samples, anomalies, rateLimited, successEwma, latencyEwmaMs, ttftEwmaMs, recencyMs }.
This feeds the auto-combo scorer as the quality scoring factor:
-
ScoringFactors.quality/ScoringWeights.qualityinopen-sse/services/autoCombo/scoring.ts. -
DEFAULT_WEIGHTS:health0.1905 → 0.1605,quality0.03. Sum stays 1.0. -
buildAutoCandidatespopulatescandidate.qualityfrom the tracker; candidates without data default to neutral 0.5 (a cold candidate is neither boosted nor penalized).
The closed loop:
RoutingEvent → QualityTracker → getQualityScore → auto-combo quality factor
↑ │
└────── request outcome (handleChatCore) ←────────────┘
The quality signal is a soft adaptive preference only. Hard exclusion stays with the existing resilience stack: circuit breaker OPEN, quota exhausted, auth failure, model lockout — none of these are affected by the quality score. A provider whose quality score dips temporarily is de-preferenced, never hard-disabled.
Files: open-sse/utils/streamTiming.ts
createStreamTiming() is the single instrumentation seam for the streaming path,
wired into createSSEStream (open-sse/utils/stream.ts):
-
markByte()— first upstream chunk received. -
markForward()— first chunk forwarded to the client (used for TTFT). -
markInterrupted()— stream timeout/abort/error before a clean finish. -
ttft()= first-forwarded-SSE-chunk latency. This is NOT token-level TTFT — a single SSE chunk may carry zero/one/many tokens. Documented precisely. -
avgItlMs()= mean inter-chunk gap (a chunk-latency proxy for ITL).
TTFT/ITL/interrupted flow into the RoutingEvent (ttftMs, itlMs) and are
exported as GenAI/OmniRoute span attributes by the OTel sink.
Files: open-sse/services/routing/otel.ts
- Dependency-free OTLP/HTTP JSON exporter (uses global
fetch, no@opentelemetry/*SDK). - Spans follow GenAI semantic conventions (
gen_ai.provider.name,gen_ai.request.model,gen_ai.usage.input_tokens/output_tokens,gen_ai.completion.finish_reason,gen_ai.system) plus OmniRoute routing attributes (outcome, status, ttft, retries, fallback). -
record()only enqueues into a bounded buffer (O(1)); a background timer flushes viaPOST {endpoint}/v1/tracesasynchronously. Under overload the oldest events are dropped (droppedcounter) — never backpressure the data plane. -
Disabled unless configured.
OMNIROUTE_OTEL_ENDPOINT(orOTEL_EXPORTER_OTLP_ENDPOINT) must be set; otherwise the sink is not registered and zero OTel code runs.
-
GET /v1/explain/routingreturns the recentRoutingEvents (the real decisions, newest first) and the per-provider/model quality snapshot. - Auth mirrors
/v1/combos(Bearer API key or dashboard session; anonymous on single-user local deployments withREQUIRE_API_KEY=false). - Combo-level per-invocation traces remain available via the existing
decisionTrace.ts(headerX-OmniRoute-Combo-Trace). - Safety: events carry only routing metadata, never prompts/bodies/credentials.
OmniRoute treats Future AGI (or any evaluator) as a potential intelligence/evaluation backend, not a dependency. The seams:
- A
RoutingEventSinkcan forward events to an evaluator asynchronously. - The
MemoryRoutingEventStore+ quality snapshot give an evaluator the raw decision stream. - A future
Evaluator(deterministic, local judge, HTTP, WASM) would consume events/traces and return aQualityScorethat feeds the samegetQualityScore/quality-factor path. - Existing eval-driven routing (
open-sse/services/evalRouting.ts) already re-orders combo targets byeval_runspass-rates when enabled.
No evaluation runs synchronously on the request path, and the gateway operates fully with the evaluator absent.
-
What remains on the synchronous hot path? Routing/scoring, guardrail
pre-checks, cache lookup, and one
emitRoutingEventfan-out (~0.12 µs over baseline scoring) to in-memory sinks. -
What moved to asynchronous processing? OTel export (timer + fetch),
call_logs/usage persistence, semantic-cache writes, quality is in-memory and O(1) (no async needed). -
How does a routing outcome become feedback?
handleChatCoreemits aRoutingEvent→QualityTrackerupdates EWMA state →getQualityScorefeeds the auto-comboqualityfactor. -
How does quality influence future routing? A low quality score reduces
the weighted score of that provider/model in
scoreAutoTargets, so degraded models are gradually de-preferenced and recover as their EWMA improves. -
How can Future AGI integrate without becoming a dependency? Via the
RoutingEventSinkinterface / a futureEvaluatoradapter — no hardcoded dependency. - What happens when the evaluator is unavailable? Routing is unaffected; quality falls back to neutral (1.0) for models with no observed signal.
- What happens when telemetry is unavailable? The OTel sink simply isn't registered; the rest of the routing layer runs unchanged.
- What happens under overload? The OTel buffer drops oldest events; quality and the ring buffer are bounded by construction; no backpressure.
- How does provider state recover after degradation? EWMA re-converges as successes accumulate; warmup keeps cold models neutral; the circuit breaker independently recovers via HALF_OPEN probes.
-
Which proposed features were intentionally NOT implemented, and why?
- Shadow traffic / experiments — already implemented
(
combo/shadowRouting.ts); not re-built. - Guardrails — already implemented (
src/lib/guardrails/); not duplicated. - Semantic cache — already implemented (
src/lib/semanticCache.ts); not duplicated. - A full experiment-management platform, dataset tooling, prompt-optimization platform, vector DB, or mandatory external OTel infrastructure — out of scope for a lean data plane.
- A Rust
RoutingEventstruct — the data plane is TypeScript; the TS type is the adapted equivalent.
- Shadow traffic / experiments — already implemented
(
| Variable | Default | Effect |
|---|---|---|
OMNIROUTE_OTEL_ENDPOINT |
unset | When set, enables the OTLP/HTTP traces exporter (e.g. http://collector:4318). |
OTEL_EXPORTER_OTLP_ENDPOINT |
unset | Fallback alias for the OTLP endpoint. |
OTEL_SERVICE_NAME |
omniroute |
service.name resource attribute. |
-
tests/unit/routing-events.test.ts— event normalization, status classification, bounded ring buffer, sink fan-out + isolation. -
tests/unit/routing-quality.test.ts— EWMA warmup, failure/success recovery, anomaly penalties, 429 transient handling, snapshot, reset. -
tests/unit/routing-scoring-quality.test.ts— weight integrity, neutral default, quality factor ranking. -
tests/unit/routing-otel.test.ts— enable gating, GenAI span payload, async flush, drop-under-overload. -
tests/unit/routing-events-concurrency.test.ts— thousands of events, ring buffer boundedness, throwing-sink isolation, interleaved async bursts, reset-during-inserts. -
tests/unit/routing-adaptive-e2e.test.ts— deterministic end-to-end loop via the realscoreAutoTargetsscorer: healthy → degrade → recover → blip, plus cold-start and lucky-cold-provider scenarios. -
tests/unit/stream-timing.test.ts— TTFT (first-forwarded-chunk), ITL, first-byte vs first-forward, interruption, malformed/empty chunk safety.
| Issue | Status | Notes |
|---|---|---|
omniglyph export mismatch |
FIXED (environmental) |
node_modules was out of sync with package-lock.json (installed 1.3.1 vs locked 1.4.0). Running npm install omniglyph@1.4.0 restored the locked version; type errors dropped to 0. Manifests unchanged. |
Stale getKnownContextOverflow tests |
KNOWN — not fixed |
combo-context-overflow-compression-probe.test.ts imports a function that no longer exists in open-sse/services/combo.ts (only comments reference it). Fixing requires re-implementing or re-writing those tests — unrelated architectural churn. |
combo-runtime-unit-concurrency.test.ts DB isolation |
KNOWN — not fixed | Test-harness SQLite-isolation assertion fails when run directly; fails identically on the base branch. |
i18n llm.txt drift |
KNOWN — not fixed |
docs/i18n/*/llm.txt differ from root; pre-existing, blocks the docs-sync pre-commit gate. |
Environmental vs code issues are kept distinct; no unrelated failures are hidden behind changed test filters.
OmniRoute · Website · npm · Docker Hub
- Setup Guide
- User Guide
- Features
- Quick Start (Docker)
- Electron Desktop App
- Termux (Android)
- PWA Guide
- MCP Server
- A2A Server
- Agent Protocols
- OpenCode Plugin
- Webhooks
- Cloud Agents
- Skills
- Memory
- Evals
- Gamification
- Guardrails
- Compliance
- Error Sanitization
- Public Credentials
- Route Guard Tiers
- Stealth Guide
- CLI Token Auth