/** * Backend routing — cloud-primary, local-fallback. * * `RoutingOllamaClient` wraps two `OllamaClient`s (a cloud client with a * Bearer key and the existing local client) and implements the cloud→local * fallback policy behind the SAME `OllamaClient` interface. Because the whole * server touches Ollama through a single `ctx.client`, wrapping here means * every tool inherits cloud routing with zero per-tool change. * * Two ROUTING MODES (F2, v2.9): * - cloud-primary (v2.7.0): every tiered call tries cloud first. * - STANDBY: local-primary; cloud serves ONLY calls carrying an explicit * per-call `backend:'cloud'` route directive. Zero egress otherwise — * the deterministic guarantee the model never overrides. * Per-call route directives ride the REQUEST as a non-enumerable Symbol * (mirror of the response provenance tag below) so they can never serialize * onto the wire: `backend` escalates one call (standby) or pins one local * (primary); `modelExplicit` marks req.model as a caller override the cloud * attempt must honor instead of clobbering with the tier map. * * Two axes of fallback exist and are ORTHOGONAL: * - BACKEND fallback (this module): cloud→local, gated by a circuit breaker. * - TIER degradation (guardrails/timeouts.ts): deep→workhorse→instant model * downgrade on timeout, WITHIN whichever backend served. * Backend is resolved first (a near-instant breaker check); tier degradation * runs inside the chosen backend. They are not chained into a 6-timeout ladder. * * Failure classification (research-grounded — Hystrix + Google SRE): * - transient (timeout / 5xx / 429 / network) → count toward the breaker, fall to local. * - auth (401/403, OLLAMA_AUTH_FAILED) → sticky 'misconfigured' breaker that does NOT * auto-recover on a timer; serve local but surface loudly. * - deterministic (404 model-missing) → do NOT count toward the breaker; release the * half-open probe and fall back to local with a * loud `cloud_model_missing` degrade reason + a * cloud-specific hint (a retired/typo'd cloud model * id degrades visibly, never a silent swap nor a * total outage), and arm a short auto-expiring * cooldown so a persistently-missing model isn't * re-probed on every call. See H2/H3 (2026-07). * * Observability: every response is tagged (non-enumerable Symbol) with which * backend served it + whether it was degraded, and a `backend_fallback` NDJSON * event is emitted on every cloud→local fallback. The runner lifts these onto * the envelope so a worse local answer is never silent. */ import type { OllamaClient, GenerateRequest, GenerateResponse, ChatRequest, ChatResponse, EmbedRequest, EmbedResponse } from "./ollama.js"; import type { Residency } from "./envelope.js"; import type { Tier, TierConfig } from "./tiers.js"; import type { Logger } from "./observability.js"; /** Why a call was served from local instead of cloud. */ export type DegradeReason = "cloud_timeout" | "cloud_5xx" | "cloud_rate_limited" | "cloud_unreachable" | "cloud_auth_failed" | "cloud_model_missing" | "circuit_open"; export type Backend = "cloud" | "local"; /** Routing provenance attached to each response and lifted onto the envelope. */ export interface RoutingInfo { backend: Backend; /** The model that actually served the call. */ model: string; /** True when we wanted cloud but served local. */ degraded: boolean; degrade_reason?: DegradeReason; circuit_state: BreakerState; /** * The `num_ctx` actually sent on the served request, if any. Cloud attempts * use the cloud cap (not the local VRAM-driven value); local serves keep the * runner's per-tier value. Absent when no num_ctx was sent. Lets the envelope * report the real context window per backend. */ num_ctx?: number; } /** Read routing provenance off a response. Undefined when not routed (local-only default). */ export declare function getRoutingInfo(resp: unknown): RoutingInfo | undefined; /** * Per-call routing instruction, attached to a REQUEST by the runner (or a * handler like chat) right before the client call. Carried on a * non-enumerable Symbol — JSON.stringify and the object spreads at the HTTP * layer never see it, so it cannot leak onto the wire. */ export interface RouteDirective { /** * 'cloud' escalates this one call to the cloud backend (the only way a * call reaches cloud in standby mode); 'local' pins this one call local * (zero egress) even under cloud-primary. */ backend?: Backend; /** * True when req.model is an explicit caller override (per-call `model`, * v2.3.0) — the cloud attempt must send req.model verbatim instead of * the tier→cloud-model map. Absent for tier-resolved models. */ modelExplicit?: boolean; } /** Attach a route directive to a request. Returns the same object for chaining. */ export declare function setRouteDirective(req: T, directive: RouteDirective): T; /** Read a request's route directive. Undefined when none was attached. */ export declare function getRouteDirective(req: unknown): RouteDirective | undefined; export type BreakerState = "closed" | "open" | "half_open" | "misconfigured"; export interface BreakerOptions { /** Consecutive trip-worthy failures before OPEN. Default 3 (low-volume single tenant). */ threshold?: number; /** OPEN→HALF-OPEN cooldown in ms. Default 20_000. */ cooldownMs?: number; /** * Deterministic-failure (cloud 404 model-missing) cooldown in ms. During this * window after a cloud 404, cloud attempts are suppressed so a retired/typo'd * cloud model doesn't pay a round-trip on every call. Auto-expires (NOT sticky * like auth) so a restored model / fixed id recovers on its own. Default 60_000. */ deterministicCooldownMs?: number; /** Injectable clock for deterministic tests. Default Date.now. */ now?: () => number; } /** * Single-backend circuit breaker. CLOSED → OPEN (after N consecutive trip-worthy * failures) → after cooldown → HALF-OPEN (one probe) → CLOSED on success / OPEN * on failure. Auth failures trip a SEPARATE sticky 'misconfigured' state that * never auto-recovers (a bad key fails deterministically forever). * * No locking: the global Ollama semaphore serializes calls, so single-probe * half-open + the consecutive-failure counter are race-free in this server. */ export declare class CircuitBreaker { private state; private consecutiveFailures; private openedAt; private halfOpenInFlight; /** Wall-clock (via `now`) until which cloud is suppressed after a 404 (H3). */ private deterministicCooldownUntil; private readonly threshold; private readonly cooldownMs; private readonly deterministicCooldownMs; private readonly now; constructor(opts?: BreakerOptions); get currentState(): BreakerState; /** True while the deterministic-failure (cloud 404) cooldown is active (H3). */ inDeterministicCooldown(): boolean; /** Decide whether to attempt cloud now. May transition OPEN→HALF-OPEN. */ allowCloud(): boolean; recordSuccess(): void; /** A transient cloud failure (timeout/5xx/429/network). */ recordFailure(): void; /** A deterministic auth failure — sticky, does not auto-recover. */ recordAuthFailure(): void; /** * Release a half-open probe that neither succeeded nor is a countable * failure — used when the probe threw a DETERMINISTIC error (e.g. a 404 * model-missing) that must not count toward the breaker but also must not * wedge it in half_open forever. Clears the in-flight flag and, if we were * probing, restores OPEN + re-arms the cooldown so a later probe can run. */ releaseProbe(): void; /** * A deterministic cloud failure (404 model-missing). Does NOT count toward the * transient breaker (it's an external config/availability fact, not a cloud * outage) and is NOT sticky like auth. Releases any half-open probe (so a 404 * can't wedge half_open — H2) and arms a short cooldown during which cloud is * skipped (H3), so a persistently-retired model isn't re-probed on every call. */ recordDeterministicFailure(): void; } export interface RoutingOllamaClientOptions { cloud: OllamaClient; local: OllamaClient; /** Cloud model per tier (instant/workhorse/deep). embed is always local. */ cloudTiers: TierConfig; /** Local model per tier — used for the fallback request. */ localTiers: TierConfig; /** Per-tier cloud-attempt timeout (ms). Cloud is far slower than local. */ cloudTimeouts: Record; /** * Context-window cap (tokens) for cloud requests. Cloud models have huge * windows (512K–1M) but are billed by GPU-time, so we cap by default rather * than inherit the local VRAM-driven num_ctx. Applied to every cloud attempt. */ cloudNumCtx?: number; breaker?: CircuitBreaker; logger?: Logger; /** * Standby mode (F2a): local-primary; cloud serves only per-call * backend:'cloud' escalations. Default false = cloud-primary (v2.7.0). */ standby?: boolean; /** Cloud host, named in the first-egress disclosure line. */ cloudHost?: string; } export declare class RoutingOllamaClient implements OllamaClient { private readonly cloud; private readonly local; private readonly cloudTiers; private readonly localTiers; private readonly cloudTimeouts; private readonly cloudNumCtx?; readonly breaker: CircuitBreaker; private readonly logger?; private readonly standby; private readonly cloudHost?; /** True once the standby first-egress disclosure has been emitted. */ private egressDisclosed; constructor(opts: RoutingOllamaClientOptions); generate(req: GenerateRequest, signal?: AbortSignal, tier?: Tier): Promise; chat(req: ChatRequest, signal?: AbortSignal, tier?: Tier): Promise; /** Embeddings are local-only — Ollama Cloud serves no embedding models. */ embed(req: EmbedRequest, signal?: AbortSignal, tier?: Tier): Promise; /** Residency is a local-VRAM concept; delegate to local (cloud-served models return null). */ residency(model: string): Promise; /** Reachability of the always-available local backend. Cloud auth is checked by `doctor`. */ probe(timeoutMs?: number): Promise<{ ok: boolean; reason?: string; }>; /** * Shared cloud-primary/local-fallback path for generate + chat. `req.model` * arrives set to the LOCAL model (the runner resolves local models, staying * cloud-agnostic); we override to the cloud model for the cloud attempt and * keep the local model for fallback. */ private route; /** Serve from local, tagging the response degraded and emitting a backend_fallback event. */ private serveLocal; } //# sourceMappingURL=routing.d.ts.map