diff --git a/src/CohereProvider.php b/src/CohereProvider.php index 1d97609..5db3f42 100644 --- a/src/CohereProvider.php +++ b/src/CohereProvider.php @@ -26,6 +26,7 @@ use PapiAI\Core\Role; use PapiAI\Core\StreamChunk; use PapiAI\Core\ToolCall; +use PapiAI\Core\ToolChoice; /** * Cohere API provider for PapiAI. @@ -62,13 +63,14 @@ public function __construct( * Send a chat completion request to the Cohere v2 API. * * @param array $messages Conversation messages - * @param array $options Options including model, maxTokens, temperature, stopSequences, and tools + * @param array $options Options including model, maxTokens, temperature, stopSequences, + * tools, and toolChoice ("auto", "none" or "required"; naming a specific tool is not supported here) * * @return Response Parsed response with text, tool calls, and usage * * @throws AuthenticationException When the API key is invalid * @throws RateLimitException When rate limits are exceeded - * @throws ProviderException When the API returns an error + * @throws ProviderException When the API returns an error, or toolChoice names a specific tool */ public function chat(array $messages, array $options = []): Response { @@ -248,6 +250,29 @@ private function buildPayload(array $messages, array $options): array $payload['tools'] = $this->convertTools($options['tools']); } + // Forced tool choice. Cohere v2 takes uppercase REQUIRED or NONE, and has no mechanism for + // forcing one *named* tool, so that case fails loudly rather than quietly downgrading to + // "some tool". Omitting the field is how Cohere spells auto: the documented default is that + // the model chooses freely, and there is no AUTO value to send. + // Note: tool_choice needs command-r7b or newer, so an older model will reject it upstream. + if (isset($options['toolChoice'])) { + $choice = ToolChoice::fromOption($options['toolChoice'], $options['tools'] ?? []); + + if ($choice->forcesSpecificTool()) { + throw new ProviderException( + sprintf( + 'Cohere cannot force a specific tool; tool_choice accepts only REQUIRED or NONE. Use "required" to insist on a tool call, or drop to a provider that supports naming "%s".', + (string) $choice->toolName, + ), + $this->getName(), + ); + } + + if (!empty($options['tools']) && !$choice->isAuto()) { + $payload['tool_choice'] = $choice->mode === ToolChoice::NONE ? 'NONE' : 'REQUIRED'; + } + } + return $payload; } diff --git a/tests/Unit/CohereToolChoiceTest.php b/tests/Unit/CohereToolChoiceTest.php new file mode 100644 index 0000000..41e1cc9 --- /dev/null +++ b/tests/Unit/CohereToolChoiceTest.php @@ -0,0 +1,96 @@ + + * + * 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\Cohere\CohereProvider; +use PapiAI\Core\Exception\ProviderException; +use PapiAI\Core\Message; + +/** + * Captures the request payload so tool-choice mapping can be asserted without HTTP. + */ +class TestableCohereToolChoiceProvider extends CohereProvider +{ + public array $lastPayload = []; + + protected function request(array $payload): array + { + $this->lastPayload = $payload; + + return ['message' => ['content' => [['text' => 'ok']]], 'finish_reason' => 'COMPLETE']; + } +} + +describe('CohereProvider tool choice', function () { + beforeEach(function () { + $this->provider = new TestableCohereToolChoiceProvider('test-api-key'); + $this->tools = [ + ['name' => 'get_weather', 'description' => 'Weather', 'parameters' => ['type' => 'object']], + ]; + $this->chat = fn (array $options) => $this->provider->chat([Message::user('hi')], $options); + }); + + it('uses Cohere\'s uppercase spellings', function () { + ($this->chat)(['tools' => $this->tools, 'toolChoice' => 'required']); + expect($this->provider->lastPayload['tool_choice'])->toBe('REQUIRED'); + + ($this->chat)(['tools' => $this->tools, 'toolChoice' => 'none']); + expect($this->provider->lastPayload['tool_choice'])->toBe('NONE'); + }); + + it('sends nothing for auto, which is how Cohere spells the default', function () { + // Cohere has no AUTO value: the documented behaviour is that omitting the field lets the + // model choose. Sending an invented value would be rejected upstream. + ($this->chat)(['tools' => $this->tools, 'toolChoice' => 'auto']); + + expect($this->provider->lastPayload)->not->toHaveKey('tool_choice'); + }); + + it('emits nothing when toolChoice is absent (backward compatible)', function () { + ($this->chat)(['tools' => $this->tools]); + + expect($this->provider->lastPayload)->not->toHaveKey('tool_choice'); + }); + + it('refuses to fake forcing a specific tool', function () { + // Cohere's tool_choice cannot name a tool. Downgrading to REQUIRED would satisfy the request + // shape while breaking the caller's guarantee, so it fails loudly instead. + expect(fn () => ($this->chat)(['tools' => $this->tools, 'toolChoice' => ['name' => 'get_weather']])) + ->toThrow(ProviderException::class, 'cannot force a specific tool'); + }); + + it('names the tool it could not force, so the message is actionable', function () { + expect(fn () => ($this->chat)(['tools' => $this->tools, 'toolChoice' => ['name' => 'get_weather']])) + ->toThrow(ProviderException::class, 'get_weather'); + }); + + it('fails before any HTTP call', function () { + try { + ($this->chat)(['tools' => $this->tools, 'toolChoice' => ['name' => 'get_weather']]); + } catch (ProviderException) { + // expected + } + + expect($this->provider->lastPayload)->toBe([]); + }); + + it('throws for an unknown toolChoice value', function () { + expect(fn () => ($this->chat)(['tools' => $this->tools, 'toolChoice' => 'always'])) + ->toThrow(InvalidArgumentException::class); + }); + + it('throws when required is asked for with no tools declared', function () { + expect(fn () => ($this->chat)(['toolChoice' => 'required'])) + ->toThrow(InvalidArgumentException::class); + }); +});