Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ final class Agent implements AgentInterface
* @param array<ToolInterface> $tools Available tools
* @param array<string, Closure> $hooks Event hooks
* @param int $maxTokens Max tokens in response
* @param float $temperature Temperature for generation
* @param float|null $temperature Temperature for generation, or null to leave it to the model.
* Defaults to null on purpose: Anthropic returns a 400 for a non-default temperature on
* Claude 4.7 and later, and Google has deprecated it, so an agent that invents a value
* nobody asked for would break those models outright.
* @param int $maxTurns Max agentic turns (tool call loops)
* @param array<MiddlewareInterface> $middleware Middleware pipeline
*/
Expand All @@ -61,7 +64,7 @@ public function __construct(
array $tools = [],
array $hooks = [],
private readonly int $maxTokens = 4096,
private readonly float $temperature = 0.7,
private readonly ?float $temperature = null,
private readonly int $maxTurns = 10,
array $middleware = [],
) {
Expand Down Expand Up @@ -281,10 +284,13 @@ private function callProvider(array $messages, ?Schema $outputSchema = null): Re
*/
private function getProviderOptions(): array
{
$options = [
'maxTokens' => $this->maxTokens,
'temperature' => $this->temperature,
];
$options = ['maxTokens' => $this->maxTokens];

// Only when the caller actually chose one. See the constructor docblock: several current
// models reject a temperature they did not ask for.
if ($this->temperature !== null) {
$options['temperature'] = $this->temperature;
}

// Only forward the model when set; an empty string would otherwise defeat
// the provider's `$options['model'] ?? $defaultModel` fallback (which only
Expand Down
6 changes: 5 additions & 1 deletion src/AgentBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ final class AgentBuilder

private int $maxTokens = 4096;

private float $temperature = 0.7;
/**
* Null until the caller chooses one, so nothing is sent that the model did not ask for.
* See {@see Agent::__construct()} for why inventing a default breaks current models.
*/
private ?float $temperature = null;

private int $maxTurns = 10;

Expand Down
7 changes: 7 additions & 0 deletions src/Contracts/ProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
* "none", "required", or `["name" => "<tool>"]` to force a specific tool. Providers validate it
* (see PapiAI\Core\ToolChoice) and throw before any HTTP call when it cannot be met.
*
* `effort` asks the model to think harder before answering: "low", "medium" or "high" (see
* PapiAI\Core\Effort). Every reasoning provider spells this differently, so each translates the
* level to its own knob. Providers with no such knob ignore it, documented per provider. That is
* deliberate and unlike `toolChoice`: effort is a hint about quality, so ignoring it degrades
* nothing the caller was promised, whereas ignoring a forced tool would break a guarantee.
*
* @psalm-type ChatOptions = array{
* model?: string,
* tools?: array,
Expand All @@ -38,6 +44,7 @@
* stopSequences?: array<string>,
* outputSchema?: array,
* toolChoice?: string|array{name: string},
* effort?: string,
* }
*/
interface ProviderInterface
Expand Down
175 changes: 175 additions & 0 deletions src/Effort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace PapiAI\Core;

/**
* How hard the model should think before answering.
*
* A neutral scale over a knob every reasoning provider spells differently: OpenAI takes a
* `reasoning_effort` string whose accepted values depend on the model, Anthropic wants a token
* budget for extended thinking, Gemini wants either a budget or one of two levels depending on the
* model generation.
*
* The scale is deliberately wider than any single provider offers, so a caller can say what they
* mean and each provider narrows it to what it actually has (see {@see nearestOf()}). Narrowing
* belongs to the provider because only it knows its own range; deciding what a level *costs* is
* shared, so that arithmetic lives here rather than being reinvented per provider.
*
* Providers with no reasoning knob ignore the option entirely, documented per provider. Effort is
* a hint about quality rather than a guarantee, so being ignored degrades nothing the caller was
* promised. That is the opposite of `toolChoice`, where being ignored would break a promise.
*/
enum Effort: string
{
/**
* The smallest budget worth spending. Anthropic rejects anything under this, and it is a sane
* floor for everyone else.
*/
public const MINIMUM_BUDGET = 1024;

/**
* Room the answer itself needs, over and above whatever thinking consumes.
*/
private const ANSWER_HEADROOM = 512;

/**
* Do not think at all. Worth asking for explicitly on models that otherwise think by default.
*/
case None = 'none';

case Minimal = 'minimal';
case Low = 'low';
case Medium = 'medium';
case High = 'high';
case ExtraHigh = 'extra-high';

/**
* Everything the ceiling allows, leaving only room to answer.
*/
case Maximum = 'maximum';

/**
* Whether this level asks the model to think at all.
*/
public function thinks(): bool
{
return $this !== self::None;
}

/**
* Tokens to spend thinking, given the ceiling for the whole response.
*
* Thinking counts against the same ceiling as the answer, so the budget is a share of it,
* clamped to leave room to reply. Callers should check {@see fitsWithin()} first: a ceiling too
* small for any budget cannot be satisfied by clamping.
*
* @param int $maxTokens The response ceiling the request will carry
*
* @return int Tokens to allot to thinking, zero when this level does not think
*/
public function budgetWithin(int $maxTokens): int
{
if (!$this->thinks()) {
return 0;
}

$ceiling = $maxTokens - self::ANSWER_HEADROOM;

return max(self::MINIMUM_BUDGET, min((int) floor($maxTokens * $this->share()), $ceiling));
}

/**
* Whether a thinking budget can fit under this ceiling at all.
*
* False means the request cannot both think and answer, which is a caller error rather than
* something to paper over by silently dropping the option.
*
* @param int $maxTokens The response ceiling the request will carry
*/
public function fitsWithin(int $maxTokens): bool
{
if (!$this->thinks()) {
return true;
}

return $maxTokens - self::ANSWER_HEADROOM >= self::MINIMUM_BUDGET;
}

/**
* The closest level a provider actually offers.
*
* Providers rarely implement the whole scale: Gemini 3 has two levels, OpenAI's top levels
* exist only on some models. Rather than each provider inventing its own rounding, they declare
* what they offer and ask for the nearest match.
*
* Ties round **up**. On a two-level scale a request for Medium is equally far from either, and
* quietly dropping to the floor is the more surprising outcome: the caller asked for real
* thinking and would get the least available. Erring high costs tokens, which is visible;
* erring low costs answer quality, which is not.
*
* @param non-empty-list<self> $offered The levels this provider can honour
*
* @return self The nearest offered level
*/
public function nearestOf(array $offered): self
{
$target = $this->rank();
$best = null;
$bestDistance = PHP_INT_MAX;

foreach ($offered as $candidate) {
$distance = abs($candidate->rank() - $target);

if ($distance < $bestDistance || ($distance === $bestDistance && $best !== null && $candidate->rank() > $best->rank())) {
$best = $candidate;
$bestDistance = $distance;
}
}

return $best ?? $this;
}

/**
* Position on the scale, low to high.
*/
private function rank(): int
{
return match ($this) {
self::None => 0,
self::Minimal => 1,
self::Low => 2,
self::Medium => 3,
self::High => 4,
self::ExtraHigh => 5,
self::Maximum => 6,
};
}

/**
* The proportion of the ceiling this level is willing to spend on thinking.
*/
private function share(): float
{
return match ($this) {
self::None => 0.0,
self::Minimal => 0.05,
self::Low => 0.2,
self::Medium => 0.4,
self::High => 0.6,
self::ExtraHigh => 0.8,
self::Maximum => 1.0,
};
}
}
37 changes: 37 additions & 0 deletions src/Exception/UnknownEffortException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace PapiAI\Core\Exception;

use InvalidArgumentException;
use PapiAI\Core\Effort;

/**
* Thrown when a caller asks for a level of effort that does not exist.
*
* The message is built here rather than at each provider so the accepted vocabulary is stated in
* one place and cannot drift as levels are added. Extends `InvalidArgumentException` to sit
* alongside the other option-validation failures, which callers already catch.
*/
final class UnknownEffortException extends InvalidArgumentException
{
public function __construct(string $value)
{
parent::__construct(sprintf(
'Unknown effort "%s". Expected one of: %s.',
$value,
implode(', ', array_map(static fn (Effort $effort): string => '"' . $effort->value . '"', Effort::cases())),
));
}
}
95 changes: 95 additions & 0 deletions tests/Unit/AgentTemperatureTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

use PapiAI\Core\Agent;
use PapiAI\Core\Contracts\ProviderInterface;
use PapiAI\Core\Response;

/**
* Records the options every call was made with.
*/
class TemperatureRecordingProvider implements ProviderInterface
{
/** @var list<array<string, mixed>> */
public array $calls = [];

public function chat(array $messages, array $options = []): Response
{
$this->calls[] = $options;

return new Response('done');
}

public function stream(array $messages, array $options = []): iterable
{
$this->calls[] = $options;

return [];
}

public function supportsTool(): bool
{
return true;
}

public function supportsVision(): bool
{
return false;
}

public function supportsStructuredOutput(): bool
{
return false;
}

public function getName(): string
{
return 'recorder';
}
}

/**
* Anthropic returns a 400 for a non-default temperature on Claude 4.7 and later, and Google has
* deprecated it too. An agent that invents a temperature nobody asked for therefore breaks those
* models outright, so it is only sent when the caller actually chose one.
*/
describe('Agent temperature', function () {
beforeEach(function () {
$this->provider = new TemperatureRecordingProvider();
});

it('sends no temperature when the caller never set one', function () {
(new Agent(provider: $this->provider, model: 'm'))->run('hi');

expect($this->provider->calls[0])->not->toHaveKey('temperature');
});

it('sends the temperature the caller chose', function () {
(new Agent(provider: $this->provider, model: 'm', temperature: 0.2))->run('hi');

expect($this->provider->calls[0]['temperature'])->toBe(0.2);
});

it('sends an explicit zero, which is a real choice', function () {
(new Agent(provider: $this->provider, model: 'm', temperature: 0.0))->run('hi');

expect($this->provider->calls[0]['temperature'])->toBe(0.0);
});

it('leaves it out when streaming too', function () {
iterator_to_array((new Agent(provider: $this->provider, model: 'm'))->stream('hi'));

expect($this->provider->calls[0])->not->toHaveKey('temperature');
});
});
Loading
Loading