Skip to content

fix(versa_azure): each model posts to its own deployment; an unmapped model is refused before it is sent (F1) - #230

Open
Broccolito wants to merge 1 commit into
mainfrom
fix/versa-azure-per-model-deployment
Open

fix(versa_azure): each model posts to its own deployment; an unmapped model is refused before it is sent (F1)#230
Broccolito wants to merge 1 commit into
mainfrom
fix/versa-azure-per-model-deployment

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Fixes F1 (MEDIUM-HIGH, pre-existing) of the 2026-09-10 QA run on merged main 7c96d796: on versa_azure, choosing any model other than gpt-5.5-2026-04-24 changed only the label.

What was wrong

versa_azure posted every request to one deployment, VERSA_AZURE_DEPLOYMENT = "gpt-5.5-2026-04-24". deployment_name came from config overrides only; model.model_name was never read. So the row, the composer chip, the context gauge and the cost basis all named the selected model while gpt-5.5 answered. A deployment that does not exist (gpt-4.1-bogus-qa-probe) completed a turn normally. token_events.model_id stayed honest only because it is the gateway's own usage.model.

Two more instances of the same bug:

  • complete_fast sends the fast model through complete_with_model, and it also landed on the main deployment.
  • A subagent's model override rewrites the restore binding's model but keeps the parent's route.

Measured before writing the map

All measurements on 2026-09-11, against the UCSF gateway with the operator's own key. The key was never printed.

  • No listing endpoint. GET openai/deployments and GET openai/models return 405 Method is not allowed (4 variants tried).

  • One-shot completion per advertised deployment. All nine return 200, each with its own name as model. The QA's gpt-4.1-bogus-qa-probe and the short aliases (gpt-5.5, gpt-4.1, gpt-4o) all return 404 DeploymentNotFound.

  • Context windows. Each deployment got a prompt just over its MODEL_CONTEXT_WINDOWS entry. An over-limit request is refused before inference, so none was billed. Every refusal named the registry's window, so MODEL_CONTEXT_WINDOWS is unchanged:

    deployment refusal names registry
    gpt-4o-2024-11-20 max context 128,000 128,000
    o4-mini-2025-04-16 max context 200,000 200,000
    gpt-4.1-2025-04-14 / gpt-4.1-mini-2025-04-14 max context 1,047,576 1,047,576
    gpt-5 / gpt-5.2 / gpt-5.4-mini / gpt-5.4-nano input limit 272,000 (= 400k − 128k output) 400,000
    gpt-5.5-2026-04-24 input limit 922,000 (= 1.05M − 128k output) 1,050,000
  • Reachable but not advertised; left out on purpose.

    • gpt-5-mini-2025-08-07 and gpt-5-nano-2025-08-07 answer.
    • o1-2024-12-17 and o3-mini-2025-01-31 still answer; both were removed from the catalog deliberately.
    • gpt-4o-mini-2024-07-18 returns 400 BadRequestForDependentService.

