Skip to content

fix(llm): make reasoning_effort model-aware (denylist + retry-on-400)#47

Closed
msyed-01 wants to merge 8 commits into
Lazarus-AI:mainfrom
msyed-01:fix/reasoning-effort-detection
Closed

fix(llm): make reasoning_effort model-aware (denylist + retry-on-400)#47
msyed-01 wants to merge 8 commits into
Lazarus-AI:mainfrom
msyed-01:fix/reasoning-effort-detection

Conversation

@msyed-01

Copy link
Copy Markdown
Contributor

Problem

AsyncLLMClient defaults reasoning_effort to "medium" and passes it through to every OpenAI-compat endpoint unconditionally. This breaks against any provider whose configured model isn't reasoning-capable. The most common case is Groq direct + Llama 3.x:

RuntimeError: Web stream error for model 'llama-3.3-70b-versatile (adapter: OpenAI)'.
Status: 400 Bad Request
Body: {"error":{"message":"`reasoning_effort` is not supported with this model",
       "type":"invalid_request_error","code":"unknown_parameter"}}

Reproducible against the documented clearwing config --set-provider flow followed by clearwing sourcehunt <target> --depth quick — the static-analyzer pre-pass still runs (so the user sees some output), but the LLM hunter loop and Ranker never execute. Files Hunted: 0. Same problem against Mistral, Mixtral, Qwen2/2.5 base, Gemma on Groq, Together, Fireworks, and other OpenAI-compat providers that serve mainstream non-reasoning models.

The constructor accepts reasoning_effort=None to opt out, but no caller in the codebase (across all 7 AsyncLLMClient(...) construction sites in providers/manager.py and llm/chat.py) passes anything other than the default. There's no CLI surface to override it either, so end users on documented providers have no workaround short of patching the source.

Fix — two layers, both inside clearwing/llm/native.py

Layer 1: model-name denylist with sentinel default

A module-level pattern list (_REASONING_EFFORT_UNSUPPORTED_PATTERNS) lists case-insensitive substrings of model names known to reject reasoning_effort: llama-3, llama-4, mistral, mixtral, qwen2, gemma. An override allowlist (_REASONING_EFFORT_OVERRIDE_ALLOW, empty today) gives a clean escape hatch for reasoning-tuned variants of denylisted families as they ship.

The constructor signature changes from reasoning_effort: str | None = "medium" to reasoning_effort: str | None | Literal["auto"] = "auto". The new "auto" sentinel dispatches to a static helper that resolves to None on a denylist match, "medium" otherwise (preserving current behavior for everything not denylisted).

Explicit values pass through untouched, including None, "medium", "high", etc. Any caller that was setting reasoning_effort explicitly continues to work identically. Layer 1 only affects code paths that relied on the default.

Layer 2: retry-on-400 fallback (defense in depth)

The denylist will never be complete. To handle the long tail — model renames, new providers tightening validation, patterns we missed — both achat and achat_stream now catch RuntimeError, classify it via _is_unsupported_reasoning_effort_error (requires both "reasoning_effort" AND either "400" or "unsupported" in the message to match), and on a match: rebuild ChatOptions with reasoning_effort=None, mutate self.reasoning_effort = None for the instance lifetime, retry exactly once. Unrelated RuntimeErrors propagate untouched.

The instance-state mutation means subsequent calls on the same client skip the param without another wasted round-trip — one 400 per (model, provider) pair per task, not per call. AsyncLLMClient instances are per-task (sourcehunt/runner.py:1783 _get_llm(task, override)), so the mutation is bounded and safe.

Validation

Check Result
ruff check / ruff format --check on touched files clean (native.py:190 and :615 are pre-existing format issues on main, left untouched — not in this PR's scope)
mypy --follow-imports=silent clearwing/llm/native.py 9 new call-arg warnings, all on the new ChatOptions(...) reconstruction. Identical pattern to the 18 existing warnings on the two pre-existing ChatOptions(...) constructions at lines 259 and 341 — genai_pyo3's stub doesn't expose its kwargs to mypy. No new classes of error. clearwing/llm/ is outside MYPY_SCOPE per the Makefile anyway.
Full pytest suite vs base commit +26 new passing tests, 0 new failures. Baseline: 2,466 passed, 30 failed. With this PR: 2,492 passed, 30 failed. Identical failure set (all pre-existing, unrelated).
End-to-end against Groq direct (llama-3.3-70b-versatile, clearwing sourcehunt --depth standard against DVMCP) Before this PR: Files Hunted: 0, Ranker LLM call dies on first 400. After this PR: Files Hunted: 20/20, 10 findings, 5 verified, 4 CRITICAL + 1 HIGH, $0 actual spend (Groq free tier). Correctly identified DVMCP's intentional eval() criticals. Runtime ~16min (Groq free-tier RPM throttling; Clearwing's existing rate-limit retry handled it cleanly).
End-to-end against OpenRouter (meta-llama/llama-3.3-70b-instruct) Also unblocked — OpenRouter's middleware was already silently stripping the param for us; this PR doesn't regress that path.

What this PR does NOT touch

  • clearwing/providers/manager.py, clearwing/llm/chat.py, clearwing/sourcehunt/runner.py, clearwing/ui/commands/sourcehunt.py — no plumbing changes
  • No config-schema changes, no CLI flag additions (we discussed adding --reasoning-effort to sourcehunt, but kept this PR scoped to the bug; happy to follow up in a separate PR if you want the explicit surface)
  • The three pre-existing test-fixture issues spotted while debugging this (FakeResponse.reasoning_content missing in test_deep_agent_loop.py / test_band_promotion.py; AgentTool.fn removed but still called in test_cve_tools.py; test_webcrypto_hooks::test_succeeds_with_init_script asserting False is True) — separate concerns, can file as a separate issue if useful

