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.12",
"papi-ai/papi-core": "^0.13",
"ext-curl": "*"
},
"require-dev": {
Expand Down
42 changes: 26 additions & 16 deletions src/GoogleProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -115,14 +118,7 @@ public function __construct(
* vision, structured output, and custom generation parameters.
*
* @param array<Message> $messages Conversation history as PapiAI Message objects
* @param array{
* model?: string,
* tools?: array,
* maxTokens?: int,
* temperature?: float,
* stopSequences?: array<string>,
* 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
*
Expand Down Expand Up @@ -956,14 +952,7 @@ public function generateImageToFile(string $prompt, string $outputPath, array $o
* options (maxTokens, temperature, stop sequences, JSON schema, tools).
*
* @param array<Message> $messages Conversation messages to convert
* @param array{
* model?: string,
* tools?: array,
* maxTokens?: int,
* temperature?: float,
* stopSequences?: array<string>,
* 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
*/
Expand Down Expand Up @@ -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;
}

Expand Down
90 changes: 90 additions & 0 deletions tests/Unit/GoogleToolChoiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?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\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);
});
});
Loading