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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
],
"require": {
"php": "^8.2",
"papi-ai/papi-core": "^0.14",
"papi-ai/papi-core": "^0.15",
"ext-curl": "*"
},
"require-dev": {
Expand Down
125 changes: 123 additions & 2 deletions src/GoogleProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@
use Generator;
use PapiAI\Core\Contracts\EmbeddingProviderInterface;
use PapiAI\Core\Contracts\ImageProviderInterface;
use PapiAI\Core\Contracts\NamedToolSelectableInterface;
use PapiAI\Core\Contracts\ProviderInterface;
use PapiAI\Core\Contracts\VideoProviderInterface;
use PapiAI\Core\Effort;
use PapiAI\Core\EmbeddingResponse;
use PapiAI\Core\Exception\AuthenticationException;
use PapiAI\Core\Exception\ProviderException;
use PapiAI\Core\Exception\RateLimitException;
use PapiAI\Core\Exception\UnknownEffortException;
use PapiAI\Core\JobStatus;
use PapiAI\Core\Message;
use PapiAI\Core\Response;
Expand Down Expand Up @@ -64,13 +67,28 @@
*
* @psalm-import-type ChatOptions from ProviderInterface
*/
class GoogleProvider implements ProviderInterface, ImageProviderInterface, EmbeddingProviderInterface, VideoProviderInterface
class GoogleProvider implements ProviderInterface, ImageProviderInterface, EmbeddingProviderInterface, VideoProviderInterface, NamedToolSelectableInterface
{
private const API_ROOT = 'https://generativelanguage.googleapis.com/v1beta';
private const API_BASE = self::API_ROOT . '/models';

/**
* Floor for a pre-3 thinking budget. Gemini 2.5 Pro will not go below this and cannot be
* switched off at all.
*/
private const MIN_THINKING_BUDGET = 128;

/**
* Ceiling for a pre-3 thinking budget, well under the output ceiling of a large request.
*/
private const MAX_THINKING_BUDGET = 32768;

// Gemini model aliases
public const MODEL_3_6_FLASH = 'gemini-3.6-flash';
public const MODEL_3_5_FLASH = 'gemini-3.5-flash';
public const MODEL_3_5_FLASH_LITE = 'gemini-3.5-flash-lite';
public const MODEL_3_1_PRO = 'gemini-3.1-pro-preview';
/** @deprecated Shut down 9 March 2026; the alias now redirects to gemini-3.1-pro-preview. */
public const MODEL_3_0_PRO = 'gemini-3-pro-preview';
public const MODEL_3_FLASH = 'gemini-3-flash-preview';
public const MODEL_3_PRO_IMAGE = 'gemini-3-pro-image-preview';
Expand Down Expand Up @@ -102,11 +120,13 @@ class GoogleProvider implements ProviderInterface, ImageProviderInterface, Embed
* @param string $apiKey Google AI API key for authentication
* @param string $defaultModel Gemini model to use when not specified in options
* @param int $defaultMaxTokens Maximum output tokens when not specified in options
* @param Effort|null $defaultEffort Thinking effort when none is given per call
*/
public function __construct(
private readonly string $apiKey,
private readonly string $defaultModel = self::MODEL_3_0_PRO,
private readonly string $defaultModel = self::MODEL_3_6_FLASH,
private readonly int $defaultMaxTokens = 8192,
private readonly ?Effort $defaultEffort = null,
) {
}

Expand Down Expand Up @@ -1027,9 +1047,110 @@ private function buildPayload(array $messages, array $options): array
}
}

// Reasoning effort maps to thinkingConfig, which has two knobs that must never both be
// sent: Gemini 3 and later take a thinking level, earlier models take a token budget.
$effort = $this->effortFor($options);

if ($effort !== null) {
$payload['generationConfig']['thinkingConfig'] = $this->thinkingConfigFor(
$effort,
(string) ($options['model'] ?? $this->defaultModel),
(int) $payload['generationConfig']['maxOutputTokens'],
);
}

return $payload;
}

/**
* The effort this request asks for: the per-call option, else the provider default.
*
* @param array<string, mixed> $options The caller's request options
*
* @throws UnknownEffortException When the level is not one core defines
*/
private function effortFor(array $options): ?Effort
{
if (!isset($options['effort'])) {
return $this->defaultEffort;
}

$level = (string) $options['effort'];

return Effort::tryFrom($level) ?? throw new UnknownEffortException($level);
}

