/** * Router configuration. * * Layered: built-in defaults <- `$AUTO_MODEL_ROUTER_HOME/config.yml` <- environment * <- CLI flags. Every field is optional on disk; `RouterConfig` is the fully * resolved shape the rest of the code consumes. */ import type { TaskType, Tier } from "../router/types.ts"; // Type-only, and deliberately so: `benchmarks.extraScores` is the same row shape // the feeds produce, so there is one score vocabulary rather than two. The cycle // (benchmark-feeds imports RouterConfig) is erased at compile time. import type { FeedScore } from "../catalog/benchmark-feeds.ts"; export type QualityAxis = "coding" | "agentic" | "intelligence"; /** * Per-task routing envelope. Task type selects the quality axis, capability * filters, and quality floor; the complexity tier's price ceiling and the * budget guard still cap cost (task selects, tier budgets). */ export interface TaskConfig { /** Quality axis to score candidates against for this task. */ axis: QualityAxis; /** Minimum quality on that axis, 0-100. Overrides the tier floor when higher. */ minQuality?: number; /** Require image input support. Hard filter for vision tasks. */ requireImage?: boolean; /** Slugs always eligible for this task regardless of quality floor. */ prefer?: string[]; } export interface ServerConfig { host: string; port: number; /** Optional bearer required from clients. Unset ⇒ loopback-only, no auth. */ apiKey?: string; /** * Harness identity emitted as the `X-Omp-Harness` header in the generated * `models.yml` provider block. Lets multiple coding harnesses share one * router while keeping per-harness daily budgets and toast scoping. Empty * ⇒ no header (single-harness default). */ harnessId?: string; /** * Profile that requests from omp subagents (`X-Omp-Subagent: 1`, set by * the embed extension for sessions without a UI) are routed under when they * ask for the default profile. Subagents do delegated, bounded work — file * reads, searches, summaries — that rarely needs the top tier. Empty * disables the remap; a name with no matching profile is ignored. */ subagentProfile: string; /** * Concurrent in-flight turns this router process will accept; excess gets a * 429 rather than being queued, so a local flood cannot pile up unbounded * upstream spend. The budget is per PROCESS, and one process now serves * every omp session on the machine, so it must cover all live sessions plus * their subagents. */ maxConcurrentTurns: number; } export interface OpenRouterConfig { baseUrl: string; /** Resolved from config, then `OPENROUTER_API_KEY`. */ apiKey: string; /** Sent as `HTTP-Referer`, for OpenRouter attribution. */ referer?: string; /** Sent as `X-Title`. */ title: string; /** Per-request timeout, ms. */ timeoutMs: number; /** Catalog freshness threshold, ms: refetch on traffic when older than this. */ catalogTtlMs: number; /** Background catalog refresh cadence, ms. 0 disables the periodic refresh. */ catalogRefreshMs: number; /** * Balance (USD, `total_credits − total_usage`) at or below which OpenRouter * stops serving: its models drop from the catalog until the account is * topped up. 0 disables the gate — the 402 breaker still catches the real * thing. Default 5. */ minCreditsUsd: number; /** Credits poll interval, ms. 0 disables the poll (and the gate never fires). */ usagePollMs: number; } /** * Ollama Cloud as a second upstream, ranked in the same catalog as OpenRouter. * * Off by default. Reached either through a local Ollama daemon (the default * `baseUrl`; it proxies `:cloud` models under the signed-in account and lists * their context length and capabilities) or directly at `https://ollama.com/v1` * with an API key. Slugs are `ollama/`. * * Ollama publishes no prices via API, so rates come from a shipped snapshot * plus `prices`; a model with no rate is dropped. Quality scores come from the * model's OpenRouter twin (matched by name, or pinned via `twins`), because * Ollama publishes none — an unmatched model is unscored and serves only * `trivial`. Ollama reports no cost per response, so the ledger's predicted * figure is what gets recorded. */ export interface OllamaConfig { enabled: boolean; /** `http://127.0.0.1:11434/v1` (daemon) or `https://ollama.com/v1` (direct). */ baseUrl: string; /** Resolved from config, then `OLLAMA_API_KEY`. Needed for ollama.com; the daemon uses its own sign-in. */ apiKey: string; /** Per-request timeout, ms. */ timeoutMs: number; /** Re-list models when the last listing is older than this, ms. */ catalogTtlMs: number; /** Also expose the daemon's LOCAL models (unpriced unless `prices` names them). Off: cloud only. */ includeLocal: boolean; /** USD per million tokens, keyed by bare cloud name; overrides/extends the shipped snapshot. */ prices: Record; /** Bare cloud name → OpenRouter slug, when the name-based twin match is wrong or missing. */ twins: Record; /** * Multiplier on an Ollama model's effective cost in ranking, 1 = list price. * Below 1 prefers Ollama when a plan's included credits would otherwise go * unused; the ledger still records list price, so spend stays honest. */ costBias: number; /** * Plan-usage fraction (0-1) at which `costBias` switches off and Ollama * ranks at list price. Read from ollama.com's `/api/usage`, which reports * consumption as a share of the plan's included monthly credits — so the * same setting is right on Pro, Max or Team. 1 keeps the bias regardless. */ biasUntilUsage: number; /** How often to re-read plan usage, ms. 0 disables the read (bias stays static). */ usagePollMs: number; /** How long to route around Ollama after a 402 (credits exhausted), ms. */ quotaCooldownMs: number; /** How long to route around Ollama after a 429 (concurrency cap), ms. */ rateLimitCooldownMs: number; /** * Override for the plan's included monthly credits, USD. 0 (default) reads * the plan from ollama.com (`POST /api/me`) and applies its published * allowance (Pro 60, Max 300); set this for a plan the router does not know. */ planCreditsUsd: number; } /** * External benchmark feeds that BACKFILL quality scores OpenRouter does not * publish. OpenRouter embeds Artificial Analysis scores for the models it has, * but returns many (GLM, MiniMax, smaller vendors) unscored — which strands * them below every tier floor above `trivial`. These feeds fill only the axes * a model is missing; a score OpenRouter already published is never overwritten. * * Refreshed on their OWN slow cadence (`refreshMs`, ~daily), independent of the * catalog's minute-scale availability refresh, and cached in `benchmark_cache`. * Every fetch is best-effort: a feed failure leaves the catalog on published * scores rather than failing a refresh. */ export interface BenchmarksConfig { /** Master switch. Off ⇒ the catalog carries only OpenRouter-published scores. */ enabled: boolean; /** * Artificial Analysis API key (v2 data API). Resolved from config, then * `ARTIFICIAL_ANALYSIS_API_KEY`. Empty ⇒ the AA feed is skipped; BenchLM * (keyless) still runs. */ artificialAnalysisApiKey: string; /** Pull the keyless BenchLM leaderboard, which covers models AA omits. */ benchlm: boolean; /** Feed cache freshness, ms: re-fetch the feeds only when older than this. */ refreshMs: number; /** Per-feed HTTP timeout, ms. */ timeoutMs: number; /** * Apply calibrated scores from our own eval harness (`src/eval`, the * `local_scores` table) as a last-resort source. Off by default: local * scores change routing, so they stay inert until deliberately enabled — * e.g. after a data-collection window closes. */ useLocalScores: boolean; /** * Scores supplied by the front door for axes the feeds leave empty. Applied by * the SAME fill-only-missing rule as the feeds, AFTER Artificial Analysis and * BenchLM and BEFORE `local`, so nothing a published source measured is ever * moved. Empty by default. * * Provenance is each entry's `source`, and only two values are accepted: * `neutral` (a benchmark's own leaderboard, taken as given) and `vendor` (a * self-reported model-card number, which the supplier discounts before sending * — the router never rescales a number it is handed). An entry claiming any * other source is dropped: config must not be able to impersonate a published * feed, nor write into the `local` lane that `useLocalScores` gates. * * Entries are sanitised on every use (`suppliedScores`), so a malformed one is * dropped with a warning rather than failing a refresh or zeroing a score, and * `key`/`creator` are normalised on the way in — the OpenRouter slug works. */ extraScores?: FeedScore[]; } /** Quality/price envelope for one complexity tier. */ export interface TierConfig { /** * Minimum quality on the request's chosen axis, 0-100 (Artificial Analysis * index as published by OpenRouter). The tier's capability guarantee. */ minQuality: number; /** Hard ceiling on input price, USD per million tokens. Unset ⇒ unbounded. */ maxInputPerMtok?: number; /** Hard ceiling on output price, USD per million tokens. Unset ⇒ unbounded. */ maxOutputPerMtok?: number; /** * Exponent on quality in the ranking score. 0 ⇒ pick the cheapest model * above the floor (Pareto-style). Higher ⇒ pay for headroom above it. */ qualityExponent: number; /** * Rank on quality NORMALISED WITHIN the candidate set instead of on the raw * 0-100 index. Default false (raw). * * Why it exists: raw scores occupy a narrow band (69-78 on the coding axis) * while prices span ~250x ($0.02-$5.00/MTok), so `(quality/100)^exponent` * over a forecast cost is a bounded numerator over an unbounded denominator * — price wins unless the exponent is enormous (measured: ~140 to select a * frontier model, where 0.715^140 is ~1e-20 and numerically fragile). * Normalising maps the set's worst quality to 0 and its best to 1, so the * exponent becomes a legible "how much do I pay for the best available * model" dial at single digits instead of triple. */ qualityNormalization?: boolean; /** * Treat this tier as a CAPABILITY FLOOR rather than a cost ranking: pick the * highest-quality candidate whose forecast turn cost is within this many USD, * ignoring quality-per-dollar entirely. Unset ⇒ normal ranking. * * This is the top tier's real job. `hard` exists because the work needs a * capable model, so "best quality under a spend cap" states the intent * directly; ranking by quality/price cannot, since a bargain model always * wins on the ratio however weak it is. Falls back to the ranked winner when * no candidate fits the budget, so this can only ever upgrade a choice. */ capabilityFloorUsd?: number; /** Slugs always allowed in this tier regardless of the quality floor. */ pin: string[]; } export interface FilterConfig { /** Glob patterns; a model must match one. Empty ⇒ allow all. */ allow: string[]; /** Glob patterns; matching models are dropped. Applied after `allow`. */ deny: string[]; /** * Model-glob → provider-glob. A model matching a key may only dispatch * through a provider whose id matches the value, so a team can keep a * subscription's models on that subscription instead of its OpenRouter * twins (`{"anthropic/*": "anthropic-subscription"}`). Applied before * ranking; a model matching several locks must satisfy each. An empty * object locks nothing. */ providerLocks: Record; /** Consider zero-price models. Off by default: rate limits make them expensive in retries. */ includeFree: boolean; /** Require `supported_parameters` to include `tools` whenever the request offers tools. */ requireToolSupport: boolean; /** * Score a turn that carries tools on the `agentic` axis instead of its task's axis. * On by default: a tool loop is won or lost on tool-driving ability, and `chat` and * `documentation` score on `intelligence`, which does not measure it. Off restores * the task's own axis for every turn. */ agenticAxisForToolTurns: boolean; /** * Minimum `agentic` score a model needs to be offered a turn that carries tools, 0-100. * The cheap tiers rank with `qualityExponent: 0` — cheapest above the floor — so on those * tiers the ranking axis is inert and only a floor keeps a tool-incapable model out. * * Judged on its own scale, NOT against a tier's `minQuality`: agentic scores run far lower * than coding and intelligence, so reusing a tier floor here empties the catalog. Measured: * 25 drops gpt-oss-20b (1.4) and gemma-3-12b (0.1) while leaving 14 models under $0.30/Mtok. * * A model that publishes NO agentic score is not filtered — only 103 of 223 tool-capable * models carry one, so rejecting the unscored would discard half the catalog. 0 disables. */ minAgenticForToolTurns: number; /** * Smallest completion budget a REASONING model is dispatched with, tokens. * * A reasoning model spends the budget thinking before it answers, so a * caller's tight cap returns nothing at all: omp asks for ~12 tokens for a * conversation title, and `ollama/gpt-oss:20b` hit the cap having produced * no content, which cost a dead dispatch and a failover to another model. * When the chosen model reasons and the caller asked for less than this, the * dispatch is raised to this floor (never above the model's own completion * ceiling). Models that answer directly keep the caller's cap. * * A cap is an upper bound, not a target — a model that answers in ten tokens * still stops at ten. 0 disables the floor. */ reasoningCompletionFloor: number; /** Drop models whose ledger success rate is below this, once `minTrustSamples` is met. */ minTrust: number; /** * How much a user verdict (/router good|bad) weighs in a model's trust * rate: each bad verdict counts as this many failures and each good one as * this many successes, beside escalations and errors. 0 (default) records * verdicts without acting on them. A person judging an answer wrong is a * stronger signal than a probe rejection, so values of 2-5 are sensible * once a week of verdicts is in the report. */ feedbackWeight: number; /** * Count a verdict toward a model's trust only when routing the same task * type the judged turn was (the ledger's `task`: coding, vision, * documentation, data, chat). A model that writes good code but bad prose * then keeps its coding trust. Verdicts on turns with no recorded task * count for every task. Off by default: verdicts are scarce, and pooling * them converges sooner. */ feedbackByTask: boolean; /** Attempts required before `minTrust` is enforced against a model. */ minTrustSamples: number; /** * Scope model trust to the requesting harness instead of the whole ledger. * Off by default: shared trust converges on more samples and keeps the * demotion guard effective with a small catalog. Enable only when harnesses * route over meaningfully different model sets and each has enough traffic * to learn its own reliability. */ trustScopedByHarness: boolean; /** * Only count ledger rows from the last N days toward model trust. 0 (the * default) keeps the all-time behaviour. * * Trust is deliberately all-time: reliability is slow-moving, and a wide * sample keeps the demotion guard stable. The cost is that the per-slug * trust aggregate scans every row a model ever had, and that runs for each * candidate on every turn — measured on a real ledger it grows from 0.8 ms at * 9k rows to 11.6 ms at 75k, i.e. it becomes a per-turn latency tax as * history accumulates. A window bounds that scan. * * Setting it CHANGES ROUTING (smaller denominators move success rates), so * price it on the ledger with `bun tools/replay.ts --set * filters.trustWindowDays=N` before enabling. */ trustWindowDays: number; /** * Headroom multiplier applied to estimated prompt tokens when checking a * model's context window, absorbing token-estimate error and the response. */ contextHeadroom: number; /** * How hard to penalise slow models in candidate scoring. A model's expected * total wait — TTFT plus streaming the expected completion at its measured * throughput — above the reference inflates its effective cost, the same lever * trust uses for flakiness, so a faster model of equal quality and price wins. * 0 disables latency scoring entirely. */ latencyWeight: number; /** Reference TTFT (ms): the start-latency component that accrues no penalty. */ latencyReferenceMs: number; /** * Reference throughput (tokens/second): the streaming speed that accrues no * penalty. Below it, a slow-streaming model's expected wait exceeds the * reference and its effective cost is inflated. */ latencyReferenceTokensPerSec: number; /** Streamed samples required before latency is scored against a model. */ latencyMinSamples: number; /** * Warm-expected samples a model needs before its observed cache hit rate * (ledger `cacheReliability`) discounts the "stay warm" price in the * stay/switch comparison. Below it, and when 0, a cache is assumed fully * reliable. Measured 2026-09-06: same-model short-gap turns still ran cold * 5-6% on glm/gemini, 11% on ling and 50% on nex. */ cacheReliabilityMinSamples: number; /** * Absolute expected-wait ceiling (ms). A hard drop, mirroring the price * ceiling: any model whose expected total wait (TTFT + streaming the expected * completion at its measured throughput) exceeds this is rejected outright, * regardless of tier or price. This is the gate the latency *penalty* cannot * be — the penalty is multiplicative on cost and capped, so on an ultra-cheap * model even the capped multiple leaves it cheapest; a slow-but-cheap model is * never demoted by scoring alone. Only models with at least `latencyMinSamples` * observations are dropped, so a new model still gets its cold-start turns. * Relaxed alongside trust in tier rescue so a narrowed catalog never 500s. * Undefined ⇒ off (the default). */ maxExpectedWaitMs?: number; /** * Latency weight for tool-result continuations (the agent loop's own * follow-ups, where no person is waiting on first token). Unset ⇒ * `latencyWeight` applies to every turn. Lower it to spend speed only on * user-facing turns. */ latencyWeightContinuation?: number; /** * How much of a model's measured escalation risk to price into its effective * cost, 0-1. 0 (the default) disables the term. * * The trust divisor treats a failure as a proportional retry of the same * model, so a 4% escalation rate reads as a 4% surcharge. The real cost of * a probe escalation is a whole re-dispatch on the NEXT tier's model: * measured over a week, escalated attempts billed ~$0.08 each while the * cheap model that failed had billed ~$0.0006 — a 700x multiple, not 4%. A * cheap model with a 3.6% escalation rate therefore cost more than a * reliable one at 4x its price, and the divisor could never see it. * * At 1, `effectiveUsd += escalationRate × (this prompt × the ledger's * measured $/prompt-token of escalated attempts)`, so flakiness is priced * at what it actually costs. Inert until the ledger holds enough escalated * attempts to measure. */ escalationCostWeight: number; } export interface ClassifierConfig { /** * Heuristic confidence below which the LLM adjudicator is consulted. * 0 disables the adjudicator entirely. */ ambiguityThreshold: number; /** Slug used for adjudication. Must be cheap and fast. */ model: string; /** * Path of a model written by `tools/train-classifier.ts`. When set, every * heuristic classification also carries the learned P(escalate) in its * reasons (`learned: p(escalate)=…`) and `Classification.learnedRisk`. * Advisory: it never moves a tier. Empty ⇒ off. */ learnedModelPath: string; /** Skip adjudication when it would exceed this fraction of the forecast turn cost. */ maxCostFraction: number; /** Absolute per-call ceiling, USD. */ maxCostUsd: number; timeoutMs: number; /** Adjudication verdicts cached per turn fingerprint. */ cacheSize: number; /** Which quality axis to score against when the request offers tools. */ toolAxis: QualityAxis; /** Axis for plain chat requests. */ chatAxis: QualityAxis; /** Tool-loop depth above which the agentic axis takes over. */ agenticLoopDepth: number; /** * Fraction of the failed-tool weight that survives when the turn is a * mechanical tool-result continuation. A retry after a failed tool call is * the most mechanical turn there is; the flat weight let automated retry * loops buy the hard tier. 1 preserves the shipped behaviour. */ mechanicalRetryFactor: number; /** * Score subtracted when the newest assistant turn issued only read-only * tools (read, grep, glob, ls, lsp…) and this is the tool-result * continuation: the model is looking, not deciding. 0 (default) records * the feature without acting on it — enable after a replay prices it. */ readOnlyToolWeight: number; /** * Score added when the CLIENT asks for a reasoning effort, per level. The * premise is that asking for reasoning states expected difficulty directly. * * That premise fails when a harness sets the level once for a whole session: * a constant cannot discriminate difficulty between turns, but it still * shifts every turn's score. Measured on a live day: the requested level * never changed within 111 of 115 conversations, `medium` (+0.14, over half * of a 0.25-wide tier band) rode on 41.6% of dispatches, and 64 of 119 * `hard` dispatches reached that tier ONLY because of it — $6.66 billed * against $0.16 for the same tokens on the moderate pick. * * Tune per deployment: a harness that raises the level deliberately for hard * turns wants these weights, one that pins it session-wide wants `medium` * near zero. Defaults preserve the shipped behaviour. */ reasoningWeights: { medium: number; high: number; xhigh: number; max: number; }; } export interface EscalationConfig { enabled: boolean; /** Hold this many text tokens before committing the stream to the client. */ probeTokens: number; /** Hard ceiling on hold time, ms. Elapsing commits the attempt. */ maxHoldMs: number; /** Max escalation retries per turn. */ maxAttempts: number; /** Tiers eligible for probing. Frontier tiers are usually excluded. */ probeTiers: Tier[]; /** Signals that trigger escalation. Narrowing this makes the guard more permissive. */ triggers: string[]; /** * Escalate when a `length` finish truncated TOOL-CALL ARGUMENTS, leaving * structurally unusable output. A length finish on prose never escalates: * that is the caller's own `max_tokens`, and a retry truncates identically. */ escalateOnLengthStop: boolean; } export interface HysteresisConfig { /** Turns to hold a tier after committing to it. */ holdTurns: number; /** Turns to hold after an escalation, so a hard sub-task stays on the strong model. */ holdTurnsAfterEscalation: number; /** * Switch models only when the expected saving exceeds the forfeited cache * discount by this multiple. 1.0 ⇒ break even; higher ⇒ stickier. */ switchMargin: number; /** * Turns over which a model switch is amortised in the stay/switch decision. * 1 (the default) is the one-turn comparison: stay at the warm model's * cache-read price vs switch at the new model's cold price. That is right * for one turn and wrong for the run that follows: it kept a $2.55/Mtok * model warm for 33 consecutive `moderate` dispatches ("stay $0.0589 ≤ * switch $0.1131 × 1.3") where the ranked winner would have been $0.003 * per turn once ITS cache was warm. With a horizon H the comparison is * `H × stayWarm` against `switchCold + (H − 1) × newWarm`, so a switch that * pays for itself within H turns is taken. Deep loops average ~25 * dispatches per user-visible turn, so single digits are conservative. */ switchHorizonTurns: number; /** * A heuristic tier UPGRADE whose classification confidence is below this * waits one turn when the current model's cache is warm; a second * consecutive upgrade classification confirms it. 0 disables. Escalations, * explicit high reasoning and failing tool loops bypass the wait. * * Measured on 7 days of live traffic: 65 of 67 moderate→hard upgrades * bounced back within 3 turns, 50 of them below 0.6 confidence, costing * $17.90 in cold hard-tier prompt reads against $0.23 for staying warm. * The stay/switch comparison never sees these because the warm cheap * model is below the new tier's floor. */ confirmUpgradesBelowConfidence: number; /** Assume a warm cache expires after this long. OpenRouter sticky sessions: 5-10 min. */ cacheWarmTtlMs: number; /** Downgrade at most this many tiers per turn, so quality never falls off a cliff. */ maxDowngradePerTurn: number; /** * Let a mechanical tool-result continuation escape a hold that sits above its * own classification. * * A hold bets that the next turn resembles the one that armed it, and it is * usually right — flapping cold-starts prompt caches. But a continuation the * classifier has already docked for being a mechanical next step, and whose * score lands below the held tier, is evidence against that bet. Measured on * 24h of live traffic: 37 of 44 sticky `hard` dispatches were exactly that, * one scoring 0.154 (trivial) yet served by claude-opus-5 — $2.66 billed * against $0.05 for the identical tokens on the moderate pick. * * Off by default: breaking a hold means a model switch, and switching costs a * cache write. Worth it when the held tier is expensive, not obviously worth * it when the tiers are close, so it is opt-in per deployment. * `maxDowngradePerTurn` still applies, so quality steps down rather than * falling off a cliff. */ breakHoldOnMechanical: boolean; } /** * Epsilon-greedy exploration: deliberately route a small fraction of turns * one tier BELOW the classified tier, to learn whether the cheaper model * would have sufficed. * * Without it the ledger only ever witnesses UNDER-routing: a tier that was * too low escalates and is recorded, while over-routing stays invisible * because the cheaper model was never run. Weights fit on that one-sided * evidence can only ever ratchet toward more expensive routing. */ export interface ExplorationConfig { /** Off by default: this deliberately degrades a slice of real turns. */ enabled: boolean; /** * Per-tier sampling rate, 0-1. A tier that is absent, or set to 0, is * never explored. `trivial` is the floor and cannot drop, so a rate for * it has no effect. * * Rates are per-tier because the tiers are wildly unequal as evidence. * In one observed window 635 explorable turns were `simple` and 1 was * `hard`, while `hard` carried ~30% of all spend. A single uniform rate * therefore spends nearly the whole exploration budget on the cheapest * question in the system. */ rates: Partial>; /** * Which hysteresis-held turns exploration may touch. * * `never` the held population is untouchable. * `cold-cache` explore a hold only after its prompt cache has expired. * `always` explore holds regardless, forfeiting a live cache read. * * This matters more than it sounds. ~95% of hard-tier spend arrives by * hold rather than by classification, so `never` confines exploration to * the cheapest boundary in the system. But held turns are consecutive * turns of an active loop and are therefore warm BY CONSTRUCTION, so * `cold-cache` barely reaches them either: on one real window it moved * explorable hard turns from 1 to 11. Reaching that population in any * useful volume means `always`, and paying the forfeited cache read -- * a real cost, but a bounded and directly measurable one. */ stickyPolicy: "never" | "cold-cache" | "always"; /** * Randomise the POST-ESCALATION hold length per conversation, to learn * what it should be. * * `holdTurnsAfterEscalation` is a hand-picked constant that nothing has * ever validated, and it governs most expensive spend: a turn escalates * once, then the hold bills the next several turns at the escalated * tier. Assignment is per conversation, so each conversation is one * clean randomised arm rather than a confounded mixture. */ holdTurns: { enabled: boolean; /** Candidate hold lengths. One is drawn per conversation. */ values: number[]; }; } export interface CacheConfig { /** * Inject Anthropic-style `cache_control` breakpoints. OpenRouter translates * them to OpenAI/Google cache primitives, so one mechanism covers every target. */ injectBreakpoints: boolean; /** Max breakpoints per request. Anthropic allows 4. */ maxBreakpoints: number; /** Skip injection below this prompt-token estimate; small prompts cannot cache. */ minPromptTokens: number; /** * Spacing of the stable mid-history breakpoints, in prompt tokens. Boundaries * land at fixed multiples of this size, so the same prefix recurs turn after * turn and each turn reads what the last one wrote. Smaller = finer recovery * after a history rewrite, at the cost of more breakpoint slots. */ milestoneTokens: number; } /** * Tool-result digest: a cheap model condenses large tool outputs before an * expensive one reads them (see server/digest.ts and the router-digest omp * extension). */ export interface DigestConfig { /** Master switch; the omp extension polls this as its policy. */ enabled: boolean; /** Tool results smaller than this pass through untouched. */ minBytes: number; /** Results larger than this are left alone (too costly even for a cheap model). */ maxBytes: number; /** Tool names (lower-case) whose results may be digested. Never errors, never edits/writes. */ tools: string[]; /** Digest only when the session's current model is at or above this tier. */ fromTier: Tier; /** Tier the digest model is picked from (cheapest candidate that fits). */ tier: Tier; /** Pin a specific digest model; empty ⇒ pick from `tier`. */ model: string; maxOutputTokens: number; /** Skip when the digest itself would cost more than this, USD. */ maxCostUsd: number; timeoutMs: number; /** * Harness tool names → the canonical names `tools` lists (read, grep, * glob, bash, ls, web_fetch, …). Hermes calls its reader `read_file`, * Cline `execute_command`, OpenCode `webfetch`; the alias table lets one * `tools` list serve every harness. Lower-case keys; unknown names pass * through unchanged. */ toolAliases: Record; } /** Usage-report options. */ export interface ReportConfig { /** * Models to price the window's traffic on as if every turn had used that * one model, at its list price with the window's own cache hit rate: the * "what the router saved" counterfactual. Slugs missing from the catalog * are skipped. */ baselines: string[]; /** * Post a one-screen summary of the last 24 hours (spend, top models, cache * hit, escalations, soft-failure spikes, Ollama meter) into the transcript * at the first omp session start of each day. `/router summary` shows it * on demand regardless. */ dailySummary: boolean; } /** * Harness-side model switch (experimental): the router advises a tier for * each user prompt and the harness moves its own active model to a * harness-native one for the mapped tiers. See omp-extension/router-switch.ts. */ /** The Anthropic Messages wire (`POST /v1/messages`, Claude Code). */ export interface AnthropicConfig { /** * Which router profile a Messages `model` name means, first matching glob * wins. Claude Code asks for `claude-*` names; unmatched names pass through * so profile ids (`auto`, `auto-max`) still work. */ models: Record; } export interface HarnessSwitchConfig { enabled: boolean; /** * Tier → harness model as `provider/id` in the harness's own registry * (e.g. `hard: anthropic/claude-opus-4-8`). A tier serves itself and every * tier above it up to the next mapped one; unmapped low tiers stay on the * router. Turns on a native model bill the harness's own provider (a * subscription, typically) and never reach the ledger. */ models: Partial>; /** Advice below this heuristic confidence leaves the model where it is. */ minConfidence: number; } export interface BudgetConfig { /** Reject or downgrade when a turn's cold forecast exceeds this, USD. */ perTurnUsd?: number; /** Force the cheapest viable tier once a conversation exceeds this, USD. */ perConversationUsd?: number; /** Rolling 24h ceiling, USD. */ perDayUsd?: number; /** * Calendar-month (UTC) target, USD. Paced: the per-day ceiling becomes * min(perDayUsd, remaining ÷ days left in the month), so a month that runs * ahead of pace tightens automatically instead of failing on its last day. * Scoped per harness like perDayUsd. */ perMonthUsd?: number; /** At the ceiling: drop to the cheapest viable model, or fail the request outright. */ onExceeded: "downgrade" | "reject"; } /** * A virtual model exposed to clients. `auto` is the general profile; the * others bias the same machinery toward cost or quality. */ export interface ProfileConfig { /** Model id as clients see it, e.g. `auto`. */ id: string; /** Display name in omp's model picker. */ name: string; /** Clamp classification to at most this tier. */ maxTier: Tier; /** Floor classification at this tier. */ minTier: Tier; /** Context window advertised to omp; drives its compaction threshold. */ contextWindow: number; /** Max output tokens advertised to omp. */ maxTokens: number; /** Per-profile budget overrides. */ budget?: Partial; } export interface LedgerConfig { /** * Where the ledger lives: a SQLite path (default * `$AUTO_MODEL_ROUTER_HOME/router.db`), or a `postgres://` URL for a store * two replicas share. The shared store holds what correctness depends on * being one copy — the turn rows a cap is counted from, conversation * routing memory, and the context blocks. The local caches (catalog * payloads, benchmark feeds, the summary marker) stay in a SQLite file * beside the config either way: a cache is a per-process convenience, and * sharing one would only add contention. */ path: string; /** Window for the blended rate published to omp, days. */ blendWindowDays: number; /** Requests required before the measured blend replaces `fallbackBlend`. */ blendMinSamples: number; /** Blend used before enough samples exist, USD per million tokens. */ fallbackBlend: { inputPerMtok: number; outputPerMtok: number }; /** Drop conversation state untouched for longer than this, ms. */ conversationTtlMs: number; /** * Delete ledger rows — and the feedback keyed to them — older than this * many days, checked at most hourly. `null` (the default) and 0 both keep * everything. * * The default is to keep, because how long a record of what people asked a * model is retained is a decision an operator makes, not one a default * should make for them: deleting is the irreversible direction. The ledger * grows ~2.5 MB a day under steady use, and trust, reports and replay only * read windows well inside a year, so a deployment that wants a window * loses nothing by setting one. Freed pages are reused (and released where * the engine can), so the file mostly stops growing rather than shrinking. */ retentionDays: number | null; } /** One redaction rule: a name, the pattern it matches, and what replaces a match. */ export interface RedactionRule { /** * Identifies the rule in errors and in the default replacement. Echoed into * the prompt as `[redacted:]`, so it must not itself be a secret. */ name: string; /** * Regular-expression SOURCE (no delimiters, no flags), compiled once at * load under a guard that refuses the shapes with exponential worst cases — * see `validateRedactionPattern` in `src/server/redact.ts` for exactly what * is refused and why. */ pattern: string; /** What a match becomes. Defaults to `[redacted:]`. */ replacement?: string; } /** * Keep strings out of every request that leaves the process. * * Off by default. Enabled, each rule is applied to the rendered upstream body * just before dispatch — the one shape every front end normalises to and every * provider client renders from — so a new upstream cannot bypass it. The * ledger row records HOW MANY matches were removed and never what they were; * nothing logs the matched text at any level. */ export interface RedactionConfig { enabled: boolean; rules: RedactionRule[]; /** * Also scan tool-call arguments and tool results. Off by default: tool * results are most of a turn's prompt bytes, so this is most of the cost — * and, for an operator worried about a secret in a file the agent read, * most of the point. */ scanTools: boolean; } /** * agentdox bridge: one shared project context that follows a conversation * across model switches, plus a model-attributed transcript written back. * * Off by default. Enabling it injects a project-context block into the system * prefix; the block is PINNED per conversation and refreshed only when the * prompt cache is already cold (see `src/context/bridge.ts`), so sharing * context does not cost a cache miss every turn. */ export interface ContextConfig { enabled: boolean; /** agentdox REST base URL, e.g. `http://localhost:3003`. */ baseUrl: string; /** Bearer token with read+write on the project scope. From `AGENTDOX_TOKEN`. */ token: string; /** * Fallback project scope when a request carries no `X-Agentdox-Scope` * header. Empty ⇒ the bridge is inert for unlabelled requests rather than * guessing, so one harness cannot leak context into another's project. */ defaultScope: string; /** Per-request timeout against agentdox, ms. */ timeoutMs: number; /** * Upper bound on how stale a pinned context block may get, ms. Reaching it * forces a refresh on the next turn even if the model did not change — * the one case where the bridge knowingly spends a cache miss. 0 disables * the TTL, refreshing only on turns whose cache is already forfeit. */ maxStalenessMs: number; /** Hard cap on injected block size, characters. */ maxBlockChars: number; /** Max memory entries agentdox may select for the block. */ memoryLimit: number; /** Max docs agentdox may select for the block. Docs are whole documents, so * this is the easiest way to blow `maxBlockChars`; 0 disables them. */ docsLimit: number; /** Max recent session messages agentdox may select for the block. */ sessionLimit: number; /** * Character budget for the project brief inside the assembled block, 0 to * omit. The brief is query-independent curated context (overview, style, * gotchas, decision log) that renders FIRST, where the prompt cache holds it. * It grows by one entry per recorded decision, so it takes an explicit * budget; measured on two live scopes, static sections ~1.6k-8.6k chars and * the decision log the rest. Keep this well inside `maxBlockChars` so the * query-relevant tail always fits too. */ briefChars: number; /** * Send the context layers a front door names on the request — the group * scope (`X-Agentdox-Group`, rendered first), the personal scope * (`X-Agentdox-Personal`, rendered last) and the member (the harness id, * which filters the project's recent tail and tags recorded turns). A * router without a team has none to send and its block is unchanged; this * is the kill switch for a team that wants single-scope blocks back. */ layers: boolean; /** Write settled turns back to agentdox sessions, tagged with the served model. */ recordTurns: boolean; /** * Inject the block into turns that carry NO tool schemas too. Those are * harness utility calls (titles, ratings), which recording already skips; * default off, because each one paid the whole block for an answer that is * about the conversation, not part of it. */ injectWithoutTools: boolean; /** Bound on queued write-backs; excess turns are dropped, never buffered unbounded. */ maxQueue: number; } /** * Context optimization (compaction): before dispatch, shrink stale, low-value * bulk — chiefly old tool output — so long agentic conversations cost less and * keep fitting narrower-window models. Deterministic and reversible-by-reference: * every elision leaves an in-band breadcrumb so the model can re-run the tool. * Off by default. See docs/context-optimization.md. */ export interface CompactionConfig { enabled: boolean; /** Compact when the estimated prompt exceeds this many tokens. */ budgetTokens: number; /** * Target fraction of `budgetTokens` to compact DOWN to once compaction * fires. Below 1 the plan overshoots, so it stays byte-stable for several * turns instead of gaining an edit per turn; every plan change rewrites * already-cached prompt bytes, and a cold prompt costs ~4.3x a warm one. */ floorRatio: number; /** * Only extend an existing plan once the compacted prompt has grown by this * factor since the plan was last made. 1 (the default) re-plans on every * over-budget turn. * * `floorRatio` rations re-planning only when the budget is reachable. On * the traffic actually observed it is not — compacted prompts sit at * 100–160k tokens against a 40k budget — so every turn is over budget and a * new edit is added the moment a tool result ages out of the protected * window. Measured over a week of same-model turns: the plan changed on * 1,031 dispatches at a 79.5% cache hit and $0.0120 each, against 92.6% and * $0.0067 when it held. At 1.1 a plan holds until the prompt is 10% larger * than when it was made — several turns in a deep loop — at the cost of * that much more stale tool output riding along in between. Fit-to-window * compaction is never rationed; a prompt that would overflow always * re-plans. */ replanGrowthRatio: number; /** Also compact when the prompt would overflow the profile's context window. */ fitToWindow: boolean; /** Never touch the last N user/assistant turns or the volatile tail. */ protectRecentTurns: number; /** Tool results larger than this (outside the protected window) are truncated. */ maxToolResultBytes: number; /** Bytes of a truncated tool result's head to keep. */ keepHeadBytes: number; /** Bytes of a truncated tool result's tail to keep. */ keepTailBytes: number; /** Elide an older tool result when a newer call to the same resource supersedes it. */ elideSupersededReads: boolean; /** * Summarising compaction: when the plan gains an edit, a cheap model * (`digest.tier` / `digest.model`, under `digest.maxCostUsd` and * `digest.timeoutMs`) digests the tool result instead of it being cut to * head+tail or a stub. The digest is stored on the edit, so the bytes sent * stay identical on later turns. Applies when the turn routed at or above * `digest.fromTier`; does not need `digest.enabled`. */ digestToolResults: boolean; /** Digests per turn at most; the rest of a plan's new edits stay plain until a later turn. */ digestMaxPerTurn: number; /** Collapse byte-identical repeated tool results to a single copy. */ collapseDuplicateResults: boolean; } /** Which API a named upstream speaks. */ export type UpstreamKind = "openai" | "azure" | "anthropic"; /** One model a named upstream serves, with what routing needs since there is no catalog to fetch. */ export interface UpstreamModelConfig { /** The model id the provider knows (an Azure deployment name for `azure`). The catalog slug is `/`. */ id: string; name?: string; /** * USD per million prompt tokens. Absent ⇒ the OpenRouter twin's price, which is what * a subscription upstream wants: the same weights are sold per token there, so the * twin is the only honest figure for what a turn is worth. Pair it with `costBias` * to rank prepaid capacity below list without pretending it is free. */ input?: number; /** USD per million completion tokens. Absent ⇒ the OpenRouter twin's. */ output?: number; /** USD per million cached prompt tokens, when the provider discounts them. */ cachedInput?: number; /** USD per million prompt tokens written to cache (Anthropic). */ cacheWrite?: number; /** Absent ⇒ the OpenRouter twin's, else 128k. */ contextLength?: number; maxCompletionTokens?: number; supportsTools?: boolean; supportsReasoning?: boolean; supportsToolChoice?: boolean; /** Accepts images. Absent ⇒ the twin's. */ vision?: boolean; /** 0-100 scores; absent ⇒ borrowed from the OpenRouter twin. */ quality?: { intelligence?: number; coding?: number; agentic?: number }; /** The OpenRouter slug whose scores and capabilities this model borrows; absent ⇒ matched by name. */ twin?: string; } /** * A named upstream beside OpenRouter and Ollama Cloud: OpenAI, Azure OpenAI, * Anthropic, or any OpenAI-compatible server (vLLM, a gateway). Its models * enter the catalog as `/` and dispatch to it. */ export interface UpstreamEntry { /** Lowercase slug; the catalog namespace. Must not be an OpenRouter vendor namespace such as `openai` or `anthropic`. */ id: string; kind: UpstreamKind; enabled: boolean; /** `https://api.openai.com/v1`, `https://.openai.azure.com`, `https://api.anthropic.com`, `http://vllm:8000/v1`. */ baseUrl: string; apiKey: string; /** Azure only: the `api-version` query parameter. */ apiVersion: string; /** `api-key` sends `x-api-key`; `oauth-bearer` sends `Authorization: Bearer` plus the Claude OAuth beta headers (a Claude Pro/Max subscription token). */ auth: "api-key" | "oauth-bearer"; /** Extra request headers, e.g. a gateway's own auth. */ headers: Record; timeoutMs: number; rateLimitCooldownMs: number; quotaCooldownMs: number; /** * Ranking multiplier on this upstream's forecast cost, like `ollama.costBias`: below 1 * prefers capacity that is already paid for (a Claude Pro/Max subscription) without * pricing it at zero, which would win every tier and make quality floors meaningless. * Ranking only — the ledger still records the price the catalog carries. Default 1. */ costBias: number; models: UpstreamModelConfig[]; } export interface RouterConfig { server: ServerConfig; openrouter: OpenRouterConfig; ollama: OllamaConfig; /** Named direct upstreams; empty by default. */ upstreams: UpstreamEntry[]; benchmarks: BenchmarksConfig; tiers: Record; tasks: Record; filters: FilterConfig; classifier: ClassifierConfig; escalation: EscalationConfig; hysteresis: HysteresisConfig; exploration: ExplorationConfig; cache: CacheConfig; context: ContextConfig; compaction: CompactionConfig; budget: BudgetConfig; report: ReportConfig; digest: DigestConfig; harnessSwitch: HarnessSwitchConfig; anthropic: AnthropicConfig; profiles: ProfileConfig[]; ledger: LedgerConfig; redaction: RedactionConfig; /** * Relax a tier's quality floor to a catalog-derived band when the configured * floor is met by fewer than three available models (never tightening it). * Without this, a narrow OpenRouter guardrail leaves every tier above * `trivial` permanently empty and the router is stuck on the cheapest model. * A floor that at least three models meet stands exactly as configured, so * on a wide catalog this is a no-op — an earlier version relaxed * unconditionally and a wide catalog's weak tail dragged every floor down. */ adaptiveTierFloors: boolean; /** * Derive each tier's input-price ceiling from the price spread of the models * actually available at every catalog refresh (quantile bands), instead of * fixed `tiers.*.maxInputPerMtok` dollars. Lets the same config self-tune to * whatever models a key admits — a hard cap becomes "drop this catalog's * priciest outliers", not a magic dollar value. An explicit ceiling still * tightens further. Off by default (fixed ceilings). */ adaptivePriceCeilings: boolean; logLevel: "silent" | "error" | "warn" | "info" | "debug"; }