fix(judge): declare reasoning off on tool-carrying calls to reasoning models - #878
fix(judge): declare reasoning off on tool-carrying calls to reasoning models#878drewdrewthis wants to merge 4 commits into
Conversation
… models (#864) Reasoning-capable models reject requests that carry function tools unless reasoning_effort is "none". The judge always sends tools, so criteria-graded runs against such models failed with a provider 400 instead of returning a verdict. The judge now passes reasoning_effort="none" on its tool-carrying litellm.completion calls (standard, discovery loop, forced verdict) when litellm.supports_reasoning says the model accepts it — and leaves the call untouched for models that don't, and never overrides a caller-set value. Un-skips examples/test_audio_to_text.py in CI: it exercises exactly this path and would have caught the regression; skipped, the class recurs silently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WalkthroughThe judge retries matching tool-call provider rejections with ChangesJudge reasoning retry
Live audio CI coverage
Sequence Diagram(s)sequenceDiagram
participant JudgeAgent
participant LiteLLM
participant Provider
JudgeAgent->>LiteLLM: Send tool-call completion
LiteLLM->>Provider: Forward request
Provider-->>LiteLLM: Reject missing reasoning_effort none
LiteLLM-->>JudgeAgent: Return provider rejection
JudgeAgent->>LiteLLM: Retry with reasoning_effort none
LiteLLM-->>JudgeAgent: Return completion
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
Gemini 2.5 Pro rejects reasoning off ("Budget 0 is invalid. This model
only works in thinking mode."), so declaring it up front broke a judge
that worked. Whether a model accepts reasoning off is not knowable in
advance: send the call untouched, and retry with reasoning_effort="none"
only when the provider's rejection asks for exactly that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pyright's reportPrivateImportUsage rejects litellm.BadRequestError as not re-exported; litellm.exceptions is the public home. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
python/tests/test_judge_transport_reasoning.py (1)
182-187: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDeclare
_TOOLSas typed class state.Ruff reports RUF012 because
_TOOLSis a mutable class attribute. Add aClassVarannotation, or create a fresh tool list per test. This also satisfies the required class-attribute type annotation.Proposed fix
-from typing import Any, Optional +from typing import Any, ClassVar, Optional @@ - _TOOLS = [ + _TOOLS: ClassVar[list[dict[str, object]]] = [As per coding guidelines, “Always use explicit type annotations for function parameters, return types, and class attributes in Python.”
🤖 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 `@python/tests/test_judge_transport_reasoning.py` around lines 182 - 187, Annotate the mutable class attribute `_TOOLS` with `ClassVar` and its appropriate list type, adding the required typing import if needed. Keep the existing tool definition and class-level reuse unchanged.Sources: Coding guidelines, Linters/SAST tools
python/scenario/judge_agent.py (1)
59-59: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDeclare module constants as
Final.
python/scenario/judge_agent.py#L59-L59: declare_REASONING_OFFasFinal[str].python/tests/test_judge_transport_reasoning.py#L41-L45: declare_REJECTION_MESSAGEasFinal[str].As per coding guidelines, “Use
Finalfor constants that should not be reassigned in Python.”🤖 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 `@python/scenario/judge_agent.py` at line 59, Declare the module constant _REASONING_OFF in python/scenario/judge_agent.py (lines 59-59) as Final[str], and declare _REJECTION_MESSAGE in python/tests/test_judge_transport_reasoning.py (lines 41-45) as Final[str], adding or reusing the appropriate Final import in each file.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.
Inline comments:
In `@python/scenario/judge_agent.py`:
- Around line 67-68: Update the rejection-detection helper around the visible
message check to require both the function-tool rejection text and the directive
to set reasoning_effort to 'none', rather than matching only the field and
value. Preserve unrelated reasoning_effort validation errors so they are not
retried, and add a regression case covering both tokens without the
function-tool rejection.
In `@python/tests/test_judge_transport_reasoning.py`:
- Around line 97-112: Update the test helper containing the _call_judge flow to
capture the existing ScenarioConfig.default_config before execution, then
restore that saved value in the finally block instead of always assigning None.
Preserve the current context_scenario reset and mock behavior.
---
Nitpick comments:
In `@python/scenario/judge_agent.py`:
- Line 59: Declare the module constant _REASONING_OFF in
python/scenario/judge_agent.py (lines 59-59) as Final[str], and declare
_REJECTION_MESSAGE in python/tests/test_judge_transport_reasoning.py (lines
41-45) as Final[str], adding or reusing the appropriate Final import in each
file.
In `@python/tests/test_judge_transport_reasoning.py`:
- Around line 182-187: Annotate the mutable class attribute `_TOOLS` with
`ClassVar` and its appropriate list type, adding the required typing import if
needed. Keep the existing tool definition and class-level reuse unchanged.
🪄 Autofix
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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 33645a9b-d549-492b-88bb-7f81e8b438f5
📒 Files selected for processing (3)
python/examples/test_audio_to_text.pypython/scenario/judge_agent.pypython/tests/test_judge_transport_reasoning.py
…giene - _rejection_asks_for_reasoning_off now requires the provider's "set reasoning_effort to 'none'" directive, so an error that merely mentions the tokens (e.g. an invalid-value rejection) surfaces instead of being replaced by a retry; regression test proven red on the loose matcher. - _call_judge restores the prior ScenarioConfig.default_config instead of clearing it. - drop the unused top-level litellm import. Addresses CodeRabbit and code-quality review threads on #878. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review verdict: READYReviewed at: No blocking concerns. All review threads resolved at this SHA. Verification performed this run
No new acceptance criteria or blocking findings from this pass. Verdict is prose, not a GitHub approval. The pr-review procedure never flips approve state. |
drewdrewthis
left a comment
There was a problem hiding this comment.
Full review complete — verdict: READY. No blocking findings, no inline threads needed.
Verified independently (not taken on the PR description's word):
- CI:
gh pr checks 878enumerated all 17 checks row-by-row — pass/skipping, zero failures. Pulled the rawtest (3.12)job log directly and confirmedexamples/test_audio_to_text.py::test_audio_to_textactually executed (un-skipped) and PASSED against real OpenAI credentials,examples/test_weather_agent__gemini.py(the PR's cited live regression gate) ran and PASSED, and all 7 new tests intests/test_judge_transport_reasoning.pyPASSED. - CodeRabbit: queried
reviewThreadsvia GraphQL directly rather than trusting the resolved-status claim — 3 threads (2 functional-correctness + 1 unused-import), allisResolved: truewith explicit bot confirmation replies.
Own two-pass review of judge_agent.py, the un-skip in test_audio_to_text.py, and the new test file:
- Retry gate
if not kwargs.get("tools") or "reasoning_effort" in kwargs: raisecorrectly no-ops for tool-less calls and never overwrites a caller-setreasoning_effort— confirmed bytest_preserves_an_explicitly_requested_effort. _rejection_asks_for_reasoning_offmatches on the full remediation directive ("set reasoning_effort to 'none'"), not just field-name substring — this is the fix for the already-resolved CodeRabbit finding on the same line, confirmed still present at HEAD.- All 3
litellm.completioncall sites (call,_run_discovery_loop,_force_verdict) are wired through_completion_with_reasoning_off_retry, each independently pinned byTestEveryCallSiteThatSendsToolsso an unwired site can't pass by way of the others. - No unused imports left behind by the skip removal (
osandpytestboth still used elsewhere in the file). No multitenancy/PII/secret concerns — pure SDK transport logic, no tenant-scoped data. No reinvention of an existing litellm/provider retry primitive (Pass 3 check).
See the upserted verdict comment for the full writeup: #878 (comment)
|
Automated low-risk assessment This PR was evaluated against the repository's Low-Risk Pull Requests procedure and does not qualify as low risk.
This PR requires a manual review before merging. |
Why
Closes #864. Platform-side epic: langwatch/langwatch#6594 (sibling PR langwatch/langwatch#6607 applies the same design to the platform's judge model client).
Some reasoning models reject requests that carry function tools unless
reasoning_effortis"none":The judge always sends tools, so a criteria-graded run against such a model failed with a provider 400 instead of returning a verdict. Verified live: the same request answers 400 without
reasoning_effortand 200 + afinish_testtool call withreasoning_effort="none".What changed
Reasoning is disabled by retry, never preemptively. The first design on this branch gated a preemptive
reasoning_effort="none"onlitellm.supports_reasoning, and this repo's own CI falsified it: the Gemini judge example failed with"Budget 0 is invalid. This model only works in thinking mode."— Gemini 2.5 Pro accepts tool-carrying requests but refuses to disable reasoning. "Supports reasoning_effort" and "accepts reasoning off" are different questions, and only the provider answers the second one.judge_agent.py:_completion_with_reasoning_off_retrysends everylitellm.completioncall untouched; when a tool-carrying call with no caller-setreasoning_effortraises a rejection carrying the remediation directiveset reasoning_effort to 'none', it retries once with reasoning off. Wired at all three call sites (standard call, discovery loop, forced verdict). Unrelated rejections surface unchanged; a caller-set effort is never rewritten; models that work today are never sent anything new.examples/test_audio_to_text.py: removed the CI skip. This example exercises exactly this path and would have caught the regression; skipped, the class recurs silently (per the epic's sequencing note).Test plan
tests/test_judge_transport_reasoning.py— 7 offline deterministic tests: rejected call retried once with reasoning off and reaches a verdict; a caller-set effort surfaces the provider's own error; an accepting (Gemini-shaped) judge gets exactly one untouched request; an unrelated 400 is not retried; a rejection that merely mentionsreasoning_effortand'none'without the directive (e.g. "reasoning_effort 'none' is invalid for this model") surfaces instead of retrying; the discovery-loop and forced-verdict call sites each retry. Falsifiability: withjudge_agent.pyreverted tomain, 3 of them fail; the directive-vs-mention test was proven red against the earlier two-token matcher.examples/test_weather_agent__gemini.py) is the live regression gate for the retry design itself — it is what failed against the first design.Human verification
Nothing UI to look at — this is a Python SDK transport change. If you want to see it live: run
examples/test_weather_agent__gemini.py(thinking-only model, must never be asked to disable reasoning) andexamples/test_audio_to_text.py(the un-skipped example that exercises the rejection-retry path); both run in this repo's CI against real providers and are green at HEAD.How I can prove I was successful
main, and the directive-vs-mention regression test fails against the previous looser matcher — both reverts were actually run, not reasoned about.grep -r supports_reasoning python/scenario/is empty.Anything surprising?
The rejection is matched on the remediation directive substring
set reasoning_effort to 'none'rather than a structured field: litellm re-wraps provider errors and the OpenAIparamfield does not survive uniformly. Matching the directive (not just the two tokens) keeps errors like "reasoning_effort 'none' is invalid for this model" — which mention both tokens but are not asking us to turn reasoning off — surfacing instead of looping. The platform sibling keys onerror.paramfirst, because it reads the raw wire body.