/**
* Build the thinkingConfig block for a level of effort.
*
* The two knobs are mutually exclusive and the API errors when both are sent, so exactly one
* comes back. Gemini 3 still accepts `thinkingBudget` for backwards compatibility, but Google
* warns it behaves unpredictably on Pro, so the level knob is always used there.
*
* @param Effort $effort The level the caller asked for
* @param string $model The model this request targets, which decides the knob
* @param int $maxTokens The output ceiling this request will carry
*
* @return array{thinkingLevel: string}|array{thinkingBudget: int} One knob, never both
*/
private function thinkingConfigFor(Effort $effort, string $model, int $maxTokens): array
{
if ($this->takesThinkingLevel($model)) {
// Lowercase, matching every REST example Google publishes.
return ['thinkingLevel' => $effort->nearestOf($this->levelsFor($model))->value];
}

return ['thinkingBudget' => $this->budgetFor($effort, $model, $maxTokens)];
}

/**
* The thinking levels a Gemini 3 model accepts.
*
* **No Gemini 3 model can switch thinking off**, so `Effort::None` narrows to the shallowest
* level on offer rather than disabling anything. Pro does not accept MINIMAL at all, so its
* floor is LOW.
*
* @return non-empty-list<Effort>
*/
private function levelsFor(string $model): array
{
if (stripos($model, 'pro') !== false) {
return [Effort::Low, Effort::Medium, Effort::High];
}

return [Effort::Minimal, Effort::Low, Effort::Medium, Effort::High];
}

/**
* The thinking budget a pre-3 Gemini model accepts.
*
* Only the Flash families can genuinely disable thinking with a zero budget. Pro has a floor
* it will not go below, and every family has a ceiling well under a large maxTokens, so the
* neutral share is clamped into the range Gemini actually accepts.
*/
private function budgetFor(Effort $effort, string $model, int $maxTokens): int
{
$canDisable = stripos($model, 'flash') !== false;

if (!$effort->thinks() && $canDisable) {
return 0;
}

$budget = max(self::MIN_THINKING_BUDGET, $effort->budgetWithin($maxTokens));

return min($budget, self::MAX_THINKING_BUDGET);
}

/**
* Whether this model wants a thinking level rather than a thinking budget.
*
* Decided from the model name because the API offers no way to ask.
*/
private function takesThinkingLevel(string $model): bool
{
return preg_match('/gemini-([3-9]|\d{2,})/i', $model) === 1;
}

/**
* Convert a single PapiAI Message into Gemini's content format.
*
Expand Down
137 changes: 137 additions & 0 deletions tests/Unit/GoogleEffortTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?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\Effort;
use PapiAI\Core\Message;
use PapiAI\Google\GoogleProvider;

/**
* Captures the request payload so effort mapping can be asserted without HTTP.
*/
class TestableGoogleEffortProvider extends GoogleProvider
{
public array $lastPayload = [];

protected function request(string $url, array $payload): array
{
$this->lastPayload = $payload;

return ['candidates' => [['content' => ['parts' => [['text' => 'ok']]], 'finishReason' => 'STOP']]];
}
}

describe('GoogleProvider reasoning effort', function () {
beforeEach(function () {
$this->provider = new TestableGoogleEffortProvider('test-api-key');
$this->chat = fn (array $options) => $this->provider->chat([Message::user('hi')], $options);
$this->thinking = fn () => $this->provider->lastPayload['generationConfig']['thinkingConfig'] ?? [];
});

describe('Gemini 3, which takes a thinking level', function () {
it('uses the level knob, never the budget one', function () {
// Sending both is an error, and a budget on Gemini 3 Pro is documented as unreliable.
($this->chat)(['effort' => 'medium', 'model' => 'gemini-3-flash-preview']);

expect(($this->thinking)())->toHaveKey('thinkingLevel');
expect(($this->thinking)())->not->toHaveKey('thinkingBudget');
});

it('carries all four levels, not just two', function () {
$levels = [];

foreach (['minimal', 'low', 'medium', 'high'] as $level) {
($this->chat)(['effort' => $level, 'model' => 'gemini-3-flash-preview']);
$levels[] = ($this->thinking)()['thinkingLevel'];
}

expect($levels)->toBe(['minimal', 'low', 'medium', 'high']);
});

it('has no off switch, so none becomes the shallowest level available', function () {
// Gemini 3 cannot disable thinking. MINIMAL is the closest, and does not guarantee it.
($this->chat)(['effort' => 'none', 'model' => 'gemini-3-flash-preview']);

expect(($this->thinking)()['thinkingLevel'])->toBe('minimal');
});

it('keeps 3.1 Pro off MINIMAL, which it does not accept', function () {
foreach (['none', 'minimal'] as $level) {
($this->chat)(['effort' => $level, 'model' => 'gemini-3.1-pro']);

expect(($this->thinking)()['thinkingLevel'])->toBe('low');
}
});

it('narrows the levels above what Gemini offers', function () {
foreach (['extra-high', 'maximum'] as $level) {
($this->chat)(['effort' => $level, 'model' => 'gemini-3-flash-preview']);

expect(($this->thinking)()['thinkingLevel'])->toBe('high');
}
});
});

describe('Gemini 2.5, which takes a thinking budget', function () {
it('sets a budget that grows with effort', function () {
$budgets = [];

foreach (['low', 'medium', 'high'] as $level) {
($this->chat)(['effort' => $level, 'model' => 'gemini-2.5-flash', 'maxTokens' => 20_000]);
$budgets[] = ($this->thinking)()['thinkingBudget'];
}

expect($budgets[0])->toBeLessThan($budgets[1]);
expect($budgets[1])->toBeLessThan($budgets[2]);
});

it('stays inside the range Gemini accepts, however large the ceiling', function () {
($this->chat)(['effort' => 'maximum', 'model' => 'gemini-2.5-pro', 'maxTokens' => 200_000]);

expect(($this->thinking)()['thinkingBudget'])->toBeLessThanOrEqual(32_768);
});

it('disables thinking on Flash, which is the only family that can', function () {
($this->chat)(['effort' => 'none', 'model' => 'gemini-2.5-flash']);

expect(($this->thinking)()['thinkingBudget'])->toBe(0);
});

it('keeps 2.5 Pro above its floor, since it cannot disable thinking', function () {
($this->chat)(['effort' => 'none', 'model' => 'gemini-2.5-pro']);

expect(($this->thinking)()['thinkingBudget'])->toBeGreaterThanOrEqual(128);
});
});

it('sends nothing when the caller does not ask', function () {
($this->chat)(['model' => 'gemini-2.5-pro']);

expect($this->provider->lastPayload['generationConfig'])->not->toHaveKey('thinkingConfig');
});

it('rejects a level it does not recognise', function () {
expect(fn () => ($this->chat)(['effort' => 'enormous']))
->toThrow(InvalidArgumentException::class, 'enormous');
});

it('accepts a provider-level default the call can override', function () {
$provider = new TestableGoogleEffortProvider('k', 'gemini-3-flash-preview', 8192, Effort::High);

$provider->chat([Message::user('hi')], []);
expect($provider->lastPayload['generationConfig']['thinkingConfig']['thinkingLevel'])->toBe('high');

$provider->chat([Message::user('hi')], ['effort' => 'low']);
expect($provider->lastPayload['generationConfig']['thinkingConfig']['thinkingLevel'])->toBe('low');
});
});
2 changes: 1 addition & 1 deletion tests/Unit/GoogleProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ public function callThrowForStatusCode(int $httpCode, ?array $data): never
$this->provider->chat([Message::user('Hello')]);

expect($this->provider->lastPayload['generationConfig']['maxOutputTokens'])->toBe(8192);
expect($this->provider->lastUrl)->toContain('gemini-3-pro-preview');
expect($this->provider->lastUrl)->toContain('gemini-3.6-flash');
});

it('overrides model and options from parameters', function () {
Expand Down
9 changes: 9 additions & 0 deletions tests/Unit/GoogleToolChoiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

declare(strict_types=1);

use PapiAI\Core\Contracts\NamedToolSelectableInterface;
use PapiAI\Core\Contracts\ToolSelectableInterface;
use PapiAI\Core\Message;
use PapiAI\Google\GoogleProvider;

Expand Down Expand Up @@ -88,3 +90,10 @@ protected function request(string $url, array $payload): array
->toThrow(InvalidArgumentException::class);
});
});

describe('GoogleProvider tool-selection capability', function () {
it('declares what it can force, so callers can ask instead of catching', function () {
expect(is_subclass_of(GoogleProvider::class, NamedToolSelectableInterface::class))->toBeTrue();
expect(is_subclass_of(GoogleProvider::class, ToolSelectableInterface::class))->toBeTrue();
});
});
Loading