The fix (crates/biorouter/src/providers/versa_azure.rs)

  1. A measured model→deployment map. VERSA_AZURE_DEPLOYMENTS is the only list: metadata() advertises exactly these models, and each request posts to the deployment of the model it names. That also fixes the fast-model path.

  2. An unmapped model fails the turn before anything is sent. The refusal comes before the payload is built or logged: no Versa deployment for model X (Azure deployment not found, so nothing was sent); available: …. Switch this chat to one of those models.

    • It is RequestFailed, classified ModelUnavailable, which is not recoverable, so the turn stops on its first attempt.
    • Construction never fails. A failed restore is a 500 on resume, meaning a chat that won't open.
  3. The override still wins when set, but a catalog deployment is not an override. VERSA_AZURE_DEPLOYMENT_NAME is read first, then the legacy AZURE_OPENAI_DEPLOYMENT_NAME.

    • ⚠ A literal "override always wins" would have left F1 in place for every onboarded install. InstitutionalSetupCard upserts VERSA_AZURE_DEPLOYMENT_NAME: gpt-5.5-2026-04-24 on every connect, and the setup form before 2026-09-03 wrote the legacy key the same way. The QA sandbox simply didn't carry the key.
    • A catalog value is either such a default or redundant with choosing that model, so it is ignored.
    • A deployment the catalog doesn't know (for example, one UCSF adds before a Biorouter release) is still the escape hatch, and it serves every request.
  4. The restore binding's deployment is derived, not remembered. restore_binding() decides it fresh on each call, and from_resolved reads it back through the same rule. As a result:

    • CLI and GUI rebinds refresh it. The QA saw it stuck at gpt-5.5-2026-04-24.
    • Rows written before this change, with gpt-5.5 stored beside another model, self-heal on restore.
    • A subagent's model rewrite follows the new model.

    The binding shape is unchanged. Format v1 shipped in 1.89.x–1.90.x; a new shape would be a row those builds can't parse, and a 500 on resume. For a model no deployment serves, the binding stores a self-describing marker: no-versa-deployment-serves-this-model.biorouter-refuses-to-send-the-turn. An older build posts to it and gets DeploymentNotFound (measured), never another model's answer.

  5. Catalog and names.

    • All nine models are reachable, so the catalog is annotated with the measurement rather than trimmed.
    • VERSA_AZURE_KNOWN_MODELS folds into the map.
    • VERSA_AZURE_DEPLOYMENT becomes VERSA_AZURE_DEFAULT_MODEL, since it now names a model. default_model_name is unchanged, and a test asserts it is on the map.
    • with_unlisted_models() is removed. "Enter a model not listed..." could only offer a choice that is refused. biorouter models set and the workspace model check now refuse unlisted Versa models up front.
  6. Privacy config-key scan: untouched. No key was added or renamed, and every read is still a literal get_param(...). privacy::config_keys still counts 26 and still finds no computed keys.

versa_bedrock (item 5): no change needed. Both converse and converse_stream send .model_id(model_config.model_name), so the selected model is the one invoked, and Bedrock itself rejects an unknown id.

Tests

Fail-before. The new routing_tests module drives real from_env / from_resolved providers against a wiremock stand-in gateway. I ran it against the pre-fix versa_azure.rs, with the module appended unchanged:

test ...routing_tests::the_advertised_catalog_is_exactly_the_measured_deployments ... FAILED
test ...routing_tests::a_request_posts_to_the_deployment_of_the_model_it_names ... FAILED
test ...routing_tests::a_persisted_default_deployment_does_not_pin_the_model ... FAILED
test ...routing_tests::a_row_written_before_this_change_posts_to_its_own_models_deployment ... FAILED
test ...routing_tests::an_unmapped_model_is_refused_before_any_request_is_sent ... FAILED
test ...routing_tests::each_catalog_model_posts_to_its_own_deployment ... FAILED
test ...routing_tests::an_explicit_override_still_wins_for_every_model ... ok
test result: FAILED. 1 passed; 6 failed

an_unmapped_model_is_refused_before_any_request_is_sent: a model no deployment serves was answered: "gpt-5.5-2026-04-24"
each_catalog_model_posts_to_its_own_deployment: the chat named gpt-5.4-mini-2026-03-17; another deployment answered
  left: "gpt-5.5-2026-04-24"
 right: "gpt-5.4-mini-2026-03-17"

The seventh test (an explicit override still wins) passes before and after; it guards behaviour that must be preserved.

New tests in tests:

  • The routing map equals the measured snapshot.
  • The override rule, including both historically shipped defaults. A future catalog trim cannot silently turn one of them into a pin.
  • An unmapped model's refusal survives the exact session-row envelope and a restore.
  • A subagent-style model rewrite follows the new model through create_from_persisted.

Results on the final code (BIOROUTER_DISABLE_KEYRING=true on every run):

  • cargo test -p biorouter --lib -- providers::versa_azure providers::factory privacy::config_keys43 passed, 0 failed
  • cargo test -p biorouter --lib3805 passed, 0 failed, 2 ignored
  • cargo test -p biorouter --test context_windows → 4 passed
  • cargo test -p biorouter-server --lib -- a_new_private_provider_chat_requires_user_action_before_first_bind1 passed (the test that uses the renamed constant)
  • cargo fmt --all -- --check → clean
  • ./scripts/clippy-lint.sh → clean: -D warnings on all targets, too_many_lines baseline ok, no banned TLS crates

Runtime

Own sandboxed instance, launched from this worktree. The daemon was this worktree's target/debug/biorouterd, built with biorouter/privacy-test-auth. The sandbox also carried the onboarding card's VERSA_AZURE_DEPLOYMENT_NAME: gpt-5.5-2026-04-24 on purpose.

Method, per model:

  1. Bind with the QA's own CLI rebind: biorouter session --resume --session-id 20260911_1 --provider versa_azure --model <m> </dev/null, which exited 0 every time.
  2. Wait for the composer chip to follow.
  3. Type "Reply with the single word ready." in the composer and send.
  4. Run select model_id, provider from token_events order by id desc limit 1.
bound model chip followed token_events (row id) row binding.deployment
gpt-5.5-2026-04-24 (default) gpt-5.5-2026-04-24|versa_azure (3629) gpt-5.5-2026-04-24
gpt-5.4-mini-2026-03-17 gpt-5.4-mini-2026-03-17|versa_azure (3630) gpt-5.4-mini-2026-03-17
gpt-5.4-nano-2026-03-17 gpt-5.4-nano-2026-03-17|versa_azure (3631) gpt-5.4-nano-2026-03-17
gpt-5.2-2025-12-11 gpt-5.2-2025-12-11|versa_azure (3632) gpt-5.2-2025-12-11
gpt-5-2025-08-07 gpt-5-2025-08-07|versa_azure (3633) gpt-5-2025-08-07
gpt-4.1-2025-04-14 gpt-4.1-2025-04-14|versa_azure (3634) gpt-4.1-2025-04-14
gpt-4.1-mini-2025-04-14 gpt-4.1-mini-2025-04-14|versa_azure (3635) gpt-4.1-mini-2025-04-14
gpt-4o-2024-11-20 gpt-4o-2024-11-20|versa_azure (3636) gpt-4o-2024-11-20
o4-mini-2025-04-16 o4-mini-2025-04-16|versa_azure (3637) o4-mini-2025-04-16
gpt-4.1-bogus-qa-probe no row; newest stays 3637 no-versa-deployment-serves-this-model.biorouter-refuses-to-send-the-turn
gpt-5.5, via the GUI's Switch models dialog gpt-5.5-2026-04-24|versa_azure (3638) gpt-5.5-2026-04-24

The bogus probe fails the turn readably. The chat shows, verbatim:

Ran into this error: Request failed: no Versa deployment for model gpt-4.1-bogus-qa-probe (Azure deployment not found, so nothing was sent); available: gpt-5.5-2026-04-24, gpt-5.4-mini-2026-03-17, gpt-5.4-nano-2026-03-17, gpt-5.2-2025-12-11, gpt-5-2025-08-07, gpt-4.1-2025-04-14, gpt-4.1-mini-2025-04-14, gpt-4o-2024-11-20, o4-mini-2025-04-16. Switch this chat to one of those models.

  • The daemon logged a single Provider call failed error_type="request", with no retries.
  • None of the 10 llm_request logs mentions the model, so nothing was sent.
  • The composer recovered: the textarea is enabled and Send is back.
  • The Switch models dialog lists exactly the nine models, with no "Enter a model not listed...".
  • The same chat then answered on gpt-5.5 (row 3638).
  • Console errors: 0.

Housekeeping.

  • The probe chat and sandbox are deleted, including the sandbox's copy of secrets.yaml.
  • The chat was restored to versa_azure / gpt-5.5-2026-04-24 before deletion.
  • The instance is stopped: Electron, daemon and vite are all gone, with no leftovers.
  • The real ~/.config/biorouter was never touched.

