Skip to content

feat(auto-combo): add nadir router strategy for prompt-aware model choice - #13051

Open
doramirdor wants to merge 4 commits into
diegosouzapw:release/v3.8.52from
doramirdor:feat/nadir-router-strategy
Open

doramirdor wants to merge 4 commits into
diegosouzapw:release/v3.8.52from
doramirdor:feat/nadir-router-strategy

Conversation

@doramirdor

Copy link
Copy Markdown
Contributor

Summary

Adds a nadir auto-router strategy: the first RouterStrategy that reads the request, not only the candidates.

Every existing strategy (rules, score, cost, latency, sla-aware, lkgp) ranks the pool by its own telemetry. The only difficulty signal in the engine (complexityRouter.ts) is a keyword heuristic feeding two low-weight scoring factors, so nothing today picks the model tier a prompt needs. nadir sends the last user turn plus the pool's model ids to Nadir's decision API (POST /v1/bucket) and routes to the model Nadir selects from that menu (simple → cheapest capable, complex → frontier). The connection serving that model is still chosen by rules, so quota, health and cost keep deciding which account; Nadir only decides which model.

Opt-in via config.routerStrategy: "nadir", configured with config.nadir: { apiKey, baseUrl, timeoutMs } (env fallbacks OMNIROUTE_NADIR_API_KEY / OMNIROUTE_NADIR_BASE_URL; baseUrl only matters for a self-hosted Nadir).

Design constraints, all covered by tests:

  • Fail-open. Timeout (default 2000 ms), non-2xx, unreachable host, malformed JSON, missing prompt, or a selection outside the pool all resolve to the rules decision, with the reason prefixed NadirStrategy: fallback (…). Never a 5xx.
  • Bounded cost of an outage. After a failed call the strategy skips the network for 30 s (per base URL), so an unreachable endpoint costs one timeout per 30 s, not one per request.
  • Minimal egress. Only the last user message's text (capped at 16k chars), the candidate model ids and a source: "omniroute" tag leave the box. No system prompt, history, tools or headers. The outbound call goes through safeOutboundFetch with the provider outbound URL guard.
  • Honest telemetry. strategy reads nadir in routing events only when Nadir actually made the choice; fallbacks report the fallback's name.

Mechanics:

  • RouterStrategy gains an optional selectAsync. New selectWithStrategyAsync prefers it and runs every existing sync strategy unchanged; resolveAutoStrategy.ts awaits it (it already runs inside an async function). selectWithStrategy is untouched.
  • RoutingContext carries the raw messages and the combo's nadir block, parsed in parseAutoConfig.
  • AUTO_ROUTING_STRATEGY_VALUES (MCP omniroute_set_routing_strategy enum), ROUTER_STRATEGY_OPTIONS (combo builder dropdown) and comboRuntimeConfigSchema (nadir block, .strict()) learn the new value.

Disclosure: I maintain Nadir. The strategy is opt-in and inert unless a combo selects it; nothing else in the routing path changes.

Related Issues

Validation

Change type: routing (Contribution Golden Path → Routing).

  • Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
  • Focused tests and category gates from the golden path
  • npm run lint (eslint on every touched file, zero findings)
  • Reconciled with the current active release base (release/v3.8.51 tip ba597b6)
  • Production-code changes include a new or updated automated test in this PR
  • SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate.

Commands run locally:

node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/router-strategy-nadir.test.ts tests/unit/router-strategies.test.ts   # 39/39
npm run test:combo:matrix        # 15/27 — both `auto` cases pass; the 12 failures are pre-existing, see below
npm run test:vitest              # 51 files / 465 tests, covers the MCP schema enum
npm run check:known-symbols      # OK (pre-existing note about 11 unfrozen MCP tools, unrelated)
npx tsc --noEmit -p open-sse/tsconfig.json
npx eslint <touched files> && npx prettier --check <touched files>

test:combo:matrix on this branch: 15 pass / 12 fail. The same command on the untouched ba597b6 fails the identical 12 cases (priority fallback, round-robin, least-used, random, strict-random, p2c, reset-aware, headroom, lkgp, DRR fairness, weighted, fusion): every one of them dispatches to claude/claude-3-5-sonnet-20241022, which the model-lifecycle guard now rejects (Model "claude/claude-3-5-sonnet-20241022" was shut down on 2025-10-28 and cannot be routed automatically, HTTP 410), so the claude target never answers. The two auto cases, the only ones that reach the changed code, pass on both. The fixture model id needs a refresh, which I left out of this PR to keep it scoped; happy to open a separate one.

