Repository navigation
Fable (adaptive-thinking) + effort=max silently truncates to empty response — stop_reason dropped, max_tokens not resized #246
Description
Summary
When routing claude-fable-5 through the Anthropic adapter with reasoning effort max (output_config.effort: "max"), the model consistently exhausts the entire max_tokens budget on adaptive thinking and returns zero visible text, while the proxy reports the turn as a normal completed response (no truncation/incomplete signal). Downstream clients (e.g. Claude Code / Claude Desktop) see this as "completed" with an empty message rather than a truncated one.
Reproduced 6/6 times on claude-fable-5 with requestedEffort: "max"; 0/3 failures on the same model without an explicit max effort override (default/low effort completed normally in ~1-1.5s).
Environment
- opencodex:
2.7.31(npm,@bitkyc08/opencodex) - OS: Windows 11 Pro (10.0.26200)
- Node: v22.17.0
- Bun (bundled runtime): 1.3.14
- Claude Code: 2.1.217 (Claude Desktop bundled CLI)
- Provider/model:
anthropic/claude-fable-5 - Adapter:
src/adapters/anthropic.ts
Evidence (from local ~/.opencodex/usage.jsonl)
{"requestId":"ocx-mrvgow0n-64","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":128798,"usage":{"inputTokens":101654,"outputTokens":8192,"totalTokens":109846}}
{"requestId":"ocx-mrvgsf9c-65","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":128240,"usage":{"inputTokens":101798,"outputTokens":8192,"totalTokens":109990}}
{"requestId":"ocx-mrvgvc5f-6c","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":126761,"usage":{"inputTokens":101907,"outputTokens":8192,"totalTokens":110099}}
{"requestId":"ocx-mrvh0wjg-6g","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":127187,"usage":{"inputTokens":51417,"outputTokens":8192,"totalTokens":59609}}
{"requestId":"ocx-mrvh5u8v-6h","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":123028,"usage":{"inputTokens":51435,"outputTokens":8192,"totalTokens":59627}}
{"requestId":"ocx-mrvhy0md-2","model":"claude-fable-5","requestedEffort":"max","status":200,"durationMs":125836,"usage":{"inputTokens":54833,"outputTokens":8192,"totalTokens":63025}}Every single requestedEffort: "max" call for claude-fable-5 hit exactly 8192 output tokens and took ~2 minutes. Non-max calls on the same model in the same log completed in ~1-1.5s with a handful of output tokens (e.g. 9, 14).
For comparison, a same-conditions Sonnet call completes in ~2.1s with 9 output tokens — so this is not an inherent slowness of the account/network, it's specific to Fable + max effort.
Root cause (traced in src/adapters/anthropic.ts, v2.7.31)
Two compounding bugs:
1. stop_reason is never read from the upstream Anthropic response.
Neither the streaming SSE parser nor the non-streaming path extracts it:
// streaming path, ~line 796-804
case "message_delta": {
const usage = data.usage as Record<string, number> | undefined;
pendingUsage = mergeAnthropicUsage(pendingUsage, usage);
break; // <- data.delta.stop_reason is discarded
}
case "message_stop": {
yield* emitDone(); // <- no stop_reason passed through
break;
}
// non-streaming path, ~line 820-848 (parseResponse)
const json = await response.json() as Record<string, unknown>;
// ...reads json.content and json.usage, never json.stop_reason
events.push({ type: "done", usage: usageFromAnthropic(usage) });A repo-wide search for stop_reason inside src/adapters/anthropic.ts returns zero matches. Downstream, src/server/claude-messages.ts:745 synthesizes the outgoing message_delta:
emit("message_delta", { type: "message_delta", delta: { stop_reason: (message as Rec).stop_reason ?? "end_turn", stop_sequence: null }, usage: ... });Since message.stop_reason is never populated by the adapter, this always falls back to "end_turn" — even when Anthropic actually returned "max_tokens". The client has no way to distinguish a genuinely finished turn from a token-capped one.
2. Adaptive-thinking models don't get max_tokens resized for effort, and default to 8192.
// ~line 36-37
const DEFAULT_MAX_TOKENS = 8192;
...
// ~line 610
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
...
// ~line 626-646
if (usesAdaptiveThinking(modelId)) {
// Adaptive-thinking models replace the token budget with an effort knob and reject
// `thinking.type: "enabled"` outright — no budget/max_tokens re-sizing needed.
body.thinking = { type: "adaptive" };
body.output_config = { effort: adaptiveEffort(parsed.options.reasoning) };
} else {
// ...budget_tokens / max_tokens resizing only happens here
}claude-fable-5 matches ADAPTIVE_THINKING_FAMILY_MINIMUMS.fable = [0, 0] (all Fable versions), so it always takes the adaptive branch, where max_tokens is left at whatever was set earlier (defaulting to 8192 when the caller doesn't pass max_output_tokens). At effort: "max", adaptive thinking can consume the entire 8192-token budget internally, leaving 0 tokens for visible output — Anthropic correctly returns stop_reason: "max_tokens" with empty/near-empty content, but bug #1 means this is never surfaced.
Expected behavior
Either:
- The adapter should thread
stop_reason(streaming:message_delta.delta.stop_reason; non-streaming:json.stop_reason) through to the emitteddone/messageevent so"max_tokens"reaches the client as an incomplete/truncated response instead of"end_turn", and/or max_tokensshould scale witheffortfor adaptive-thinking models too (similar in spirit toreasoningBudget(), which already exists but is currently only used on the non-adaptive branch), somax/xhigheffort doesn't get boxed into the same 8192 ceiling as default effort.
Actual behavior
Client receives an HTTP 200, status: "completed" response with no visible text after ~2 minutes and 8192 output tokens fully consumed by internal thinking. Looks identical to a normal, successful, empty-output turn — no error, no truncation indicator.
Workaround
Avoid effort: "max" for claude-fable-5 (and likely high/xhigh, structurally same risk though not locally confirmed) until fixed. Default/low effort was reproduced as working correctly in the same logs.
Activity
Ingwannu commentedon Jul 22, 2026
Confirmed as a real adapter bug and fixed on
devin 2aa4430c.Two independent failures were addressed:
max_tokensbudget by effort instead of leaving the default 8192 cap. High receives 24,576; xhigh/max receive the current safe 32,000 ceiling, preserving room for visible output.stop_reasonis now captured from both streamingmessage_deltaframes and non-streaming JSON. Amax_tokensstop is carried through the adapter and Responses bridge asresponse.incompletewithmax_output_tokens, rather than a false normal completion.Added request-sizing, streaming, JSON, and bridge lifecycle regressions. Focused isolated tests and the full 3,394-test suite passed. Closing as completed.
Ingwannu commentedon Jul 22, 2026
Follow-up correction merged via #256 in
df060b62: the earlier 32,000 adaptive ceiling still allowedeffort=maxto consume the full reasoning allowance with no guaranteed visible-output room. The final policy now uses 40,192 when the caller omits a limit (32,000 reasoning budget + 8,192 output headroom) and preserves any explicitmaxOutputTokensexactly instead of silently raising or capping it. The rebased PR passed the full Linux/macOS/Windows matrix and focused 55-test adapter/bridge verification.fix(bridge): treat length/content_filter as truncation, not silent co…