← LLM Module

LLM MODULE FEATURE

Providers & Backends

The assistant is only as reliable as the provider boundary under it. `semitexa/llm` keeps that boundary explicit and swappable.

Feature Guide

A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.

What this does

Provider contracts, backend resolution, reserve models that answer when the first one fails, named routes that put several models to work at once, and the environment knobs that shape them.

Runtime Backends

How the module reaches an LLM

The built-in implementation focuses on Ollama, but the rest of the module talks to provider contracts, not to hard-coded curl calls scattered through commands.

Backend Resolver path Key env knobs Operational note
Local Ollama LlmBackend::Local -> LocalOllamaProvider LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, LLM_RETRIES Default self-hosted path for local inference and fast development loops.
Remote Ollama LlmBackend::RemoteOllama -> RemoteOllamaProvider LLM_REMOTE_OLLAMA_URL, LLM_REMOTE_OLLAMA_MODEL, timeouts, retries Useful when the model runtime lives on another host or heavier node.
Backend-agnostic commands LlmProviderInterface + LlmProviderResolver backend key + provider factory The assistant loop only needs `healthCheck()` and `complete()` from the active provider.
LlmProviderInterface LlmProviderResolver FailoverProvider LLM_FALLBACK LLM_ROUTE local Ollama remote Ollama

Verified against Semitexa Ultimate 2026.09.19.1020

Providers & Backends

The provider layer isolates the assistant from any one backend. semitexa/llm talks to an LlmProviderInterface; LlmProviderResolver decides which backend answers: local Ollama, remote Ollama, Gemini or Claude. One application can use several of them at once, each feature on the models it needs, with reserve models behind them that answer when the first one cannot.

How it works

Inject LlmProviderResolver and ask it for a provider:

$this->llm->provider()->complete(new LlmRequest($system, $message));
Call Answers with
provider() LLM_BACKEND, one model
provider(LlmBackend::Anthropic) that backend, one model
deciderProvider() the same on its decider model
route('chat') the models of route chat, best first, each a reserve for the one before
deciderRoute('chat') route chat on its decider models

provider() stays one model on purpose: its callers tune it by its class (withLimits() on a GeminiProvider, tools only when it is one). Reserves come with a route.

The decider is the cheaper model that silently picks a tool or classifies; the provider's own model writes the reply. Its model is LLM_DECIDER_MODEL_<BACKEND> (LLM_DECIDER_MODEL_ANTHROPIC=claude-haiku-5-5), which also wins over a model a route names for that backend. The plain LLM_DECIDER_MODEL applies to the LLM_BACKEND provider only, since it names a model of that provider. Gemini and Claude can switch models; the Ollama providers always use their own, so a model named for them in a route is ignored.

Every provider speaks HTTP through the framework's client (HttpClientInterface): a failed connection or a timeout, a 429, a 500, 502, 503, 504 or 529 (overloaded) is tried again up to the provider's retry count (LLM_RETRIES, LLM_REMOTE_OLLAMA_RETRIES, GEMINI_RETRIES, ANTHROPIC_RETRIES), 0.5 s, 1 s, 1.5 s apart, a Retry-After honoured up to 5 s; any other 4xx, a TLS failure and any other status are not. A timeout of 0 means one hour, not none. The connect timeout (*_CONNECT_TIMEOUT, 5 s by default) is kept apart from the generation timeout, so an endpoint that has gone away costs seconds, not minutes.

Several models at once, with reserves: routes

A route is a name a feature asks for; the operator decides what serves it.

LLM_ROUTE_CHAT=anthropic:claude-opus-5-5,gemini
LLM_DECIDER_MODEL_ANTHROPIC=claude-haiku-5-5
$reply = $this->llm->route('chat')->complete($request);           // Opus, then Gemini
$pick  = $this->llm->deciderRoute('chat')->complete($decision);   // Haiku, then Gemini's decider

Each member is <backend> or <backend>:<model>; a name LlmBackend does not know is left out, not read as local, and logged as llm.route_unknown_backend (once per worker). One model at one address is asked once, also when it is named twice or as gemini beside gemini:<its default model>. A route nobody configured is LLM_BACKEND with the LLM_FALLBACK reserves behind it:

LLM_BACKEND=anthropic
LLM_FALLBACK=gemini,local

so a feature can ask for its route before anyone sets one, and gets Claude, then Gemini, then local Ollama. Every member but the last gives up at its first failure instead of retrying, so the next one is asked at once.

A conversation turn that asks twice (the decider, then the reply) reads the route once and builds both from it, so a setting changed mid-turn cannot split the turn across two sets of models:

$specs    = $this->llm->routeSpecs('chat');                  // ['anthropic', 'gemini']
$decider  = $this->llm->fromMembers($specs, decider: true);
$narrator = $this->llm->fromMembers($specs);
$tools    = $this->llm->membersSupportTools($specs);

