feat(llm): add reasoningEffort so a caller can turn hidden reasoning off - #308
feat(llm): add reasoningEffort so a caller can turn hidden reasoning off#308InfiniLakeSoftware wants to merge 2 commits into
Conversation
A reasoning-capable model can spend the entire max_tokens budget on hidden reasoning and return HTTP 200 with empty content and finish_reason "length". graft caches whatever comes back, so this shows up as blank summaries from a run that reported success throughout. Measured against a local LM Studio server with qwen3.5-9b: 2048 max_tokens, 2048 reasoning tokens, zero content. createChatCompletion already recovers from providers that REJECT tools while reasoning is active, but that path is driven by a 400 - it cannot reach this case, because nothing throws. The request has to say up front how much reasoning to spend. Adds reasoningEffort to the OpenAI-compatible transport, threaded through ChatModelConfig and EngineConfig, resolved from GRAFT_REASONING_EFFORT, and exposed as --reasoning-effort. Unset by default, so the model's own default stays in force and no existing behaviour changes. LiteLLM and OrcaRouter inherit it via OpenAIChatModel. Two adapter tests cover it: omitted unless set, forwarded when set, and present on the first request alongside a forced object tool_choice - the combination the existing 400-driven fallback cannot produce. Files changed: - src/ai/llm/types.ts - src/ai/llm/openai.ts - src/ai/llm/factory.ts - src/ai/providers.ts - src/cli.ts - src/engine.ts - test/llm-adapters.test.ts - README.md
🌱 graft blast radius4 areas changed → 3 areas can be affected. 8 dependent symbols, depth 2. flowchart TB
A0(("Pull Request Review<br/>4 symbols"))
A1(("LLM Routing<br/>2 symbols"))
A2(("Cluster Naming<br/>2 symbols"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2 reached;
Who knows this code — 4 people across 7 areas
Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no All 8 dependent symbols, grouped by areaPull Request Review — 4 symbols in 4 files
LLM Routing — 2 symbols in 2 files
Cluster Naming — 2 symbols in 1 file
Test signal per changed area — 2 ✓ · 2 ✗Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
2 test suites also reference this code2 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
|
Ran into exactly the case this PR targets (a reasoning model burning the whole I reproduced graft's concept-step request (forced
So on LiteLLM+vLLM the top-level Would you consider either (a) sending |
…body PR trailhq#308 added `reasoning_effort` as a top-level field, which is what LM Studio honours. On a LiteLLM proxy fronting vLLM it is silently dropped during the gateway's own param mapping: the request then runs long enough to hit the gateway timeout, and graft fails with `Connection error.` The same value under `extra_body` is forwarded to the server untouched, and a vLLM server reached directly wants `chat_template_kwargs: { enable_thinking: false }` instead — reported on trailhq#308 with measurements (177 reasoning tokens / 38.8s top-level, 0 / 7.9s under `extra_body`, 0 / 12.2s via `chat_template_kwargs`). So the switch is real but its spelling is not portable, and a typed field per gateway quirk is a losing game. Add `extraBody`: a JSON object merged into the request body, threaded through the same chain as every other provider dimension — `ChatModelConfig`, `EngineConfig`, `GRAFT_LLM_EXTRA_BODY`, and `--extra-body`. It covers `reasoning_effort`, `chat_template_kwargs`, and whatever the next gateway names differently, in one switch. Merged last, so a caller who names a key graft also sets gets their value on the wire: the stack in front of the model, not this adapter, decides which spelling works. Five keys are reserved — model, messages, tools, tool_choice, stream — because those carry the structured-output coercion the caller asked for, the manifest label, and the response shape. They are dropped with one warning at construction rather than per request. Malformed input throws instead of being ignored. A passthrough exists precisely because the request fails without it, so silently dropping a bad one would send the very request the user was avoiding, and they would debug their gateway rather than their JSON. Also threads both `reasoningEffort` and `extraBody` into the naming pass in `nameReport`, which built its own chat model and so was the one LLM call in the product that ignored the user's reasoning settings entirely — the same omission this change would otherwise repeat. Testing: the adapter merge, override and reserved-key cases are unit tests, but the path that actually broke is the whole chain, so `cli-extra-body.test.ts` runs a real `graft build --deep` against a recording stand-in gateway and asserts the JSON on the socket — both the flag and the env var, including that a reserved key is refused out loud. `provider-extra-body.test.ts` covers config resolution and that the value reaches all three OpenAI-compatible adapters. Full suite green (1,232 passing, 5 skipped). Files changed: - src/ai/llm/types.ts - src/ai/llm/openai.ts - src/ai/llm/factory.ts - src/ai/providers.ts - src/blast/name.ts - src/cli.ts - src/engine.ts - test/llm-adapters.test.ts - test/provider-extra-body.test.ts - test/cli-extra-body.test.ts - README.md - .env.example Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — the measurements are what makes this actionable, especially the three-way split. I've gone with (b), your general passthrough, and pushed it to this branch.
I didn't take (a) — moving Details worth knowing before you test it:
On testing: I can only prove graft's side. |
Problem
A reasoning-capable model can spend its entire
max_tokensbudget on hidden reasoning and return200with empty content.ChatSummarizercapsmaxTokensat 2048 andcontext/build.tscaches whatever comes back, so the result is a blank summary written by a run that reported success the whole way through.Measured directly against a local LM Studio server with
qwen/qwen3.5-9b, using graft's own summarizer prompt:Every token went to reasoning; the answer never started. On a real repo this left 1,638 of 3,502 cached summaries empty.
This is not the case #27 fixed, and not the one
isRejectedToolsWithReasoningcovers. Every fallback increateChatCompletionlives in thecatchand is keyed on a 400 — they handle providers that reject the request. Here nothing is rejected and nothing throws, so no catch-based recovery can reach it. The only fix is for the request to say up front how much reasoning to spend.Fix
Add
reasoningEffortto the OpenAI-compatible transport and thread it through the existing config chain:ReasoningEffortinsrc/ai/llm/types.ts(none|minimal|low|medium|high), so the public config surface doesn't leak the OpenAI SDK's type.OpenAIChatModelOptions.reasoningEffort→ sent asreasoning_efforton the request.LiteLLMChatModelandOrcaRouterChatModelextendOpenAIChatModel, so they inherit it;ChatModelConfigpasses it to all three. Theanthropicadapter ignores it.EngineConfig.reasoningEffort, resolved inresolveConfigfromGRAFT_REASONING_EFFORT, matching howGRAFT_PROVIDER/GRAFT_MODEL/GRAFT_BASE_URLalready work.--reasoning-effort <level>alongside the existing--provider/--model/--api-key/--base-urlflags.Unset by default, so the model's own default stays in force and nothing changes for existing users or providers.
Why an explicit knob rather than detection
Detecting "empty content plus a large
reasoning_tokens" and retrying would be guessing at intent — a legitimately empty completion is possible, and retrying doubles cost on a path that is already the expensive one. Whether reasoning is wanted is the caller's decision, and every other provider dimension in graft is already expressed the same way: config field, env var, CLI flag.Worth noting for anyone hitting this against LM Studio specifically: it ignores both
chat_template_kwargs: {enable_thinking: false}and Qwen's/no_thinkprompt switch. I verified both are no-ops there — identical reasoning-token counts and byte-identical output with each set.reasoning_effortis the only switch it honours.Testing
Two adapter tests in
test/llm-adapters.test.ts, network-free, using the existing stub-client pattern:none,high)tool_choice— the exact combination the existing 400-driven fallback cannot produce, since it only ever appears on a retryFull suite green (1,217 passing, 5 skipped).
README.mdupdated alongside the otherGRAFT_*variables.Measured effect
On the same 4-file sample at temperature 0,
nonevs the model's default: summaries of equivalent quality, but 8.7× faster (15s vs 132s) with 7,276 reasoning tokens no longer generated and discarded. Turning reasoning off also stopped the blank-summary failure entirely — peak output was 262 tokens, comfortably inside the existing 2048 budget.