Not changed here (follow-ups)

  • InstitutionalSetupCard.tsx still upserts VERSA_AZURE_DEPLOYMENT_NAME: gpt-5.5-2026-04-24. That value is now inert. Its Advanced "deployment" field, if edited to a non-catalog name, pins every model, which is the documented override semantics. DefaultProviderSetupForm's PROVIDER_KEY_DEFAULTS.versa_azure still lists AZURE_OPENAI_* defaults that no declared key reads.
  • Legacy AZURE_OPENAI_DEPLOYMENT_NAME fallback (pre-existing). A user who configured the public azure_openai provider with their own deployment name has that name read as a Versa override, so every Versa request would 404. Unchanged by this PR: the value was already the deployment before.
  • The stop notice's wording. It appends "Please retry if you think this is a transient or recoverable error." to every non-recoverable provider error, this one included.
  • gpt-5-mini-2025-08-07 and gpt-5-nano-2025-08-07 are served but not offered.

🤖 Generated with Claude Code

… one is refused before it is sent

F1 of the 2026-09-10 QA run on 7c96d79. `versa_azure` posted every request
to one fixed deployment (`VERSA_AZURE_DEPLOYMENT = gpt-5.5-2026-04-24`) and
never consulted the model, so choosing any of the other eight advertised
models changed the label, the context gauge and the cost basis while gpt-5.5
kept answering — and a model that does not exist completed a turn normally.
`token_events.model_id` told the truth because it is the gateway's own answer.

Measured before writing the map (UCSF gateway, 2026-09-11): the gateway has
no listing endpoint (GET openai/deployments and openai/models answer 405), so
each advertised deployment was sent a one-shot completion. All nine answer
200 with their own name as `model`; the QA's bogus name and the short aliases
are DeploymentNotFound. A prompt just over each registry window was refused
with exactly that window (the gpt-5 family as an input limit, window less the
128k output reservation), so MODEL_CONTEXT_WINDOWS needs no change.

- `VERSA_AZURE_DEPLOYMENTS` is the measured model->deployment map and the one
  source of the advertised catalog. A request derives its deployment from the
  model IT names, which also fixes the fast model (`complete_fast`) landing on
  the main deployment.
- A model the map does not know is refused before the payload is built or
  logged: RequestFailed "no Versa deployment for model `X` (Azure deployment
  not found, so nothing was sent); available: ...", classified
  ModelUnavailable, so the turn stops on its first attempt. Construction never
  fails, because a failed restore is a chat that will not open.
- `VERSA_AZURE_DEPLOYMENT_NAME` (then the legacy AZURE_OPENAI_ key) still wins
  when set, but a value naming a CATALOG deployment is not an override: the
  onboarding card upserts the shipped default on every connect and the old
  setup form wrote the legacy key the same way, so honouring it would leave F1
  in place for every onboarded install. A deployment the catalog does not know
  remains the escape hatch and serves every request.
- The restore binding's `deployment` is derived on every `restore_binding()`
  and read back through the same rule, so a rebind refreshes it, rows the old
  code wrote (gpt-5.5 beside another model) self-heal, and a subagent's model
  rewrite follows the new model. The binding shape is unchanged — format v1
  has shipped, and older builds must still parse it — and a model no
  deployment serves stores a self-describing marker that an older build posts
  to and gets DeploymentNotFound for (measured), never another model's answer.
- `VERSA_AZURE_DEPLOYMENT` becomes `VERSA_AZURE_DEFAULT_MODEL` (it names a
  model now), `VERSA_AZURE_KNOWN_MODELS` folds into the map, and
  `with_unlisted_models()` is gone: an unlisted name can only be refused.

No config key is added or renamed; every read stays a literal `get_param`, so
the privacy config-key scan still counts 26. versa_bedrock needs no change:
both Converse paths send `.model_id(model_config.model_name)`.

The new routing tests drive real `from_env`/`from_resolved` providers against
a local stand-in gateway; run against the pre-fix implementation, six of seven
fail with F1's own symptom (gpt-5.5 answered), and the seventh (an explicit
override still wins) passes on both.
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