diff --git a/composer.json b/composer.json index 2962a28..e9a267a 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": "^8.2", - "papi-ai/papi-core": "^0.14", + "papi-ai/papi-core": "^0.15", "ext-curl": "*" }, "require-dev": { diff --git a/src/GoogleProvider.php b/src/GoogleProvider.php index cb78b32..f945596 100644 --- a/src/GoogleProvider.php +++ b/src/GoogleProvider.php @@ -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; @@ -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'; @@ -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, ) { } @@ -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 $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 + */ + 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. * diff --git a/tests/Unit/GoogleEffortTest.php b/tests/Unit/GoogleEffortTest.php new file mode 100644 index 0000000..7111c9a --- /dev/null +++ b/tests/Unit/GoogleEffortTest.php @@ -0,0 +1,137 @@ + + * + * 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'); + }); +}); diff --git a/tests/Unit/GoogleProviderTest.php b/tests/Unit/GoogleProviderTest.php index 6adcf03..3c8d280 100644 --- a/tests/Unit/GoogleProviderTest.php +++ b/tests/Unit/GoogleProviderTest.php @@ -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 () { diff --git a/tests/Unit/GoogleToolChoiceTest.php b/tests/Unit/GoogleToolChoiceTest.php index f69d8d3..f0e78ce 100644 --- a/tests/Unit/GoogleToolChoiceTest.php +++ b/tests/Unit/GoogleToolChoiceTest.php @@ -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; @@ -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(); + }); +});