diff --git a/composer.json b/composer.json index 995b2ec..99fe5cf 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ ], "require": { "php": "^8.2", - "papi-ai/papi-core": "^0.9", + "papi-ai/papi-core": "^0.13", "ext-curl": "*" }, "require-dev": { diff --git a/src/DeepSeekProvider.php b/src/DeepSeekProvider.php index 2c1d335..518de6a 100644 --- a/src/DeepSeekProvider.php +++ b/src/DeepSeekProvider.php @@ -24,6 +24,7 @@ use PapiAI\Core\Role; use PapiAI\Core\StreamChunk; use PapiAI\Core\ToolCall; +use PapiAI\Core\ToolChoice; use RuntimeException; /** @@ -41,6 +42,8 @@ * - deepseek-reasoner (reasoning) * * @see https://api-docs.deepseek.com/ + * + * @psalm-import-type ChatOptions from ProviderInterface */ class DeepSeekProvider implements ProviderInterface { @@ -70,14 +73,7 @@ public function __construct( * and parses the response back into a core Response object. * * @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 * @@ -208,6 +204,21 @@ private function buildPayload(array $messages, array $options): array $payload['tools'] = $this->convertTools($options['tools']); } + // Forced tool choice (OpenAI-compatible). 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'])) { + $payload['tool_choice'] = $choice->toolName !== null + ? ['type' => 'function', 'function' => ['name' => $choice->toolName]] + : match ($choice->mode) { + ToolChoice::NONE => 'none', + ToolChoice::REQUIRED => 'required', + default => 'auto', + }; + } + } + return $payload; } diff --git a/tests/Unit/DeepSeekToolChoiceTest.php b/tests/Unit/DeepSeekToolChoiceTest.php new file mode 100644 index 0000000..713f173 --- /dev/null +++ b/tests/Unit/DeepSeekToolChoiceTest.php @@ -0,0 +1,69 @@ + + * + * 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\DeepSeek\DeepSeekProvider; + +/** + * Captures the request payload so tool-choice mapping can be asserted without HTTP. + */ +class TestableDeepSeekToolChoiceProvider extends DeepSeekProvider +{ + public array $lastPayload = []; + + protected function request(array $payload): array + { + $this->lastPayload = $payload; + + return ['choices' => [['message' => ['role' => 'assistant', 'content' => 'ok'], 'finish_reason' => 'stop']]]; + } +} + +describe('DeepSeekProvider tool choice', function () { + beforeEach(function () { + $this->provider = new TestableDeepSeekToolChoiceProvider('test-api-key'); + $this->tools = [ + ['name' => 'get_weather', 'description' => 'Weather', 'parameters' => ['type' => 'object']], + ]; + }); + + it('maps auto/none/required to the OpenAI-compatible strings', function () { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'auto']); + expect($this->provider->lastPayload['tool_choice'])->toBe('auto'); + + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'none']); + expect($this->provider->lastPayload['tool_choice'])->toBe('none'); + + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => 'required']); + expect($this->provider->lastPayload['tool_choice'])->toBe('required'); + }); + + it('maps a specific tool to the function form', function () { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools, 'toolChoice' => ['name' => 'get_weather']]); + + expect($this->provider->lastPayload['tool_choice'])->toBe(['type' => 'function', 'function' => ['name' => 'get_weather']]); + }); + + it('emits no tool_choice when absent (backward compatible)', function () { + $this->provider->chat([Message::user('hi')], ['tools' => $this->tools]); + + expect($this->provider->lastPayload)->not->toHaveKey('tool_choice'); + }); + + it('throws for an unenforceable choice, before any HTTP call', function () { + expect(fn () => $this->provider->chat([Message::user('hi')], ['toolChoice' => 'required'])) + ->toThrow(InvalidArgumentException::class); + expect($this->provider->lastPayload)->toBe([]); + }); +});