NAME
Langertha::Reasoning - Immutable normalized reasoning-effort control with cross-provider conversion
VERSION
version 0.505
SYNOPSIS
my $r = Langertha::Reasoning->new(
effort => 'high',
model => 'claude-opus-4-8',
);
my %kwargs = $r->to('anthropic');
# ( output_config => { effort => 'high' }, thinking => { type => 'adaptive' } )
# Gemini 2.5 takes an integer thinking_budget instead of a level
my $rb = Langertha::Reasoning->new(
thinking_budget => 2048,
model => 'gemini-2.5-pro',
);
my %kw = $rb->to('gemini');
# ( thinkingConfig => { thinkingBudget => 2048 } )
DESCRIPTION
Canonical value object for the request-side reasoning-effort knob, dispatched by an engine's reasoning_wire_format. Mirrors Langertha::Tool / Langertha::ToolChoice: the value-set clamping and per-provider placement of the field live in this one reviewable place rather than scattered across engines (ADR 0001).
The normalized vocabulary is the OpenAI superset none|minimal|low|medium|high|xhigh|max. Each to_* serializer clamps that vocabulary to what the target wire actually accepts and returns the body kwargs to merge into the request.
The OpenAI clamp is model-gated, not wire-gated: Chat Completions (to_openai) and the Responses API (to_responses) $ref the identical ReasoningEffort schema, so both share one per-model gate and can never diverge for the same model. The accepted set differs per model generation — gpt-6 (astra) accepts neither none nor minimal (low..max); the gpt-5.6/gpt-5.5 generation accepts none/xhigh/(max) but not minimal; the legacy gpt-5 generation accepts minimal but not none/xhigh/max — so no single wire-level clamp is correct. Unlisted model ids keep the full vocabulary. See "to_openai".
Gemini splits its reasoning knob by model generation: Gemini 3 and Gemini 4 accept a thinkingLevel emitted from effort (vocabulary minimal|low|medium|high, clamped to the subset the configured model family accepts — see "to_gemini_level"); Gemini 2.5 takes a thinkingBudget integer instead. Exactly one of the two fields is emitted per request — never both. BUILD rejects every effort/thinking_budget combination that would produce an ambiguous wire form (either field on the wrong generation, or both fields together on any generation). The rule is "exactly one native control per generation", enforced loudly before any request is built.
Self-hosted OpenAI-compatible servers (llama.cpp, SGLang) switch thinking through the chat template instead: the chat_template_kwargs tag ("to_chat_template_kwargs") sends chat_template_kwargs.enable_thinking plus the effort unclamped. "thinking_enabled" is an explicit on/off switch without an effort level; it and "effort" are one concern and must agree.
effort
The normalized reasoning effort, one of none|minimal|low|medium|high|xhigh|max. Optional (must not coexist with thinking_budget on any Gemini generation; see "BUILD"). On Gemini 3 and Gemini 4, effort is the only knob and emits thinkingConfig.thinkingLevel, model-gated clamped to the level subset the configured model family accepts ("to_gemini_level"). Setting effort on a Gemini 2.5 model is rejected — Gemini 2.5 takes the integer budget, not the level vocabulary.
model
Optional model name. Used by "to_anthropic" to detect always-on "Fable-class" models (where thinking:{type:disabled} 400s and the thinking field must be omitted) and by "to_gemini" to dispatch between Gemini 2.5 (thinkingBudget) and Gemini 3 (thinkingLevel) and to clamp the Gemini 3 level vocabulary to the model family's supported subset.
thinking_display
Optional Anthropic thinking-visibility control, serialized by "to_anthropic" as thinking.display. Values: summarized (return a readable summary of the reasoning), omitted (no summary; the thinking field comes back empty), or updates (beta; between-tool-call progress notes). On every current Claude model the API default is omitted, so a caller that wants to read $response->thinking must set thinking_display => 'summarized' explicitly. Consumed only on the anthropic wire; ignored on every other format. Only takes effect together with a thinking block, i.e. on the adaptive (non-Fable-class) path — see "to_anthropic".
thinking_budget
Optional integer thinking budget for Gemini 2.5 models. When set on a Gemini 2.5 model (model id starting with gemini-2.5), "to_gemini" emits thinkingConfig.thinkingBudget as the integer. Setting thinking_budget on a Gemini 3 or Gemini 4 model, or setting it together with effort on any model, is rejected at construction time ("BUILD") — the two fields speak different units (binary level vs integer tokens) and a combined or wrong-generation wire form would be ambiguous.
thinking_enabled
Optional explicit thinking on/off switch, without an effort level (karr k382). Tri-state: unset (has_thinking_enabled false, the default), 0 or 1. It is the same concern as "effort", so the two must agree: thinking_enabled => 0 together with any effort other than none, or thinking_enabled => 1 together with effort none, croaks at construction ("BUILD"). thinking_enabled => 0 without an effort is normalized to effort none at construction ("normalize_off_switch"), so every wire sees the same off switch. Off therefore cannot disable thinking where effort none cannot: Gemini 3/4 raise it to their floor level (minimal or low), models whose OpenAI or Responses ladder has no none (gpt-6, legacy gpt-5) drop it, GPT-OSS on Ollama raises it to low, and on a Gemini 2.5 (budget-control) model it is not normalized at all and is dropped with a carp. thinking_enabled => 1 without an effort has a wire form only on chat_template_kwargs ("to_chat_template_kwargs"), on the opted-in thinking-toggle path ("thinking_toggle", the model's on-type) and on Ollama's boolean think (not GPT-OSS); every other wire sends nothing for it ("drops_thinking_enabled_on").
thinking_toggle
Whether the target endpoint speaks the thinking on/off toggle (MiniMax's cloud API, Kimi's Messages and chat/completions faces; karr k209/k215/k219). Set by "reasoning_kwargs_for" in Langertha::Role::ReasoningEffort from the engine's opt-in. Only when it is true does a thinking-toggle profile ("thinking_on" in Langertha::Reasoning::Profile) serialize as the toggle; when false (the default) the same model id serializes exactly like the unlisted-id passthrough, because a self-hosted server or proxy serving a bare MiniMax-M3 / kimi-k2.6 id does not parse the toggle.
normalize_off_switch
my $args = Langertha::Reasoning->normalize_off_switch(\%args);
Returns the constructor arguments with thinking_enabled => 0 (and no effort) turned into effort => 'none', the off switch every wire knows. Not on a budget-control generation (Gemini 2.5, see "thinking_budget"), which takes no effort: there the switch stays as given and is dropped ("drops_thinking_enabled_on"). The constructor calls it; Langertha::Role::ReasoningEffort calls it to resolve the effort for readers other than the serializers.
assert_switch_agrees
Langertha::Reasoning->assert_switch_agrees( $thinking_enabled, $effort );
Croaks when the two disagree within one layer: thinking_enabled => 0 with an effort other than none, or thinking_enabled => 1 with none. Either one undefined passes. Called from "BUILD" and from the engine constructor ("thinking_enabled" in Langertha::Role::ReasoningEffort).
to_gemini_level
Maps the normalized effort onto Gemini 3's thinkingLevel vocabulary (minimal|low|medium|high): none/minimal become minimal, high/xhigh/max become high, low and medium pass through. The result is then clamped down to the subset the configured "model" family accepts: gemini-3.7-flash, gemini-3.8-flash and gemini-3.1-pro-* drop minimal to low (no minimal support), gemini-3-pro-* accepts only low|high and drops minimal and medium to low. Gemini 4 (gemini-4-argon and the rest of gemini-4*, not gemini-40) accepts low|medium|high and raises minimal to low, as 3.7/3.8-flash do. Models outside the Gemini 3 and 4 lines (or no model) keep the universally-accepted binary low|high collapse, splitting at high.
to_openai
to_responses
Serialize "effort" to the two OpenAI wires — Chat Completions (reasoning_effort => $effort) and the Responses API (reasoning => { effort => $effort }). Both surfaces $ref the identical ReasoningEffort schema, so both clamp through the same model-gated gate: the accepted value set is per model generation (gpt-6 astra: low|medium|high|xhigh|max, no none|minimal; gpt-5.6-* / gpt-5.5-*: none|low|medium|high|xhigh(|max), no minimal; gpt-5 legacy: minimal|low|medium|high, no none|xhigh|max), and an unrecognized model id keeps the full normalized vocabulary. An effort the configured "model" does not accept yields an empty list on both wires — they can never diverge. Empty list when no "effort" is set.
A thinking-toggle model (its profile has "thinking_on" in Langertha::Reasoning::Profile: MiniMax-M3 / M2.x, Kimi K2.x), serialized for an endpoint that opted in with "thinking_toggle", takes no effort field on to_openai: it gets thinking => { type => ... } instead — disabled for none where the model can turn thinking off, the model's on-type (adaptive or enabled) for any other level. Every level gives the same depth there. Without "thinking_toggle" the same model id is serialized like any unlisted id.
to_anthropic
Serializes to the Messages-API reasoning shape: output_config.effort (when "effort" maps onto Anthropic's low|medium|high|xhigh|max set) plus a thinking block. On adaptive (non-Fable-class) models the block is { type => 'adaptive' }, carrying display => ... when "thinking_display" is set. Fable-class models (Fable / Mythos) get no thinking key — thinking is always on and type:disabled 400s there — and therefore cannot carry a display either. Empty list when neither an Anthropic-supported effort nor a thinking_display is present.
A thinking-toggle model on an opted-in endpoint ("thinking_toggle") gets only the thinking toggle described under "to_openai" (no output_config.effort), with display added to an on toggle when "thinking_display" is set (whether MiniMax and Kimi accept display there is not verified).
to_ollama
Serializes to Ollama's options.think knob. For the GPT-OSS family (whose resolved Langertha::Reasoning::Profile carries ollama_levels) it emits a graded level string — low/medium/high/max — because GPT-OSS ignores the boolean and always reasons (none maps to the floor low; live-probed 2026-09-17 via ollama.com gpt-oss:20b, k175). For every other model it emits the options.think boolean: any effort level other than none turns thinking on, none turns it off, and so do "thinking_enabled" 1 and 0. A bare thinking_enabled => 1 sends nothing on GPT-OSS. Empty list when neither is set. (Ollama does not compose Langertha::Role::ReasoningEffort — the engine calls this serializer directly from its chat_request when a per-request reasoning_effort or thinking_enabled control arrives.)
to_chat_template_kwargs
Serializes to the chat-template switch of self-hosted OpenAI-compatible servers (llama.cpp, SGLang, and any OpenAI-compatible endpoint that opts in with reasoning_wire_format => 'chat_template_kwargs'):
effort none -> chat_template_kwargs => { enable_thinking => false }
effort low .. max -> chat_template_kwargs => { enable_thinking => true },
reasoning_effort => $effort
thinking_enabled 1 (alone) -> chat_template_kwargs => { enable_thinking => true }
nothing set -> ()
enable_thinking is always a real JSON boolean. The effort is sent as given: the OpenAI per-model effort ladder ("to_openai") is not consulted, since a self-hosted model id says nothing about which levels its template takes. Effort none sends no reasoning_effort, because some servers and templates reject it. A chat_template_kwargs the caller passes to the request is merged with this one, the caller's keys winning ("chat_request" in Langertha::Role::OpenAICompatible).
drops_thinking_enabled_on
my $dropped = $r->drops_thinking_enabled_on($reasoning_wire_format);
True when "thinking_enabled" is set without an "effort" and the given wire has no form for it, so the serializers send nothing; "reasoning_kwargs_for" in Langertha::Role::ReasoningEffort uses this to carp once per engine. A bare thinking_enabled => 1 has a form on chat_template_kwargs, on the opted-in thinking-toggle path and on ollama (except GPT-OSS, which takes only levels). A bare thinking_enabled => 0 is left only on a budget-control generation ("normalize_off_switch") and has a form only on chat_template_kwargs.
to
my %kwargs = $r->to($reasoning_wire_format);
Dispatch to the per-format serializer. Returns the body kwargs to merge into the request (an empty list when the value is unsupported on that wire).
SEE ALSO
Langertha::Reasoning::Profile - The per-model wire-truth this value object resolves and consumes
Langertha::Role::ReasoningEffort - The composed role exposing
reasoning_effortandthinking_budgetLangertha::ToolChoice - Sibling value object for tool-selection policy
SUPPORT
Issues
Please report bugs and feature requests at https://github.com/Getty/langertha/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.