fix: return 400 for unresolvable model selectors - #254
Conversation
…not 500 (fixes mozilla-ai#252) resolve_provider_selector raises ValueError for selectors with no provider: prefix and AnyLLMError for unknown providers. Every dispatch site called it without catching either, so a typo like "nosuchmodel" or "nobody:model" escaped as a bare 500 Internal Server Error. Fix: add _raise_for_unresolvable_model in _pipeline.py that maps both exceptions to HTTP 400 with a detail string naming the bad selector. Wrap the bare call in resolve_dispatch_provider (chat, messages, responses routes) and each bare resolve_provider_selector call site (embeddings, images, audio, rerank, moderations, batches). Add 7 unit tests covering ValueError/AnyLLMError mapping, model name in detail, cached-provider fast-path, fresh-resolution success, and both error paths through resolve_dispatch_provider. Signed-off-by: Aloys Jehwin <aloys.jehwin@sap.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughChangesModel resolution errors
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
✨ Simplify code
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/gateway/api/routes/batches.py (1)
126-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove redundant
try...exceptblock.Hey there! Since the new inner block catches
ValueError(along withAnyLLMError) and delegates it to our helper which raises anHTTPException, the outerexcept ValueErrorblock below this is actually unreachable now. We can safely remove the extra nesting to keep the code nice and clean!♻️ Proposed refactor
- try: - try: - resolved = resolve_provider_selector(config, request.model) - except (ValueError, AnyLLMError) as exc: - _raise_for_unresolvable_model(request.model, exc) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid request: {e}", - ) from e + try: + resolved = resolve_provider_selector(config, request.model) + except (ValueError, AnyLLMError) as exc: + _raise_for_unresolvable_model(request.model, exc)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/gateway/api/routes/batches.py` around lines 126 - 129, Remove the redundant try/except around resolve_provider_selector in the batch route. Keep the existing ValueError and AnyLLMError handling delegated through _raise_for_unresolvable_model, and eliminate the now-unreachable outer ValueError handling while preserving the route’s current HTTPException behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/gateway/api/routes/audio.py`:
- Around line 105-108: Move the provider resolution block and all dependent
assignments through provider_kwargs before reserve_budget in both
src/gateway/api/routes/audio.py lines 105-108 and 270-273. Update the
corresponding audio route flows around resolve_provider_selector so unresolvable
models raise before budget reservation, while preserving the existing
reservation and refund behavior for resolvable models.
---
Nitpick comments:
In `@src/gateway/api/routes/batches.py`:
- Around line 126-129: Remove the redundant try/except around
resolve_provider_selector in the batch route. Keep the existing ValueError and
AnyLLMError handling delegated through _raise_for_unresolvable_model, and
eliminate the now-unreachable outer ValueError handling while preserving the
route’s current HTTPException behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 1d376532-5d22-456f-a7fc-889ce0e21717
📒 Files selected for processing (8)
src/gateway/api/routes/_pipeline.pysrc/gateway/api/routes/audio.pysrc/gateway/api/routes/batches.pysrc/gateway/api/routes/embeddings.pysrc/gateway/api/routes/images.pysrc/gateway/api/routes/moderations.pysrc/gateway/api/routes/rerank.pytests/unit/test_bad_model_name_returns_400.py
| try: | ||
| resolved = resolve_provider_selector(config, model) | ||
| except (ValueError, AnyLLMError) as exc: | ||
| _raise_for_unresolvable_model(model, exc) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
Fix budget reservation leak on unresolvable models.
Great work standardizing these model resolution errors! I just spotted a sneaky little budget leak in the audio routes.
Since resolve_provider_selector is called after reserve_budget, an unresolvable model will cause _raise_for_unresolvable_model to raise an HTTPException that escapes before reaching our refund block. This leaves the budget reserved indefinitely and violates our guideline to refund on every error path.
src/gateway/api/routes/audio.py#L105-L108: Let's move this provider resolution block (and the subsequent assignments up toprovider_kwargs) above thereserve_budgetcall to prevent the leak, just like you did in the other routes.src/gateway/api/routes/audio.py#L270-L273: Same here! Move this resolution block above thereserve_budgetcall.
📍 Affects 1 file
src/gateway/api/routes/audio.py#L105-L108(this comment)src/gateway/api/routes/audio.py#L270-L273
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/gateway/api/routes/audio.py` around lines 105 - 108, Move the provider
resolution block and all dependent assignments through provider_kwargs before
reserve_budget in both src/gateway/api/routes/audio.py lines 105-108 and
270-273. Update the corresponding audio route flows around
resolve_provider_selector so unresolvable models raise before budget
reservation, while preserving the existing reservation and refund behavior for
resolvable models.
Source: Coding guidelines
njbrake
left a comment
There was a problem hiding this comment.
Note: this review was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.
Thanks for this. The diagnosis is right and the fix is in the right place: I read resolve_provider_selector and confirmed it raises ValueError for an unparseable selector and AnyLLMError for an unknown provider, so catching both and mapping to 400 is correct. Centralizing that in _raise_for_unresolvable_model is a good call, and there is no circular import (all six route modules import cleanly).
The blocker is that the PR does not pass the Definition of Done checks the checklist marks as run. Both make lint and make typecheck fail:
make lint: 7 I001 import-sort errors. The new imports were prepended out of order in every modified source file. For example from any_llm.exceptions import AnyLLMError lands before from any_llm import ..., and the _pipeline import drops into the middle of the gateway.services block. ruff check --fix resolves all 7.
make typecheck: 11 mypy errors. The decisive one is in _pipeline.py: resolve_dispatch_provider reports Missing return statement. Because _raise_for_unresolvable_model is typed -> None, mypy believes the except branch can fall through without returning a ResolvedProvider. Typing the helper -> NoReturn (it always raises) fixes that and documents intent. The other 10 errors are the two untyped test helpers (_make_ctx, _make_config) and their call sites under mypy strict.
Both checks are enforced in CI (otari-lint.yml, otari-typecheck.yml), so this is red as submitted.
Two smaller items:
- batches.py has dead code. The inner catch delegates to the helper, which raises
HTTPException, notValueError, so the outerexcept ValueErroris now unreachable. It can be dropped, collapsing the doubletryinto one. CodeRabbit flagged the same thing. - audio.py reserves budget before resolving the model. CodeRabbit noted this; it is worth mentioning but not blocking. The reservation amount is
0.0and the ordering predates this PR, so no behavior regresses here. If you want resolution to gate reservation, that is a separate, small follow-up.
One suggestion, not blocking: the new tests exercise the helper and resolve_dispatch_provider in isolation but nothing asserts a route returns 400 end to end. An integration case (for example POST /v1/embeddings with model="nosuchmodel") would lock in the wiring across all six routes.
I have the lint fix, the NoReturn fix, the test annotations, and the batches.py dead-code removal staged locally; with those applied, ruff is clean, mypy is clean across 195 files, and the 7 new tests pass. Happy to push them to this branch if that is easier than reworking it yourself.
- _raise_for_unresolvable_model typed -> NoReturn so mypy sees it always raises - ruff I001 import-sort fixes across all 7 modified route files + test file - remove unreachable outer except ValueError in batches.py (dead code after inner handler delegates to helper) - type annotate _make_ctx / _make_config test helpers to satisfy mypy strict Signed-off-by: Aloys Jehwin <aloysjehwin@gmail.com>
|
Thanks for the detailed review @njbrake — addressed all three blockers in the latest commit:
|
The branch predated mozilla-ai#275, which consolidated the pass-through routes (audio, embeddings, images, moderations, rerank) onto the shared run_passthrough helper and moved provider resolution out of the individual route files. The branch's per-route edits therefore no longer applied. Resolve the merge by dropping those obsolete edits and moving the fix to where resolution now happens: - _pipeline.py: keep the _raise_for_unresolvable_model helper and the guarded resolve_dispatch_provider (covers chat, messages, responses). - _passthrough.py: guard both resolve_provider_selector calls in run_passthrough. On the reserve-before-resolve path (audio) refund the held reservation before raising the 400, closing the budget leak CodeRabbit flagged. - batches.py: unify on _raise_for_unresolvable_model so an unknown provider (AnyLLMError), not just an unparseable selector (ValueError), maps to 400. Tests: - test_passthrough_enforcement.py: assert every billable route (including the audio reserve-first routes) returns 400, not 500, for an unresolvable model and never reaches the provider. - test_alias_api.py: a deleted alias now surfaces as a 400 response rather than a bare ValueError; keep the no-dispatch guarantee. - test_batches_endpoint.py: assert the model name appears in the detail. make lint, make typecheck, generate_openapi --check, and the full unit + integration suites pass (only two pre-existing, network-dependent provider-error tests fail identically on origin/main in this sandbox). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/gateway/api/routes/_passthrough.py (1)
226-250: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winKeep raw provider exceptions out of persisted logs
error_log.error_message = str(e)andlogger.error(..., e)both carry provider text into storage and logs./v1/usageand/v1/users/{id}/usageexposeerror_message, so this can leak upstream details to API consumers as well. Store an opaque error code/class here and log only non-sensitive metadata.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/gateway/api/routes/_passthrough.py` around lines 226 - 250, Replace raw provider exception text in the passthrough exception handler with the existing opaque error classification. Compute the error class before constructing UsageLog, store only that class or code in error_message, and update logger.error to include non-sensitive metadata without e. Preserve mapped-error propagation and the existing HTTPException response.Source: Path instructions
tests/integration/test_passthrough_enforcement.py (1)
25-25: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winImport a declared HTTP client dependency here
pyproject.tomldoesn’t declarehttpx2; this test is only getting it transitively throughgenai-pricesinuv.lock. ImportResponsefromhttpxor addhttpx2as a direct test dependency so the test doesn’t hinge on a transitive package.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/integration/test_passthrough_enforcement.py` at line 25, Update the import in the passthrough enforcement tests to use the declared httpx dependency instead of the transitive httpx2 package, unless httpx2 is intentionally added as a direct test dependency in pyproject.toml. Keep the Response usage unchanged.
🧹 Nitpick comments (1)
tests/integration/test_batches_endpoint.py (1)
454-456: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueEm dash in a comment separator.
# Enforcement — user resolution, budget, rate limiting, ownership (issue#258)uses an em dash as a prose separator. Small thing, easy swap to a colon or hyphen.As per coding guidelines: "Avoid em dashes and double hyphens as prose separators; use punctuation or rephrase."
✏️ Proposed fix
-# Enforcement — user resolution, budget, rate limiting, ownership (issue `#258`) +# Enforcement: user resolution, budget, rate limiting, ownership (issue `#258`)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/integration/test_batches_endpoint.py` around lines 454 - 456, Update the enforcement section comment separator near the “Enforcement” label to replace the em dash with a colon or single hyphen, preserving the existing descriptive text and issue reference.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/gateway/api/routes/_passthrough.py`:
- Around line 226-250: Replace raw provider exception text in the passthrough
exception handler with the existing opaque error classification. Compute the
error class before constructing UsageLog, store only that class or code in
error_message, and update logger.error to include non-sensitive metadata without
e. Preserve mapped-error propagation and the existing HTTPException response.
In `@tests/integration/test_passthrough_enforcement.py`:
- Line 25: Update the import in the passthrough enforcement tests to use the
declared httpx dependency instead of the transitive httpx2 package, unless
httpx2 is intentionally added as a direct test dependency in pyproject.toml.
Keep the Response usage unchanged.
---
Nitpick comments:
In `@tests/integration/test_batches_endpoint.py`:
- Around line 454-456: Update the enforcement section comment separator near the
“Enforcement” label to replace the em dash with a colon or single hyphen,
preserving the existing descriptive text and issue reference.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 752c5446-b713-450b-a5c8-32220793cd7a
📒 Files selected for processing (6)
src/gateway/api/routes/_passthrough.pysrc/gateway/api/routes/_pipeline.pysrc/gateway/api/routes/batches.pytests/integration/test_alias_api.pytests/integration/test_batches_endpoint.pytests/integration/test_passthrough_enforcement.py
🚧 Files skipped from review as they are similar to previous changes (2)
- src/gateway/api/routes/_pipeline.py
- src/gateway/api/routes/batches.py
|
Note: this comment was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's. Thanks for this, @AloysJehwin. The diagnosis was spot on: Heads up on what changed: I've pushed an update to your branch to bring it up to date with The update keeps your
Locally: lint, mypy, the OpenAPI check, and the full unit + integration suites pass. The PR is now mergeable with a focused 7-file diff. I also tweaked the PR title to the |
Dismissing my earlier review: the requested changes are in. The branch was updated to main and the 400 fix re-targeted onto run_passthrough and _pipeline; CI (tests, lint, typecheck) is green. Dismissal actioned by Claude at @njbrake's direction.
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
Description
resolve_provider_selectorraisesValueErrorfor a selector with noprovider:prefix andAnyLLMErrorfor an unknown provider. Every dispatch site called it without catching either — a typo likenosuchmodelornobody:modelescaped as a bare500 Internal Server Error. Both are client input errors and should return400with a useful detail string.PR Type
Relevant issues
Closes #252
Changes
src/gateway/api/routes/_pipeline.py_raise_for_unresolvable_model(model_selector, exc)helper — single place that mapsValueError/AnyLLMError→HTTP 400with the bad model name in the detailresolve_provider_selectorcall inresolve_dispatch_providerwithtry/except→_raise_for_unresolvable_model(covers chat, messages, responses routes)src/gateway/api/routes/{embeddings,images,audio,rerank,moderations,batches}.py_raise_for_unresolvable_modelandAnyLLMErrorresolve_provider_selectorcall with the same guardtests/unit/test_bad_model_name_returns_400.py— 7 new unit testsChecklist
tests/unit,tests/integration).make lint,make typecheck,make test).uv run python scripts/generate_openapi.py).AI Usage
AI Model/Tool used: Claude (claude-sonnet)
Any additional AI details you'd like to share: Used to identify the bug, trace the call path, write the fix and unit tests.
Summary
Technical notes
_raise_for_unresolvable_modelhelper to convertValueErrorandAnyLLMErrorinto a standardizedHTTPException(400) that instructs callers to useprovider:model.