Skip to content

fix(auxiliary): use a named lane's own default model when a task pins no model - #348

Merged
OmarB97 merged 1 commit into
mainfrom
fix/aux-task-provider-default-model-fork-2026-08-02
Aug 2, 2026
Merged

fix(auxiliary): use a named lane's own default model when a task pins no model#348
OmarB97 merged 1 commit into
mainfrom
fix/aux-task-provider-default-model-fork-2026-08-02

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The line

agent/auxiliary_client.py:4924, inside resolve_provider_client:

if not model and provider != "auto":
    model = _get_aux_model_for_provider(provider) or _read_main_model() or model

That is the universal blank-model fallback for every concrete provider, and
it is right for the case it was written for: an xai-oauth user with grok-4.3
configured should get grok-4.3 for title generation rather than silently
dropping to some catalog default (NousResearch#31845). _get_aux_model_for_provider only
knows built-in providers, so for a name that is one of the user's own
providers: / custom_providers: entries it returns "" and
_read_main_model() — the model.model value from config.yaml — wins.

So this config:

model:
  provider: deepseek
  default: deepseek-chat

providers:
  my-local-lane:
    api: "http://127.0.0.1:8080/v1"
    default_model: "qwen3-4b"

auxiliary:
  approval:
    provider: "my-local-lane"     # note: no `model:`

resolved the endpoint perfectly and then asked it for deepseek-chat. The
entry's own default_model was never consulted. On a local backend serving a
different model set that is a 404, or — on a proxy that quietly substitutes —
a silent dud. Measured before/after on the resolver, same config:

PRE-FIX   -> base_url=http://127.0.0.1:8080/v1/  model=deepseek-chat
WITH FIX  -> base_url=http://127.0.0.1:8080/v1/  model=qwen3-4b

Worth being explicit about why this hid so well: provider, base_url and
api_key were all resolved correctly. Any test asserting "did we reach the
right endpoint" passed. Only the model field on the request was wrong.

Why #340 did not fix it

PR #340 added auxiliary.route and hit this exact wall: a route pointed at a
named entry would have shipped the main model to the routed lane, "the one
thing this lane must not do". Its _auxiliary_route_target therefore reads the
entry's default_model itself before handing off, and its comment names the
line above as the reason.

That fix was scoped to the route on purpose. auxiliary.route was brand-new
config — nothing on disk anywhere used it, so widening the route's model
resolution could not regress an existing user. The per-task
auxiliary.<task>.provider pin is years-old config that people already run, so
changing what a blank model: means there is a behavior change that deserved
its own change and its own tests rather than riding along inside a feature PR.
This is that follow-up.

What changes for a user

A task that names one of your own provider entries and pins no model now sends
that entry's default_model. Nothing else moves:

Config Before After
provider: my-local-lane (no model:) main chat model entry's default_model
provider: my-local-lane, model: tiny tiny tiny
provider: auto / provider: main main chat model main chat model
provider: my-local-lane, entry has no default_model main chat model main chat model
provider: openrouter (or any built-in) catalog default → main model unchanged

Where the fix lives

In _resolve_task_provider_model, not at line 4924. Two reasons:

  1. It is the funnel. All five auxiliary entry points go through it —
    call_llm, async_call_llm, get_text_auxiliary_client,
    get_async_text_auxiliary_client, resolve_vision_provider_client — so the
    sync path, the async twin and the vision path are all covered by one site.
    resolve_vision_provider_client reaches resolve_provider_client through
    _get_cached_client, so the vision lane carried the main model too.
  2. The raw config string still exists there. By the time
    resolve_provider_client runs, provider: main has already been resolved
    into the main provider's id — on the vision path by
    _normalize_vision_provider, inside resolve_provider_client by
    _normalize_aux_provider. A fix at line 4924 could not tell
    provider: main (which must keep inheriting the main model) from
    provider: custom:my-lane (which must not). Upstream it can, so the
    sentinels stay honest.

auto, main and bare custom are excluded as main-lane sentinels — all
three mean "the lane the main runtime already resolved". Bare custom belongs
in that set: it is the anonymous OPENAI_BASE_URL / model.base_url endpoint
the main lane owns, not a named row. The lookup also runs after the
direct-API alias expansion, so provider: openai (which rewrites to
custom + api.openai.com) cannot pair a same-named entry's model with the
aliased endpoint.

Line 4924 itself is untouched and remains the last resort, so nothing outside
the per-task path — agent_init's main-agent client, the fallback chains,
trajectory_compressor — changes at all.

_auxiliary_route_target's inline lookup is now the shared
_named_provider_default_model, so there is one implementation. It normalises
both config shapes, because _get_named_custom_provider already does:
providers.<name>.default_model (dict shape) and custom_providers[].model
or default_model (legacy list shape) all arrive as entry["model"].

Testing

scripts/run_tests.sh over every test file that touches the auxiliary resolver
(135 files across tests/agent, tests/hermes_cli, tests/run_agent,
tests/tools, tests/gateway, tests/cron, tests/cli, tests/tui_gateway):
3937 tests, 0 failures.

The keep-green set called out for this change — tests/agent/auxiliary_client/,
test_auxiliary_route.py, test_image_routing.py, test_auxiliary_main_first.py
— passes unchanged.

New: tests/agent/test_auxiliary_task_provider_model.py (24 tests). Like
test_auxiliary_route.py it drives the real resolution path against a temp
HERMES_HOME with a real config.yaml, and asserts on the model kwarg that
actually reaches .chat.completions.create() — a stub at the client seam is
exactly what let this survive. Headline case: a named lane with no model pinned
sends the entry's model, not the main chat model.

Also covered: a task-level model: still wins; an explicit call argument still
wins; auto / main / bare custom still inherit the main model even with an
entry literally named custom on disk
; model: auto is still nulled before the
lane lookup so the literal string never reaches the wire; an entry with no
default model falls back exactly as before; a first-class provider is untouched;
the custom:<name> menu-key spelling resolves the same way; both legacy
custom_providers[] model spellings work; the vision path and the async client
get the entry's model; provider: main on the vision path still inherits the
main model; and the shared helper returns None rather than raising when config
loading breaks.

Verified the new tests fail against the unfixed resolver: with the new block
disabled, 10 of the 24 fail and the 14 guard tests still pass.

… no model

`auxiliary.<task>.provider: my-local-lane` with no `model:` sent the MAIN
chat model's id to that endpoint. `resolve_provider_client` fills a blank
model for every concrete provider from `_read_main_model()`, so a task that
named one of the user's own `providers:`/`custom_providers:` entries got the
main model paired with the entry's base_url — a 404 from a local backend
serving a different model set, not an answer.

The entry's own `default_model` is what "use that provider" reads as, so it
now fills the blank first, in `_resolve_task_provider_model` — the one
function every auxiliary entry point funnels through (call_llm,
async_call_llm, get_text_auxiliary_client, get_async_text_auxiliary_client,
resolve_vision_provider_client), which is also where the raw config string
still exists.

`auto`, `main` and bare `custom` are excluded: they mean "the lane the main
runtime already resolved", so they keep inheriting the main chat model, and
`_read_main_model()` stays the last resort for a lane that declares no model
of its own.

PR #340 added exactly this rule for `auxiliary.route` and deliberately left
the per-task path alone to keep its blast radius small. Its inline lookup is
now the shared `_named_provider_default_model`, which normalises both config
shapes (`providers.<name>.default_model` and `custom_providers[].model`).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@OmarB97
OmarB97 merged commit 9f6ab34 into main Aug 2, 2026
35 checks passed
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.

1 participant