Skip to content

feat(routing): smart model routing for Nous Portal (session + delegation) - #43379

Closed
OutThisLife wants to merge 2 commits into
mainfrom
bb/model-routing
Closed

feat(routing): smart model routing for Nous Portal (session + delegation)#43379
OutThisLife wants to merge 2 commits into
mainfrom
bb/model-routing

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

An "Auto" model picker for Hermes, scoped to the Nous Portal. A cheap classifier labels each incoming request's complexity tier (light / standard / heavy) and runs it on a tier-appropriate Portal model. Hard tasks get a frontier model; trivial ones get something small and fast.

Nous Portal only. Every tier resolves through the nous provider — the Portal already fronts frontier models across vendors (anthropic/…, openai/…, google/…, x-ai/…) behind one credential, so a single Portal key covers every tier. The router only engages when the active (session) / parent (delegation) model is itself on Nous Portal, and it short-circuits before the classifier call, so off-Portal users never incur a picker cost and are never silently moved onto Nous.

Cache-safety

Hermes' per-conversation prompt cache is sacred, so routing only happens where there is no cached prefix to invalidate:

  • Session start — classifies the first message of a fresh session and picks the model before the first API call. Resumed sessions are never re-routed.
  • Delegation — classifies each delegate_task subtask's goal and picks the subagent's model (subagents start from fresh context).

It never swaps the main model mid-conversation — that stays /model's job (which deliberately resets the cache). An explicit delegation.model / /model always wins.

Design

  • Off by default, opt-in via smart_model_routing.enabled.
  • Fail-open: a broken/slow/misconfigured classifier falls back to default_tier and never wedges a turn.
  • Quality-first: the classifier is told to bias toward the higher tier when unsure; min_tier floor lets you forbid the light tier.
  • Tiers are bare Nous Portal model ids; credentials resolve through the same runtime-provider resolver delegation uses.

Changes

  • agent/model_router.py — classifier + tier resolution + Nous-only gate.
  • agent/conversation_loop.py — session-start routing (_maybe_apply_session_routing).
  • tools/delegate_tool.py — delegation routing (_route_task_creds).
  • hermes_cli/config.pysmart_model_routing block + auxiliary.routing_classifier (defaults to Nous Portal).
  • website/docs/user-guide/features/smart-model-routing.md — feature docs.

Test plan

  • scripts/run_tests.sh tests/agent/test_model_router.py — 22/22 pass.
  • Off-Portal session/parent → strict no-op, classifier never called.
  • Disabled by default → inert.
  • Tier maps to current model → no switch (cache-safe).
  • min_tier floor honored; classifier fail-open to default_tier.
  • Explicit delegation.model beats routing.

Opt-in, cache-safe "Auto" model picker. A cheap classifier labels an
incoming request's complexity tier (light/standard/heavy) and routes it
to a tier-appropriate model — at the only two points with no cached
prefix to invalidate: the start of a fresh session (before the first API
call) and each delegate_task boundary (subagents start fresh). It never
swaps the main model mid-conversation (that stays /model's job).

- agent/model_router.py: classifier via auxiliary.routing_classifier,
  tier->model resolution with min_tier floor, fail-open everywhere,
  no-op when the chosen model matches the current one (no cache break).
- conversation_loop.py: _maybe_apply_session_routing fires once per
  fresh session before the system prompt is built.
- delegate_tool.py: _route_task_creds picks each subtask's model by goal;
  explicit delegation.model still wins.
- config.py: smart_model_routing section (off by default) +
  auxiliary.routing_classifier task.
- Docs + 18 unit tests.
@github-actions

github-actions Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/model-routing vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 10657 on HEAD, 10656 on base (🆕 +1)

🆕 New issues (19):

Rule Count
unsupported-operator 10
unresolved-attribute 6
invalid-argument-type 2
unresolved-import 1
First entries
tests/cli/test_resume_display.py:716: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["resume_display"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/hermes_cli/test_aux_config.py:47: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["session_search"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/tools/test_browser_console.py:341: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["record_sessions"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/tools/test_web_providers.py:219: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["extract_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/hermes_cli/test_mcp_reload_confirm_gate.py:33: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
hermes_cli/config.py:4738: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/cli/test_fast_command.py:484: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/cli/test_reasoning_command.py:552: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/tools/test_web_providers.py:218: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["search_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/agent/test_curator.py:1038: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["curator"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/hermes_cli/test_aux_config.py:54: [unresolved-attribute] unresolved-attribute: Attribute `keys` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/tools/test_web_providers.py:217: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/hermes_cli/test_aux_config.py:37: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["title_generation"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/agent/test_model_router.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/gateway/test_whatsapp_reply_prefix.py:119: [unsupported-operator] unsupported-operator: Operator `>=` is not supported between objects of type `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements` and `int`
tests/hermes_cli/test_kanban_core_functionality.py:3375: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/hermes_cli/test_destructive_slash_confirm_gate.py:32: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
tests/tools/test_browser_lightpanda.py:242: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["engine"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`
hermes_cli/config.py:4728: [unresolved-attribute] unresolved-attribute: Attribute `items` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 32 union elements`

✅ Fixed issues (18):

Rule Count
unsupported-operator 10
unresolved-attribute 6
invalid-argument-type 2
First entries
hermes_cli/config.py:4684: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/tools/test_web_providers.py:218: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["search_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
hermes_cli/config.py:4674: [unresolved-attribute] unresolved-attribute: Attribute `items` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/tools/test_browser_console.py:341: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["record_sessions"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_destructive_slash_confirm_gate.py:32: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/tools/test_browser_lightpanda.py:242: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["engine"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/cli/test_resume_display.py:716: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["resume_display"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/agent/test_curator.py:1038: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["curator"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_aux_config.py:54: [unresolved-attribute] unresolved-attribute: Attribute `keys` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/tools/test_web_providers.py:219: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["extract_backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_aux_config.py:47: [unsupported-operator] unsupported-operator: Operator `not in` is not supported between objects of type `Literal["session_search"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/gateway/test_whatsapp_reply_prefix.py:119: [unsupported-operator] unsupported-operator: Operator `>=` is not supported between objects of type `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements` and `int`
tests/cli/test_fast_command.py:484: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/cli/test_reasoning_command.py:552: [invalid-argument-type] invalid-argument-type: Argument to bound method `TestCase.assertIn` is incorrect: Expected `Iterable[Any] | Container[Any]`, found `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/tools/test_web_providers.py:217: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["backend"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_kanban_core_functionality.py:3375: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_aux_config.py:37: [unsupported-operator] unsupported-operator: Operator `in` is not supported between objects of type `Literal["title_generation"]` and `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`
tests/hermes_cli/test_mcp_reload_confirm_gate.py:33: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `str`, `list[Unknown]`, `list[str]`, `None`, `int` in union `str | dict[Unknown, Unknown] | list[Unknown] | ... omitted 31 union elements`

Unchanged: 5567 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/delegate Subagent delegation P3 Low — cosmetic, nice to have duplicate This issue or pull request already exists labels Jun 10, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competing with #37444 for the same opt-in smart-model-routing feature. Saturated cluster — prior attempts in #18930 and #20327 (both closed) and discussion in #33223. Marking duplicate of the canonical open PR (#37444); maintainers can pick the preferred implementation.

Smart model routing now ships as a Nous Portal capability. The router
only engages when the active (session) / parent (delegation) model is on
Nous Portal, and short-circuits BEFORE the classifier call so off-Portal
users never incur a picker cost. Every tier resolves through the Nous
provider (the Portal fronts frontier models across vendors behind one
credential), so tiers are configured as bare Nous model ids.

- model_router: Nous-only gate in route() ahead of classification; tiers
  resolve via the nous provider; _tier_model accepts a bare id (legacy
  {provider, model} dict still accepted, provider ignored).
- config: routing_classifier defaults to provider nous; tiers default to
  bare Portal model ids.
- docs: Prerequisites + Nous-only framing.
- tests: route() gate (off-Portal no-op skips classifier), tier-resolves
  -through-nous; all 22 pass.
@OutThisLife OutThisLife changed the title feat(routing): smart model routing (session + delegation) feat(routing): smart model routing for Nous Portal (session + delegation) Jun 10, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator Author

@teknium1 closing this for now

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint duplicate This issue or pull request already exists P3 Low — cosmetic, nice to have tool/delegate Subagent delegation type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants