What's New in PapiAI 0.15
Reasoning effort, forced tool choice, repository ingestion, and a lesson about model IDs.
PapiAI 0.15 is out across all nineteen packages. It adds a neutral way to control how hard a model thinks, a type-safe way to force tool calls, and a new package for feeding a whole codebase to a model within a token budget.
It also fixes a class of bug we had not been watching for, which turned out to be the most interesting part of the release. More on that at the end.
Reasoning effort, without provider trivia
Every thinking model exposes a knob for how much reasoning to spend, and every provider names it differently. OpenAI has reasoning_effort. Anthropic takes a thinking token budget. Google has thinking levels on Gemini 3 and a token budget on 2.5. DeepSeek nests its own object. Ollama takes a boolean.
Writing provider-agnostic code meant knowing all of that. Now there is one scale:
$response = $agent->run('Prove that the square root of 2 is irrational', [
'effort' => 'high',
]);
Or set a default for every call a provider makes:
use PapiAI\Core\Effort;
use PapiAI\Google\GoogleProvider;
$provider = new GoogleProvider(
apiKey: $_ENV['GOOGLE_API_KEY'],
defaultEffort: Effort::High,
);
The scale has seven levels: none, minimal, low, medium, high, extra-high, maximum.
Seven, because the alternative is losing information at the contract. If the neutral scale only had three levels, a provider offering five would have to round two of them away before you ever saw them. Instead the core owns the full vocabulary and each provider narrows to what it actually supports, with ties rounding up. Ask for extra-high on a model that only offers low, medium and high, and you get high rather than a silent downgrade to medium.
Two details worth knowing, because both are cases where we chose honesty over convenience:
- Mistral and Cohere have no equivalent knob.
effortis ignored there rather than faked with a temperature tweak that would look like it worked. - No Gemini 3 model can switch thinking off.
Effort::Nonenarrows to the shallowest level the model offers, rather than pretending to disable something the API will not disable.
An effort level the core does not recognise throws UnknownEffortException before any HTTP request is made, so a typo costs you nothing.
Forced tool choice, answered by type
By default a model decides for itself whether to call a tool. Sometimes you need to take that decision away: extraction pipelines want a specific tool every time, and a summarisation step wants no tools at all.
// It must call some tool
$agent->run('What is the weather in Lisbon?', ['toolChoice' => 'required']);
// It must call this tool
$agent->run('What is the weather in Lisbon?', ['toolChoice' => ['name' => 'get_weather']]);
// It must not call any tool
$agent->run('Summarise the conversation so far', ['toolChoice' => 'none']);
Inside an Agent, the choice is forced on the opening call only, then reverts to automatic. That is deliberate. Forcing a tool call on every turn means the model can never stop calling tools and produce an answer, so the loop runs until it hits maxTurns and gives you nothing useful.
Support is genuinely uneven across providers, and this is where it gets interesting. The obvious API would be a supportsToolChoice() method. We did not add one, because a boolean cannot express the three states that actually exist: force a named tool, force any tool, or nothing at all. Nor can it be answered by the existing supportsTool(), which returns true on providers that support tools but reject a forced choice.
So it is answered by type:
use PapiAI\Core\Contracts\NamedToolSelectableInterface;
use PapiAI\Core\Contracts\ToolSelectableInterface;
if ($provider instanceof NamedToolSelectableInterface) {
// can force one specific tool by name
} elseif ($provider instanceof ToolSelectableInterface) {
// can force "some tool" or "no tools", but not a named one
}
Anthropic, OpenAI, Azure OpenAI, Google, Mistral, Groq, Grok and DeepSeek implement the named interface. Cohere implements only the broader one, because its API can require a tool call but cannot name which. Ollama and ElevenLabs implement neither.
A provider that cannot honour what you asked for throws rather than quietly downgrading to automatic. A silently ignored constraint is the worst outcome here: your extraction pipeline appears to work, and produces prose instead of structured data on some fraction of calls.
FailoverProvider implements neither interface on purpose, since what it can force depends on which provider ends up answering.
Repository ingestion
Grounding a model in "what is this codebase" usually means a hand-rolled directory walk that either misses the context you needed or blows your token budget. The new papi-ai/ingest package makes it a proper seam: deterministic, with no model calls, and bounded by a budget you set.
composer require papi-ai/ingest
use PapiAI\Ingest\Depth;
use PapiAI\Ingest\IngestRequest;
use PapiAI\Ingest\NativeIngestor;
$digest = (new NativeIngestor())->ingest(new IngestRequest(
source: '/path/to/repo',
depth: Depth::Api,
tokenBudget: 4000,
));
$agent->run("Here is the codebase:\n\n" . $digest->toPrompt());
Three depths. Depth::Tree gives the file tree alone. Depth::Full gives complete contents. Depth::Api is the one worth reaching for: each file's public shape, its classes, signatures and outgoing dependencies. On a real repository that renders the whole system's structure for well under a tenth of what Full costs, which leaves budget for the handful of files that actually matter to the task.
Symbol extraction needs a parser, so it lives in its own package and the dependency is opt-in:
composer require papi-ai/ingest-php-symbols
use PapiAI\Ingest\PhpSymbols\PhpSymbolExtractor;
$ingestor = new NativeIngestor(new PhpSymbolExtractor());
Ask for Depth::Api without an extractor and it throws UnsupportedDepthException, naming the package to install. It does not quietly hand you a file tree and let you wonder why the model seems confused.
Budget trimming is deterministic: the tree first, then files in priority order until the budget is spent, then a trailer naming exactly what was dropped. Ties break by path, so the same repository always produces the same digest, which means you can cache on it. Nothing is ever truncated silently.
Pass a prioritiser to decide what survives the cut. Most-recently-modified first works well: the model gets the whole map, plus the detail on the parts you are actually working in.
Remote repositories are handled by papi-ai/ingest-gitingest, which shells out to the gitingest CLI and tells you plainly when the binary is missing.
Token optimisation
Command output is the worst offender in an agent's token budget. git status, test runs and directory listings are verbose, repetitive and mostly noise. papi-ai/rtk wraps the rtk CLI to compress it before it reaches the model:
use PapiAI\Rtk\RtkProxy;
$result = (new RtkProxy())->optimiseCommand('git status');
echo $result->optimised;
echo $result->tokensBefore . ' -> ' . $result->tokensAfter;
The optimisation is lossy by design, so the result carries the strategy used and the before and after counts. You get to decide whether the trade was worth it, rather than discovering later that something important was compressed away.
The Agent stopped inventing a temperature
This one is small, and it is a behaviour change worth reading twice.
Agent used to send temperature: 0.7 on every request, whether you asked for it or not. That default was invented by us, not by any provider. It is now ?float $temperature = null, and the parameter is only sent when you actually choose a value.
The trigger was that the invented default had started to break things: temperature is a hard 400 error on Claude 4.7 and later, and deprecated on Gemini. But it was wrong before it broke. Every model ships a default its own vendor tuned, and quietly overriding it with a number we made up meant nobody was getting the behaviour their provider intended.
If your code relied on the implicit 0.7, you will now get the model's own default. Pass temperature: 0.7 explicitly to keep the old behaviour.
Image generation, rebuilt
Google's image generation moved off Imagen. The Imagen line shuts down on 17 August 2026, taking its separate :predict endpoint with it, and the Gemini image models that replace it do not speak that endpoint at all.
$result = $provider->generateImage('A professional product photo of headphones', [
'model' => GoogleProvider::MODEL_3_1_FLASH_IMAGE,
'aspectRatio' => '16:9', // optional
'imageSize' => '2K', // optional: 1K, 2K or 4K
]);
$image = $result['images'][0];
$extension = str_replace('image/', '', $image['mimeType']);
file_put_contents("output.{$extension}", base64_decode($image['data']));
Two changes to note. Aspect ratio and image size are now optional: leave them out and the model picks its own. Previously editImage() forced 1:1 on every call, which silently reshaped whatever image you passed in. It was the same mistake as the invented temperature, in a different corner.
And numberOfImages above 1 now throws. The Gemini image models return one image per request and have no equivalent of Imagen's sampleCount. Returning a single image to a caller who asked for four would be a lie that is hard to notice.
The part we did not plan: model IDs rot
While preparing this release we checked our default models against what the providers currently serve.
Six of the ten defaults were dead or redirected.
Not deprecated. Dead. Google's chat default had been shut down since March and nobody noticed until July. DeepSeek's default was discontinued and its requests simply failed. Grok's default silently redirected to a newer model and billed at that model's higher rate, which is the worst failure mode of the lot, because everything appears to work while the invoice quietly changes.
The pattern is obvious in hindsight. A model ID is a string you write down once, in code that then works perfectly for a year, and it can be retired by someone else without warning. Nothing in your test suite will notice, because a test suite mocks HTTP.
So papi-ai/google now ships a scheduled model watch: a weekly job that compares every model ID the package ships against the provider's published model list, and opens an issue when one disappears. It is rolling out to the other providers next.
Two design decisions in it are worth stealing if you build something similar.
It needs no API key. It reads the public documentation rather than the authenticated models endpoint. A watchdog on a public repository must never depend on someone attaching a billable credential to it.
It fails only on the unacknowledged case. A constant that has vanished and is not marked deprecated fails the job. A constant we have already marked deprecated is reported but does not fail, so the job goes green again as soon as a human acknowledges the retirement. A scheduled job that stays permanently red becomes wallpaper, and then it hides the next real failure.
The same rot had reached our documentation, where nothing was checking at all. Several provider READMEs recommended models that had been retired, and one documented two constants that had never existed in the first place. All of that is corrected as of 1 August 2026, across every package and this website.
Upgrading
All nineteen packages are on papi-ai/papi-core: ^0.15.
composer update papi-ai/papi-core papi-ai/google
The one behaviour change to check before upgrading is the temperature default described above. Everything else is additive.
If you use Google image generation, upgrade before 17 August 2026. The Imagen path stops working on that date regardless of what you do.