Files changed

  • clearwing/llm/native.py (+151 / -13)
  • tests/test_native_reasoning_effort.py (+319, new file, 26 tests across 6 test classes)

Commits

Seven commits, each a coherent TDD step (red → green → commit), so the history is reviewable commit-by-commit. Squash on merge is fine if you prefer single-commit PRs.

Backward compatibility

  • No public API surface removed
  • Default behavior unchanged for any model not in the denylist
  • Explicit reasoning_effort=X calls pass through untouched (including reasoning_effort="medium" against a denylisted model — Layer 1 only fires when the caller didn't specify)
  • No config schema changes, no new required dependencies

Happy to iterate on the denylist patterns, the sentinel naming, or the retry semantics. Thanks for considering!

Syed added 8 commits May 27, 2026 12:30
Adds _REASONING_EFFORT_UNSUPPORTED_PATTERNS and the
AsyncLLMClient._auto_resolve_reasoning_effort static method. No call
sites use it yet -- wired up in the next commit.
New sentinel default 'auto' dispatches to _auto_resolve_reasoning_effort.
Explicit values (including None and 'medium') still pass through
untouched -- fully backward-compatible for any caller that was setting
the param explicitly.
Classifies RuntimeError messages from genai-pyo3 so we only retry on
the specific 'reasoning_effort rejected' 400, not on rate-limits or
unrelated 400s.
Reconstructs a frozen ChatOptions with reasoning_effort=None, preserving
all other fields. Needed by the retry-on-400 path in achat/achat_stream.
Defense-in-depth for models the denylist misses. After one retry the
instance state is mutated to None, so subsequent calls skip the param
without another wasted round-trip.
Symmetric to the non-streaming path. Refactors the consume loop into
an inner coroutine so the retry can re-run it cleanly with rebuilt
options.
Whitespace-only changes. No behavior change. `ruff check` and
`ruff format --check` now pass clean for the files this branch touches
(the two pre-existing format issues in clearwing/llm/native.py at
lines 190 and 615 also exist on main, so this PR leaves them alone).
The original docstring pointed at a design doc that lives in our
internal tree, not in this repo. Replaced with a self-contained
description of what the file covers.
@ropoctl

ropoctl commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

master is the real main branch. Please rebase if you still want to merge it.

@ropoctl

ropoctl commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Confirmed this addresses a real gap that's still present on master: AsyncLLMClient defaults reasoning_effort="medium" and native.py sends it unconditionally on the OpenAI-compat path, with no denylist or retry-on-400 — so non-reasoning models on Groq/Together/Fireworks/etc. (Llama, Mistral, Qwen base, Gemma) still 400. The denylist + retry-on-400 approach here is the right shape.

One thing before review: this PR targets main, but master is the canonical branch for this repo (it's a strict superset — main is fully merged into it, and all the recent LLM/native.py work lands there first). native.py has moved a fair bit on master since this branch, so could you retarget this PR to master and rebase? Once it's against master I'll review and merge.

Thanks for the detailed problem writeup and the two-layer fix — keeping it open pending the rebase.

@ropoctl

ropoctl commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Closing this out. To be clear for the record: this is a valid fix for a real gapnative.py on main still sends reasoning_effort unconditionally on the OpenAI-compat path, with no denylist or retry-on-400, so non-reasoning models on Groq/Together/Fireworks/etc. will still 400.

We're not merging it right now because:

  • It only affects users who point clearwing at a non-reasoning OpenAI-compat model; the default Anthropic/Claude path is unaffected.
  • native.py has been substantially refactored since this branch's base (provider routing centralized, codex/aiohttp streaming fallback added), so the patch no longer applies cleanly and would need a non-trivial re-port.

Not a reflection on the work — the diagnosis and two-layer approach (denylist + retry-on-400) were solid. If/when we prioritize multi-provider support, this is the reference. Happy to reopen or take a fresh rebase against master if someone hits this in practice. Thanks @msyed-01.

@ropoctl

ropoctl commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Followed up: re-ported this onto the current native.py (the refactor since this branch made the original patch no longer apply) in #65, with you credited via Co-authored-by. Thanks again @msyed-01.

@msyed-01

Copy link
Copy Markdown
Contributor Author

Thanks @ropoctl, followed up over on #65. Sorry for the silence on the rebase ask. The two-layer approach landing in any form is the important part, i really appreciate the re-port. Closing the loop here.

ropoctl added a commit that referenced this pull request Jun 26, 2026
AsyncLLMClient defaulted reasoning_effort to "medium" and sent it on every
OpenAI-compat request unconditionally. Non-reasoning models served over
OpenAI-compat endpoints (Llama 3.x/4, Mistral, Mixtral, Qwen2/2.5, Gemma on
Groq/Together/Fireworks/etc.) reject the param with HTTP 400, which killed
the sourcehunt hunter loop and ranker for those providers.

Two layers, both in clearwing/llm/native.py:

1. Model-name denylist with a sentinel default. The constructor default
   becomes "auto": _auto_resolve_reasoning_effort() maps known non-reasoning
   model families to None and everything else (incl. Anthropic/Claude) to
   "medium" — so the default Anthropic path is unchanged. Any explicit value
   (including None) bypasses auto-resolution.

2. Retry-on-400 safety net. If a provider still rejects reasoning_effort at
   call time, achat/achat_stream detect that specific 400, drop the param,
   latch reasoning_effort=None for the session, and retry once. Unrelated
   400s/429s/500s propagate untouched.

Re-port of #47 onto the refactored native.py (provider routing + codex/
aiohttp streaming fallback landed since the original branch). Tests from the
original PR carried over verbatim.

Co-authored-by: msyed-01 <288113432+msyed-01@users.noreply.github.com>
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.

2 participants