Live contract check (keyless, zero cost): POST https://api.getnadir.com/v1/bucket with a 6-model menu returns selected_model verbatim from the menu, plus bucket and confidence; the strategy binds only to those three fields.

Tests Added Or Updated

  • tests/unit/router-strategy-nadir.test.ts (new, 13 tests): happy path incl. request shape (URL, X-API-Key, menu, prompt, timeout), connection choice delegated to rules, confidence clamp, OPEN-breaker exclusion, every fail-open path (unknown model, empty response, non-2xx, thrown error, bad JSON), no-network on textless requests, failure cooldown with a fake clock, base URL normalization (/v1 suffix, invalid scheme), env fallbacks, prompt cap, sync select() fallback, selectWithStrategyAsync dispatch for async and sync strategies, helper units.
  • tests/unit/router-strategies.test.ts: listStrategies expectation now includes nadir.

Coverage Notes

open-sse/services/autoCombo/nadirStrategy.ts is new and exercised end to end by the new test file through an injected transport (no global fetch mocking); the only untested lines are the default transport's two lazy imports of safeOutboundFetch / getProviderOutboundGuard. The 10-line additions to routerStrategy.ts, autoConfig.ts and resolveAutoStrategy.ts are covered by the same tests plus the existing combo matrix. No touched file loses coverage.

Reviewer Notes

  • The only behavior change on the existing path is await selectWithStrategyAsync(...) in resolveAutoStrategy.ts; for every strategy other than nadir it resolves synchronously to the same select() result as before.
  • The one network call per request is the whole point of the strategy, hence opt-in, hard-timed-out, cooled down after failure, and fail-open. If you prefer a global kill switch on top of the per-combo opt-in, say so and I will add an env flag.
  • config.nadir.apiKey lives in combo config like sla* and judgeModel do; operators who prefer to keep it out of the DB can use OMNIROUTE_NADIR_API_KEY instead.
  • No changelog fragment yet: the filename needs the PR number, I will push it as a follow-up commit once the number exists.

@diegosouzapw

Copy link
Copy Markdown
Owner

This is careful, well-engineered work — the fail-open guarantees, the outbound-fetch guard, and the minimal-egress design (last user turn only, capped, no history/tools) all check out reading the code. Given you've disclosed maintaining Nadir yourself, I want to flag this for the repo owner as a policy decision rather than a pure code review: this would be the first vendor-authored SaaS routing integration shipped as a core RouterStrategy, and I think that's worth an explicit yes/no before merge rather than treating it as "just another strategy" PR, precedent-wise. Separately, a small but real gap: OMNIROUTE_NADIR_API_KEY / OMNIROUTE_NADIR_BASE_URL are read via process.env in the new code but aren't in .env.example yet — that'll need to land regardless of the policy call. Holding this as defer pending owner sign-off, not because of anything wrong with the implementation.

@diegosouzapw diegosouzapw changed the title feat(auto-combo): add nadir router strategy for prompt-aware model choice [defer] feat(auto-combo): add nadir router strategy for prompt-aware model choice Sep 15, 2026
@doramirdor

Copy link
Copy Markdown
Contributor Author

Thanks for the careful read.

Env gap closed in 993501d.

Both files, not just .env.example: check:env-doc-sync is strict in three directions (code → .env.example, .env.example → ENVIRONMENT.md, and back), so documenting the pair in one and not the other turns it red. The two entries now sit in section 6, Tool & Routing Policies, of .env.example and docs/reference/ENVIRONMENT.md.

The same commit changes how the strategy reads them, which is the part worth your eye. They were read indirectly:

const ENV_API_KEY = "OMNIROUTE_NADIR_API_KEY";
// ...
readString(process.env[ENV_API_KEY]);

scripts/check/check-env-doc-sync.mjs scans source with grep -rhoE 'process\.env\.[A-Z][A-Z0-9_]+' and then keeps only what matches ^process\.env\.(NAME)$. A computed lookup is invisible to it — which is why the gate stayed green while the two vars were undocumented, and why this needed a human reader rather than CI. They are direct member accesses now: the checker's code-var count moves 598 → 600 and it still reports in sync, so these entries cannot drift back out unnoticed. Worth knowing generally, since the same blind spot would hide any other indirect read in the repo.

The entry text describes the behavior rather than the intent: the key is optional (a keyless call goes out on Nadir's anonymous per-IP tier, which is not usable behind a shared gateway), and OMNIROUTE_NADIR_BASE_URL is only needed for a self-hosted Nadir, trailing /v1 tolerated.

Checks: check:env-doc-sync passes, as do check-docs-sync, check-doc-links, check-fabricated-docs and check-docs-frontmatter. check:docs-counts fails identically on the untouched release/v3.8.51 tip (package version, locale count, scoring-factor count, and a 171-vs-172 migration count), so none of that comes from this change. docs/reference/ENVIRONMENT.md is in .prettierignore as manually aligned, so the two new table rows are hand-aligned and nothing else in the file moved.

On the policy question: that is the right way to treat it, and I would rather it be decided explicitly than slip through as "just another strategy". Happy to leave this parked for however long the owner call takes, and I am not going to press it. If the answer is "not in core", the plugin seam (plugin.json plus an onRequest hook) reaches the same outcome with no upstream surface area, and I will close this in favor of that — just say which way you want it.

@doramirdor

Copy link
Copy Markdown
Contributor Author

Checking in, and to be clear: nothing here needs a code change from you.

Since your last comment, #13056 merged today, thanks for taking that one through the train. On this PR the one concrete gap you named is closed in 993501d: both .env.example and docs/reference/ENVIRONMENT.md, plus the switch to direct process.env.NAME access, since check-env-doc-sync.mjs matches ^process\.env\.(NAME)$ and could not see the computed lookup the original used.

So the only thing outstanding is the policy question you raised, whether a vendor-authored SaaS routing integration belongs in core as a RouterStrategy. That is the owner's call and I am not trying to push it. It would help to know whether that call is queued behind something or still open, so I know whether to keep this current or let it rest.

If it is a yes, say the word and I will rebase. The branch is 518 commits behind release/v3.8.51, but the overlap is narrow: upstream has touched .env.example, docs/reference/ENVIRONMENT.md, docs/routing/AUTO-COMBO.md, src/shared/validation/schemas/combo.ts and resolveAutoStrategy.ts. The four core files this PR edits and both test files have had zero upstream commits since my base.

@doramirdor
doramirdor force-pushed the feat/nadir-router-strategy branch from 993501d to 073d00b Compare September 18, 2026 15:26
@doramirdor

Copy link
Copy Markdown
Contributor Author

Rebased onto the current release/v3.8.51 tip (c07cebba), so this is no longer 500-odd commits stale. New head 073d00b4, replacing 993501dd. No content change: same 3 commits, same 13 files, +718/-5.

Two conflicts, both places where your tip and my branch appended to the same block:

  • .env.example — OMNIROUTE_DISABLE_CONVERSATION_TRACKING (feat(sse): allow disabling conversation tracking #13150) landed at the end of section 6. Kept yours first, then my two Nadir entries.
  • docs/reference/ENVIRONMENT.md — same section, your six self-hosted / conversation-tracking rows then my two. That file is in .prettierignore as manually aligned, so I hand-aligned rather than formatted: all 15 rows in section 6 still share the same variable and default column widths.

Nothing else was reformatted, and no other file needed touching.

Re-verified after the rebase:

  • check:env-doc-sync green, 616 code vars, no drift in any of the three directions it checks
  • typecheck:core clean
  • tests/unit/router-strategy-nadir.test.ts + tests/unit/router-strategies.test.ts: 39/39
  • tests/unit/combo/**: 320/320, which is the suite that covers resolveAutoStrategy.ts, the one non-doc file of mine that had upstream movement under it
  • check-changelog-integrity: OK

One red that is not mine: check-file-size reports open-sse/executors/base.ts at 1754 against a frozen 1753. This PR does not touch that file, and I get the identical failure from a clean worktree of the untouched tip, so it is base-red.

Still no action needed from you on the code. The policy question from my last comment is the only open item.

doramirdor added a commit to doramirdor/OmniRoute that referenced this pull request Sep 18, 2026
@doramirdor

Copy link
Copy Markdown
Contributor Author

Flagging the one red check here before it reads as mine: API Route Typecheck fails on my head, and it is base-red.

It did not run on my previous head at all, so the rebase surfaced it rather than the change causing it. My head reports 3:

✗ open-sse/executors/tinycmsDomMocks.ts TS2367 (baseline 0, live 1)
✗ open-sse/executors/tinycmsDomMocks.ts TS2322 (baseline 0, live 5)
✗ open-sse/executors/tinycmsDomMocks.ts TS2339 (baseline 0, live 7)

A clean worktree of the untouched tip (8feea123 as of writing) reports those same 3 plus two more:

✗ src/app/api/v1/_shared/rerankProviderNodes.ts TS2677 (baseline 0, live 1)
✗ src/mitm/handlers/antigravity.ts TS2677 (baseline 0, live 1)

So my head's failures are a strict subset of the base's, and this PR touches none of those three files.

Repro, symlinking node_modules in rather than reinstalling:

git worktree add /tmp/base origin/release/v3.8.51
cd /tmp/base && ln -s ../../path/to/checkout/node_modules node_modules
node scripts/check/check-api-typecheck.mjs

Same story as check-file-size, which reports open-sse/executors/base.ts at 1754 against a frozen 1753 on the untouched tip as well. Both look like they want their own PRs from someone who owns those files; I did not touch either, and I would rather not widen a frozen baseline from a PR that has nothing to do with it.

@diegosouzapw diegosouzapw added the deferred-v3.8.52 Grande demais / suspeito para o lote atual; precisa de sessão dedicada no ciclo v3.8.52 label Sep 25, 2026
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.51 to release/v3.8.52 September 29, 2026 11:26
@diegosouzapw

Copy link
Copy Markdown
Owner

Re-homed to release/v3.8.52: v3.8.51 entered its release freeze, so the branch now belongs to the release captain and development continues on the next cycle. Nothing is wrong with this PR — it just needed a live base. No action needed from you; CI will re-run against the new base.

…oice

Every RouterStrategy ranks the candidates by their own telemetry; none of
them reads the request. The `nadir` strategy sends the last user turn and
the pool's model ids to Nadir's decision API (POST /v1/bucket) and routes
to the model it selects, leaving the connection choice for that model to
the rules strategy so quota, health and cost still decide which account.

- RouterStrategy gains an optional selectAsync; selectWithStrategyAsync
  prefers it and runs every sync strategy unchanged. The auto path awaits
  it (resolveAutoStrategy already runs in an async function).
- RoutingContext carries the raw messages and the combo's config.nadir
  block ({ apiKey, baseUrl, timeoutMs }; env fallbacks
  OMNIROUTE_NADIR_API_KEY / OMNIROUTE_NADIR_BASE_URL).
- Fail-open: timeout (2 s default), non-2xx, bad JSON, missing prompt or a
  selection outside the pool resolve to the rules decision; a 30 s cooldown
  after a failure bounds the cost of an outage to one timeout per window.
- Egress is the last user message (capped at 16k chars), the model ids and
  a source tag; the call goes through safeOutboundFetch with the provider
  outbound URL guard.
- Dashboard option, Zod schema entry, MCP enum value and docs.
`OMNIROUTE_NADIR_API_KEY` and `OMNIROUTE_NADIR_BASE_URL` are read by
`nadirStrategy.ts` but were missing from `.env.example` and
`docs/reference/ENVIRONMENT.md`, which both declare themselves a complete
contract over every variable the runtime reads.

`check:env-doc-sync` stayed green on the gap because the strategy read the two
through string constants (`process.env[ENV_API_KEY]`), and the checker's
scanner only matches literal `process.env.NAME` member access. Reading them
directly instead puts both inside the gate: its code-var count goes 598 -> 600
and the contract is still reported in sync, so the entries can no longer drift
away unnoticed.

Both entries land in section 6 (Tool & Routing Policies) next to the other
combo/routing knobs, and say what the code does: the key is optional, a keyless
call lands on the anonymous per-IP tier, and the base URL is only needed for a
self-hosted Nadir.
@doramirdor
doramirdor force-pushed the feat/nadir-router-strategy branch from 073d00b to adf038a Compare September 29, 2026 14:56
@doramirdor

Copy link
Copy Markdown
Contributor Author

Thanks for re-homing this to release/v3.8.52.

Two notes on the current head c3a1b46:

The code side is done from my end. The open question is still the policy call on a vendor-authored strategy in core. Is that something you can decide in the v3.8.52 cycle? Happy to rebase onto the tip whenever you want it on the train.

@diegosouzapw diegosouzapw removed the deferred-v3.8.52 Grande demais / suspeito para o lote atual; precisa de sessão dedicada no ciclo v3.8.52 label Oct 1, 2026
@diegosouzapw diegosouzapw changed the title [defer] feat(auto-combo): add nadir router strategy for prompt-aware model choice feat(auto-combo): add nadir router strategy for prompt-aware model choice Oct 1, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants