Skip to content

Fable (adaptive-thinking) + effort=max silently truncates to empty response — stop_reason dropped, max_tokens not resized #246

Description

@cherub8128

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 emitted done/message event so "max_tokens" reaches the client as an incomplete/truncated response instead of "end_turn", and/or
  • max_tokens should scale with effort for adaptive-thinking models too (similar in spirit to reasoningBudget(), which already exists but is currently only used on the non-adaptive branch), so max/xhigh effort 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions