From 082455dc35b4e53c1dc99e278df922d57e380735 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 29 Jul 2026 22:59:45 +0100 Subject: [PATCH] Map forced tool choice onto the Cohere v2 API Cohere was left out of the v0.13 toolChoice release, so it accepted the option and silently dropped it. Cohere spells the modes in uppercase, REQUIRED and NONE, and has no value for auto: the documented default is that omitting the field lets the model choose, so auto sends nothing rather than an invented value. It also cannot force one *named* tool. Downgrading that to REQUIRED would satisfy the request shape while breaking the caller's guarantee, so it throws a ProviderException naming the tool it could not force, the same way Ollama refuses forced choice outright. --- src/CohereProvider.php | 29 ++++++++- tests/Unit/CohereToolChoiceTest.php | 96 +++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/CohereToolChoiceTest.php 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); + }); +});