From 880e8faaf41d99f5f0baccf3469baf9a467c198f Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 26 Jul 2026 00:04:10 +0100 Subject: [PATCH] Honour the toolChoice option (fail loud where unenforceable); require papi-core ^0.13 --- composer.json | 2 +- src/GoogleProvider.php | 42 +++++++++----- tests/Unit/GoogleToolChoiceTest.php | 90 +++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/GoogleToolChoiceTest.php diff --git a/composer.json b/composer.json index fbec480..348df45 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": "^8.2", - "papi-ai/papi-core": "^0.12", + "papi-ai/papi-core": "^0.13", "ext-curl": "*" }, "require-dev": { diff --git a/src/GoogleProvider.php b/src/GoogleProvider.php index 1cf4f62..cb78b32 100644 --- a/src/GoogleProvider.php +++ b/src/GoogleProvider.php @@ -29,6 +29,7 @@ use PapiAI\Core\Role; use PapiAI\Core\StreamChunk; use PapiAI\Core\ToolCall; +use PapiAI\Core\ToolChoice; use PapiAI\Core\VideoResponse; use RuntimeException; @@ -60,6 +61,8 @@ * - imagen-4.0-fast-generate-001, imagen-3.0-capability-001 * * @see https://ai.google.dev/gemini-api/docs + * + * @psalm-import-type ChatOptions from ProviderInterface */ class GoogleProvider implements ProviderInterface, ImageProviderInterface, EmbeddingProviderInterface, VideoProviderInterface { @@ -115,14 +118,7 @@ public function __construct( * vision, structured output, and custom generation parameters. * * @param array $messages Conversation history as PapiAI Message objects - * @param array{ - * model?: string, - * tools?: array, - * maxTokens?: int, - * temperature?: float, - * stopSequences?: array, - * outputSchema?: array, - * } $options Request options (model, tools, maxTokens, temperature, etc.) + * @param ChatOptions $options Request options (model, tools, maxTokens, temperature, toolChoice, etc.) * * @return Response Parsed response containing text, tool calls, usage, and stop reason * @@ -956,14 +952,7 @@ public function generateImageToFile(string $prompt, string $outputPath, array $o * options (maxTokens, temperature, stop sequences, JSON schema, tools). * * @param array $messages Conversation messages to convert - * @param array{ - * model?: string, - * tools?: array, - * maxTokens?: int, - * temperature?: float, - * stopSequences?: array, - * outputSchema?: array, - * } $options Request options controlling generation behavior + * @param ChatOptions $options Request options controlling generation behavior * * @return array The complete Gemini API payload ready for JSON encoding */ @@ -1017,6 +1006,27 @@ private function buildPayload(array $messages, array $options): array ]; } + // Handle forced tool choice. Validation lives in core and throws before any HTTP call. + if (isset($options['toolChoice'])) { + $choice = ToolChoice::fromOption($options['toolChoice'], $options['tools'] ?? []); + + if (!empty($options['tools'])) { + $config = [ + 'mode' => match ($choice->mode) { + ToolChoice::NONE => 'NONE', + ToolChoice::REQUIRED => 'ANY', + default => 'AUTO', + }, + ]; + + if ($choice->toolName !== null) { + $config['allowedFunctionNames'] = [$choice->toolName]; + } + + $payload['toolConfig'] = ['functionCallingConfig' => $config]; + } + } + return $payload; } diff --git a/tests/Unit/GoogleToolChoiceTest.php b/tests/Unit/GoogleToolChoiceTest.php new file mode 100644 index 0000000..f69d8d3 --- /dev/null +++ b/tests/Unit/GoogleToolChoiceTest.php @@ -0,0 +1,90 @@ + + * + * 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\Message; +use PapiAI\Google\GoogleProvider; + +/** + * Captures the request payload so tool-choice mapping can be asserted without HTTP. + */ +class TestableGoogleToolChoiceProvider 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 tool choice', function () { + beforeEach(function () { + $this->provider = new TestableGoogleToolChoiceProvider('test-api-key'); + $this->tools = [ + ['name' => 'get_weather', 'description' => 'Weather', 'parameters' => ['type' => 'object']], + ]; + }); + + $callConfig = function ($provider) { + return $provider->lastPayload['toolConfig']['functionCallingConfig'] ?? null; + }; + + it('maps auto to AUTO', function () use ($callConfig) { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'auto']); + + expect($callConfig($this->provider))->toBe(['mode' => 'AUTO']); + }); + + it('maps none to NONE', function () use ($callConfig) { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'none']); + + expect($callConfig($this->provider))->toBe(['mode' => 'NONE']); + }); + + it('maps required to ANY without allowedFunctionNames', function () use ($callConfig) { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'required']); + + expect($callConfig($this->provider))->toBe(['mode' => 'ANY']); + }); + + it('maps a specific tool to ANY + allowedFunctionNames', function () use ($callConfig) { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => ['name' => 'get_weather']]); + + expect($callConfig($this->provider))->toBe(['mode' => 'ANY', 'allowedFunctionNames' => ['get_weather']]); + }); + + it('emits no toolConfig when toolChoice is absent (backward compatible)', function () { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools]); + + expect($this->provider->lastPayload)->not->toHaveKey('toolConfig'); + }); + + it('throws for required with no tools, before any HTTP call', function () { + expect(fn () => $this->provider->chat([Message::user('hi')], ['toolChoice' => 'required'])) + ->toThrow(InvalidArgumentException::class); + expect($this->provider->lastPayload)->toBe([]); + }); + + it('throws for an unknown tool name', function () { + expect(fn () => $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => ['name' => 'nope']])) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws for an unknown toolChoice value', function () { + expect(fn () => $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'always'])) + ->toThrow(InvalidArgumentException::class); + }); +});