To decide a route while the application runs (an administrator's setting rather than the env), bind an LlmRouteOverrideInterface:

#[SatisfiesServiceContract(of: LlmRouteOverrideInterface::class)]
final class BrainSetting implements LlmRouteOverrideInterface
{
    public function members(string $route): ?array
    {
        return $route === 'chat' && $this->chosen() === 'claude' ? ['anthropic', 'gemini'] : null;
    }
}

It is asked on every route(), deciderRoute() and routeSpecs() call, before LLM_ROUTE_<NAME>; null leaves the route to its env, and so does an exception (logged as llm.route_override_failed): a setting that cannot be read does not silence the feature. Keep it cheap.

What a failover does

  • The answer names who gave it. LlmResponse::$servedBy is <provider>:<model> (anthropic:claude-opus-5-5); name() and model() of a route name its first choice. A route of one model is that provider itself, which sets no servedBy: log $response->servedBy ?? $provider->name() . ':' . $provider->model().
  • Failures have a kind. LlmResponse::$failure is an LlmFailure: Unconfigured, Unauthorized, NoCredit, RateLimited, QuotaExhausted (a daily quota), Timeout, Network, ServerError, BadRequest, Refused or Malformed. The HTTP status decides first; only a 400 or 403 is read further, from the API's own error fields. Every failure moves on to the next model, a refusal too: a model that declined is not asked to decide for the others. When all fail, the last failure comes back, its error listing every model's reason.
  • What will repeat is set aside. No key, a refused key, no credit, a reached spend cap or a used-up daily quota (LlmFailure::isLasting()) sets that model aside for LLM_BENCH_SECONDS (300 by default, 0 never). A model that does not answer at all (a timeout, an unreachable host) is set aside for LLM_BENCH_TRANSIENT_SECONDS (30 by default), so the next calls do not each wait out the same timeout; a timeout under a caller's own withLimits() sets nothing aside, since it was that caller's budget. Gemini's FAILED_PRECONDITION (billing not enabled, a region not served) counts as Unconfigured, and so does a remote Ollama with no LLM_REMOTE_OLLAMA_URL. A member that throws instead of answering counts as Malformed, and the next one is asked. While a worker drains, a call that got no answer (a timeout, a network failure) is returned as it is: the drain cancelled it, so nothing is set aside and no reserve is asked. A 429 per minute or a 5xx is not set aside: the next call may pass. A provider of your own that sets no failure is never set aside.
  • One list for every worker. Under the server the set-aside list is shared; LlmProviderResolver::resetBenches() called in a request (after a key or credit is fixed) asks every model again in every worker. Called from a console command it clears only that command's process.
  • When every model is set aside, all are asked anyway, in order.
  • A request with tools goes only to models that take tools (Gemini, Claude): a text-only model would answer with text the caller reads as a tool call. routeSupportsTools('chat') is true only when every model of the route takes tools; otherwise ask for JSON in text, and every reserve can answer.
  • Each failure is logged as llm.failover (from, failure, error, seconds set aside); an answer after a failover as llm.failover.served.
  • The time adds up. A reserve is asked only after the model before it gave up, its timeout included. For a live chat, keep the first model's timeout short.

A FailoverProvider can also be built by hand: new FailoverProvider([$claude, $gemini], $health, $logger). Its withLimits() reaches every member that takes limits, with the retries given to the last member only, so the others still hand over at their first failure; its withModel() switches the first choice only.

Configuration

Key Default Meaning
LLM_BACKEND local local, remote_ollama, gemini or anthropic
LLM_FALLBACK empty reserves behind LLM_BACKEND on a route nobody configured, in order
LLM_ROUTE_<NAME> LLM_BACKEND + reserves the models of route <name>, best first
LLM_DECIDER_MODEL_<BACKEND> the backend's model the decider model of that backend
LLM_DECIDER_MODEL none the decider model of LLM_BACKEND
LLM_BENCH_SECONDS 300 how long a model that will keep failing is set aside
LLM_BENCH_TRANSIENT_SECONDS 30 how long a model that did not answer at all is set aside
GEMINI_API_KEY, ANTHROPIC_API_KEY empty a backend with no key fails as Unconfigured and is set aside

Testing

LlmProviderResolver::withProvider($scripted) answers every provider(), route(), fromMembers() and decider call with one provider, so no test reaches a network. ScriptedProvider takes replies as text, or as LlmResponse objects to script a failure (LlmResponse::failed('HTTP 402', LlmFailure::NoCredit)) or a tool call, and can be given a name and model to stand in for one member of a route.

Why this matters

This keeps the assistant runtime swappable without changing the planner or executor, and it keeps an assistant answering when one vendor is down or out of credit. The command surface does not need to care which backend actually answered: the response says so.

© Brian Kernighan: "Controlling complexity is the essence of computer programming."

Provider Setup Example Implementation slice
<?phpdeclare(strict_types=1);namespace App\Infrastructure\Llm;use Semitexa\Llm\Domain\Enum\LlmBackend;use Semitexa\Llm\Domain\Model\LlmRequest;// Local Ollama// LLM_BASE_URL=http://127.0.0.1:11434// LLM_MODEL=gemma3:4b// Remote Ollama// LLM_REMOTE_OLLAMA_URL=http://10.0.0.25:11434// LLM_REMOTE_OLLAMA_MODEL=gemma4:e2b// Resolve the configured provider factory from your container/bootstrap.$factory = $factory ?? throw new \LogicException('Resolve $factory from your application container.');$request = new LlmRequest(    systemPrompt: 'You are a Semitexa framework assistant.',    userMessage: 'Summarize the current deployment health.',);$provider = $factory->get(LlmBackend::Local);if ($provider->healthCheck()) {    $response = $provider->complete($request);}

Provider Rules

What the built-in providers already handle

The provider classes are thin, but they own several practical concerns that should not leak into planner or command code.

They build the provider-specific HTTP payload from `LlmRequest` and normalize the response into `LlmResponse`.

They run a health check before interactive use so the operator gets a clean failure early.

They expose retries, timeouts, base URL, and model selection through environment-backed config.

They report latency and token usage when the backend returns that metadata.

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal