← Laragents 11 / 18

Providers and models

Two chat clients ship with the package, and they exist because the loop needs something to call. If what you want is fifteen providers behind one interface, laravel/ai is the framework's own answer and is better at that than this will be.

Clients\OpenAiClient         chat, tools, structured output
Clients\AnthropicClient      the same, plus prompt caching
Clients\FailoverClient       one, then the next, on a rate limit

Clients\OpenAiEmbedClient        vectors, for memory recall
Clients\OpenAiTranscribeClient   speech in
Clients\OpenAiSpeakClient        speech out
Clients\OpenAiImageClient        images

The last four are not called by anything in the package. They are there for an application that records voice notes or generates images, and they meter through the same recorder as everything else.

The model is always an argument

$loop->run(messages: $messages, model: 'gpt-4.1-mini', ...);

There is no default anywhere. This is deliberate and comes from a specific scar: the application this came from resolved the model from the name of the operation, so renaming an operation moved it to a different model and a different price with nobody deciding it. One export operation spent months on the expensive tier because it had not been named the right way.

With no default there is nowhere to fall to by accident.

Which provider answers

Laragents::talkWith(AnthropicClient::class);

That is the fallback for anything that does not say otherwise. Which provider a given conversation uses is usually a runtime decision your own executor makes, by resolving the client it wants and passing it to the loop.

Reasoning models are different, and the package knows

ModelCapabilities reads the model name and decides two things:

ModelCapabilities::isReasoning('gpt-5');        // true
ModelCapabilities::isReasoning('gpt-5-chat');   // false, despite the prefix
ModelCapabilities::supportsTemperature($model);
ModelCapabilities::supportsOutputCap($model);

A reasoning model rejects temperature with a 400, so it is not sent. And its output cap covers the thinking you never see, so a number sized for the visible answer can be spent entirely on reasoning, returning nothing. The cap is dropped for those models rather than allowed to do that.

Both are silent failures otherwise: one is an error you get to see, the other is an empty reply with no explanation.

Prompt caching, on Anthropic

The Anthropic client marks the last message and the last tool definition with an ephemeral cache_control, so the stable head of the request is cached and the next turn pays a tenth for it.

It also reports what happened:

$response->cacheCreationTokens;
$response->cacheReadTokens;

And it prices it: the effective input is input + cacheCreation × 1.25 + cacheRead × 0.1, which is what the recorder receives. Writing to the cache costs more than a plain token and reading from it costs a tenth, so metering raw input tokens would tell you the wrong number in both directions.

Failover

'failover' => [
    ['client' => \EduLazaro\Laragents\Clients\AnthropicClient::class, 'model' => 'claude-sonnet-4-6'],
],

Tried in order, and only on a rate limit. A 429 is the other side saying "not now", and the conversation in front of a person does not get to wait. With two providers already implementing one contract, the second one is sitting there idle while the first says no.

A missing key, a malformed request or a refusal are not going to go better elsewhere, and retrying those everywhere turns one failure into as many as you have providers, each one billed.

Empty by default, because failing over means the reply comes from a model the caller did not choose, and that is a decision rather than a default.

One normalised response

$response->content;
$response->toolCalls;      // OpenAI shape, whatever the provider was
$response->finishReason;
$response->reasoning;      // when the model showed its working
$response->citations;      // when it searched
$response->wasTruncated();

Tool calls come back in OpenAI shape whatever answered, so the loop has one format to parse. Translating Anthropic's tool_use into it is the client's job, not the loop's.

finishReason is worth checking, and wasTruncated() exists because providers spell it two different ways: length on OpenAI, max_tokens on Anthropic. Either way a truncated reply arrives as ordinary content, cut mid-sentence or empty, with nothing raised.

reasoning is kept apart from content because it is not the answer: showing it to a user as if it were reads as the model talking to itself. It is for a debug view and for a log when an answer comes out wrong. Providers redact it most of the time even though the tokens were billed, so its absence proves nothing.

citations are only ever filled by a provider-side search capability. A model answering from memory cites nothing, and one that offers a citation unprompted has invented it.