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.9",
"papi-ai/papi-core": "^0.13",
"ext-curl": "*"
},
"require-dev": {
Expand Down
27 changes: 19 additions & 8 deletions src/DeepSeekProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use PapiAI\Core\Role;
use PapiAI\Core\StreamChunk;
use PapiAI\Core\ToolCall;
use PapiAI\Core\ToolChoice;
use RuntimeException;

/**
Expand All @@ -41,6 +42,8 @@
* - deepseek-reasoner (reasoning)
*
* @see https://api-docs.deepseek.com/
*
* @psalm-import-type ChatOptions from ProviderInterface
*/
class DeepSeekProvider implements ProviderInterface
{
Expand Down Expand Up @@ -70,14 +73,7 @@ public function __construct(
* and parses the response back into a core Response object.
*
* @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 @@ -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;
}

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