NAME

Langertha::Role::Capabilities - Engine-capability registry derived from composed roles

VERSION

version 0.503

SYNOPSIS

if ( $engine->supports('tool_choice_named') ) { ... }

my $caps = $engine->engine_capabilities;
for my $cap ( sort keys %$caps ) {
    say "$cap" if $caps->{$cap};
}

# Engine-level override for a wire reality the role inventory
# cannot express (e.g. provider only accepts string tool_choice):
around engine_capabilities => sub {
  my ( $orig, $self, @rest ) = @_;
  my $caps = $self->$orig(@rest);
  delete $caps->{tool_choice_named};
  return $caps;
};

DESCRIPTION

Composed by Langertha::Role::Chat (and therefore present on every engine), this role provides the engine_capabilities method plus the supports helper. The default implementation derives the flag set from which capability-bearing roles the engine composes — no per-role plumbing required, the registry below is the single source of truth.

Engines override (via around) when the wire reality differs from the role inventory — for example to clear tool_choice_named on a provider that only accepts string forms of tool_choice.

The mapping from role to flag is intentionally kept inside this one module so adding a new capability is a single-file change. The role itself does not need to know about engine_capabilities.

probe_model_capabilities_f

my $learned = await $engine->probe_model_capabilities_f;
my $learned = await $engine->probe_model_capabilities_f( models => [ 'llava', 'llama3.3' ] );
# { 'llava' => { image_input => 1 }, 'llama3.3' => { image_input => 0 } }

$engine->supports('image_input');   # now answers from the learned fact

Asks the provider's own model metadata endpoint which capabilities a model has and stores the answer on this engine instance (ADR 0032). It is the only way facts enter the learned layer: supports and "engine_capabilities" never send a request.

Without models, the probe asks about chat_model (nothing when no model is configured). Endpoints that describe every model in one document (OpenRouter, Mistral, LM Studio, TSystems) are fetched once and every model they describe is learned; Ollama's /api/show is asked once per model; llama.cpp's /props describes the one loaded model, so its fact is stored for every id that was asked about.

models => 'all' asks for the whole catalogue and does not fall back to chat_model: every model the document names is learned from one request. It croaks on a format whose document does not name its models (Ollama, llama.cpp; see "is_catalogue" in Langertha::ModelProbe). Use it to probe an endpoint once and hand the result to the other engine instances on the same endpoint with "import_learned_capabilities".

Resolves to { $model_id => { $capability => 0|1 } }, the facts learned by this call, exactly as they were merged into the engine's store (a fresh HashRef the caller owns, ready for "import_learned_capabilities"). Only the capabilities in "probed_capabilities" in Langertha::ModelProbe are learned (image_input). A model the document does not describe, or describes without the field, gets no fact and keeps its static answer.

Engines that implement a probe: Langertha::Engine::OpenRouter, Langertha::Engine::Mistral, Langertha::Engine::Ollama, Langertha::Engine::OllamaOpenAI, Langertha::Engine::LMStudio, Langertha::Engine::LMStudioOpenAI, Langertha::Engine::LlamaCpp and Langertha::Engine::TSystems (documentation-derived, not live-verified). On every other engine the method exists and resolves to an empty HashRef without a request.

Only non-empty plain strings count as model ids; anything else in models is ignored. When chat_model is looked up in the store, the exact id wins, then the format's equivalent spelling ("lookup_ids" in Langertha::ModelProbe): on Ollama a missing tag means :latest (llava finds llava:latest and back), on OpenRouter a routing variant such as :online or :free falls back to its base id when the variant itself is not listed.

On Ollama a model the server does not have (/api/show answers 404) gives no fact, and the other models of the call are still learned. Any other non-success answer fails the future with <engine> model metadata probe failed: <status> - <body>, a success answer that is not JSON with <engine> model metadata probe: response from <path> is not JSON; in both cases nothing from the call is stored.

probe_model_capabilities

my $learned = $engine->probe_model_capabilities;

Synchronous wrapper around "probe_model_capabilities_f" (blocks with ->get).

learned_model_capabilities

my $learned = $engine->learned_model_capabilities;
# { 'openai/gpt-4o' => { image_input => 1 }, ... }

A copy of every fact probed or imported so far on this instance, per model id. It has the shape "import_learned_capabilities" takes.

import_learned_capabilities

# One metadata fetch for a whole endpoint, shared by every instance on it:
my $learned = await $probe_engine->probe_model_capabilities_f( models => 'all' );
$_->import_learned_capabilities($learned) for @other_engines;

Merges { $model_id => { $capability => 0|1 } } into this instance's learned store, as if this instance had probed it: the facts take the same place in "engine_capabilities" (after the static per-model table, under the layer-1 and layer-2 wire gates), and a later fact for the same model and capability replaces the earlier one. The map is the result of "probe_model_capabilities_f" or "learned_model_capabilities" of another instance; nothing is fetched (ADR 0032).

Only the capabilities in "probed_capabilities" in Langertha::ModelProbe are taken; other names are ignored. An empty model id, a model whose facts are not a HashRef, and a fact value that is undef or an unblessed reference are skipped; any other value is stored as 1 or 0 by truth (a JSON boolean works). Croaks unless the argument is a HashRef. Returns the facts it merged, in the same shape.

Facts are keyed by model id, not by endpoint: import only into instances that talk to the endpoint the facts were read from.

clear_learned_model_capabilities

Forgets every probed or imported fact; "engine_capabilities" answers from the static layers again.

model_metadata_format

The Langertha::ModelProbe format tag of this engine's model metadata, or undef (the default) when the engine has no probe.

model_metadata_url

The URL "probe_model_capabilities_f" requests, or undef (the default).

engine_capabilities

my $caps = $engine->engine_capabilities;

Returns a HashRef of capability flags. The default derives the flag set in three layers: (1) it scans the composed role inventory and sets flags from the static role-to-flags map (ADR 0002); (2) an engine may correct the whole-endpoint wire reality via around (remove flags the wire cannot deliver at all, or add an ad-hoc flag — the outer gate); (3) it applies the engine's model_capability_corrections for the currently selected chat_model, refining the base where the wire reality is per-model rather than per-engine (ADR 0002 amendment, ADR 0019).

After layer 3 comes the learned layer (ADR 0032): facts that "probe_model_capabilities_f" read from the provider's own model metadata for the current chat_model. For a model the probe reported, the provider's statement wins over the static table in both directions; a learned 1 can only re-assert a flag the composed roles grant (layer 1). The learned layer runs inside the base method, so the engine's around (layer 2) still has the last word: a wire that cannot carry a field stays closed. The learned layer is empty until the caller probes, so this method never sends a request.

A capability flag means the wire accepts the field, not that any given model will honor it. For example reasoning_effort being true says the engine's API will accept a reasoning-effort field on the request; whether a particular model supports reasoning is a separate runtime concern (every reasoning field 400s on a non-reasoning model). Engines whose wire never accepts the field clear the flag via around engine_capabilities (e.g. Perplexity), or per model via "model_capability_corrections" (e.g. MiniMax's OpenAI endpoint, where only MiniMax-M3 keeps it).

Prompt caching is request-side-asymmetric, so it gets two flags rather than one: prompt_cache means the wire accepts an explicit cache-enable breakpoint (Anthropic's cache_control), while prompt_cache_key means the wire accepts an OpenAI-style routing hint (caching itself is automatic there). The single Langertha::Role::PromptCache role contributes both; the OpenAIBase / AnthropicBase base classes each clear the one that does not apply to their wire, so the OpenAI family advertises only the key and the Anthropic family only the enable breakpoint.

prefix_caching (from Langertha::Role::RuntimeKnobs, composed on the self-hosted vLLM / SGLang / llama.cpp engines) means the wire accepts prefix-cache isolation/reuse controls (cache_salt, cache_prompt, n_cache_reuse, id_slot, priority, return_cached_tokens_details, extra_key) — not that prefix caching is on. Whether the server actually caches is launch state the client cannot observe (vLLM --enable-prefix-caching, SGLang --enable-mixed-prefill / --enable-prefix-caching, llama.cpp --cache_prompt); the flag only says the request body may carry the knobs.

server_tools (from Langertha::Role::ServerTools) means the wire accepts provider-native server-side tool entries in tools (web_search, file_search, remote mcp, ...; see Langertha::ServerTool). It is one flag on purpose: which tool types a model honors is a fast-moving provider vocabulary, not a capability.

image_input (from Langertha::Role::ImageInput) is the exception to the wire-only contract: it means the selected model sees an image part (Langertha::Content::Image), not merely that the wire accepts one. It is resolved per model; engines whose model is unknown to the client (gateways, self-hosted servers, shims) make no claim. The flag is advisory: nothing blocks an image on an engine or model without it.

model_capability_corrections

sub model_capability_corrections {
  return (
    'kimi-k3'       => { tool_choice_named => 0 },  # exact model id
    qr/\Akimi-k2\./ => { tool_choice_any   => 0 },  # a model family
  );
}

The per-model correction layer (layer 3 of engine_capabilities). Returns an ordered list of ( $matcher => \%overrides ) pairs. $matcher is either an exact model-id string (matched with eq) or a qr// regex (matched against the engine's chat_model) — model ids come in families (gpt-5.6-*, kimi-k2.7-*), so both forms are supported. \%overrides maps a capability flag to 1 (assert) or 0 (clear); later matching entries win on a shared flag.

This is the sanctioned home for a wire reality that differs per model rather than per engine — for example a model that forbids a forced named tool while its siblings allow it. Engine-wide corrections (the whole endpoint never accepts a field) belong in around engine_capabilities instead. The default returns an empty list, so engines that need no per-model refinement pay nothing.

supports

if ( $engine->supports('tool_choice_named') ) { ... }

Convenience wrapper that returns a true value when the named capability is present and truthy in engine_capabilities.

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.