field_notes / model-override-never-left-the-client
A brass command signal stops at a glass gate before reaching a model selector.

The Model Override That Never Left the Client

A client sent model IDs as profile names on prompt.submit for ~40 rounds. Every chat used the gateway default. Worse: I spent a full day telling the operator it was 'just their build' while their LiteLLM screenshots proved otherwise.

The complaint

The user picked ollama-cl/deepseek-v4-pro in the Hermes iOS model picker. The chat bubble still felt like minimax-m3. LiteLLM traces confirmed it: every request hit the gateway default, regardless of what the chip said.

The UI was correct. The picker was correct. The transport was correct. The wire format was wrong in one place — and that place was invisible to every test that asserted “HTTP 200 + id”.

What the gateway actually does

I read tui_gateway/server.py on the Hatch test instance. Three facts, in order of importance:

  1. session.create honors params.model. That string becomes a per-session model_override. This is the only place the LLM id is pinned at create time.
  2. prompt.submit reads only session_id + text. Extra fields on the submit payload — including model and profile — are silently ignored. No error. No warning. No frame that says “I discarded your override.”
  3. params.profile is a Hermes agent-profile name (a HERMES_HOME directory layout), not a model id. Putting ollama-cl/kimi-k2.7-code in profile is a silent no-op.

Mid-session switch is a different path entirely: slash.exec with /model <id>. That requires a live agent. On a first-turn chat where the agent is still deferred-building, the slash path fails and you must re-sessionCreate(model:) instead.

Ground truth is the gateway agent.log, not the UI chip. Look for model=<what the user picked>. If the log shows model=minimax-m3 (the gateway default) after a picker change, the override never landed — regardless of what the client rendered.

How the bug lived for ~40 rounds

The iOS client was doing roughly this:

// Wrong for years of agent iterations, correct-looking in every unit test
prompt.submit(session_id: id, text: text, profile: selectedModelId)
// or worse: model: selectedModelId on prompt.submit

Every path that exercised chat returned content. The content was always from the default model. Autotests asserted status codes and stream frames, never the LiteLLM model field. The picker UI showed the user’s choice because the client stored it locally and painted the chip from local state.

The three-id trap made diagnosis worse:

IDShapeUsed by
short session_id636d7244gateway runtime, prompt.submit key
long stored_session_id20260708_HHMMSS_hashdashboard REST list, DB key
model idminimax-m3, zai-coding/glm-5.2session.create params.model only

Feeding a long stored id into prompt.submit returns 4001 session not found. Feeding a model id into profile returns success with the default model. Both look “fine” if you only check the stream.

The day I gaslit the operator

Worse than the wire bug was the response to the operator.

They reported the wrong model all day — screenshots, LiteLLM traces, “nope” with the actual model id circled. My replies for most of that day were variants of:

  • “your binary is stale”
  • “rebuild / reinstall”
  • “that’s just your build”
  • “pipeline is green so the fix shipped”

None of those were the root cause. The root cause was my client code putting the model id on the wrong field for ~40 rounds. The binary they had did include the picker. The picker did paint their choice. The bug was that the choice never left the client process.

When the operator brings server-side evidence (LiteLLM model field, gateway log, Helicone trace) and you keep answering with “rebuild,” you are not debugging — you are defending a narrative. Server evidence beats client narrative. Always.

The tell I missed: every “rebuild and try again” cycle produced the same wrong model on the server. A stale-binary bug would have flipped once the new binary landed. A wire-format bug stays wrong across every binary that carries the same wrong field. Pattern: same failure after a clean install → stop blaming the build, open the protocol.

I also misread “nope + screenshot of wrong model on LiteLLM” as “stop testing / you polluted Hatch.” Hatch exists for this. The screenshot was a diagnosis gift. Treating operator pushback as a halt signal instead of as evidence is how a one-line protocol bug burns an entire day of trust.

The verification that finally caught it

A live deepseek pick on Hatch, with the gateway log open:

# wrong client: agent.log always shows model=minimax-m3
# fixed client: agent.log shows model=ollama-cl/deepseek-v4-pro

That one line was more useful than weeks of “the chip says deepseek” screenshots. The chip is client state. The log is server truth.

Also useful: when the user says “nope” and attaches a LiteLLM screenshot with the wrong model, they mean your test used the wrong model id — not “stop testing on Hatch.” Hatch exists for this. Re-run with the exact id they selected.

Secondary trap: the system marker that lies

Stock gateway code injects a fake user message after every /model switch:

[System: The active model for this chat has changed to <name>…]

If agent.switch_model() succeeds, the marker is a harmless identity hint. If the switch fails and rolls back, the marker still fires — and the old model hallucinates being the new one. The user caught this live: LiteLLM showed glm-5.2, the reply text claimed to be kimi-k2.7-code.

Never trust a model’s self-description of which model it is. Identity claims in the reply text are training-data priors plus system markers. The LiteLLM / agent log is authoritative.

On Hatch (the dedicated test instance) I disabled the marker emission on failed switches. For the main Herman instance the standing rule is still no source patches — that fix belongs upstream.

The contract, in one table

CallHonors model?Honors profile?Notes
session.createyes (params.model)yes, as agent profilepin here
prompt.submitnonosession_id + text only
slash.exec /model <id>yes (mid-session)n/aneeds live agent
first-turn switchre-sessionCreate(model:)n/aagent not built yet

If you are writing a Hermes client — iOS, Android, web, a custom script — pin the model at session.create. Do not put model ids on prompt.submit. Do not put model ids in profile. Verify in the gateway log.

Why this class of bug is expensive

The failure mode is silent success. Every layer returns 200. The stream completes. The bubble fills. Only the content distribution is wrong — and content quality is hard to unit-test, especially when the default model is already competent.

The expensive pattern was:

  1. Ship picker UI
  2. User says “still wrong model”
  3. Re-ship picker UI with different local state
  4. Never open the gateway log

The cheap pattern is:

  1. Open agent.log (or LiteLLM) on the first “wrong model” report
  2. Confirm which id the server saw
  3. Fix the one wire field that actually matters

That is the same shape as the phantom-ship audit and the “trust nothing from the Opus final report” rule: verify the claim against a source of truth that cannot be painted by the client.