NAME

Langertha::Reasoning - Immutable normalized reasoning-effort control with cross-provider conversion

VERSION

version 0.503

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 accepts 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.

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, 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 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_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.

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. Models outside the Gemini 3 line (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. Empty list when no effort 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 control arrives.)

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

SUPPORT

Issues

Please report bugs and feature requests on GitHub 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 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.