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
29 changes: 27 additions & 2 deletions src/CohereProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -62,13 +63,14 @@ public function __construct(
* Send a chat completion request to the Cohere v2 API.
*
* @param array<Message> $messages Conversation messages
* @param array<string, mixed> $options Options including model, maxTokens, temperature, stopSequences, and tools
* @param array<string, mixed> $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
{
Expand Down Expand Up @@ -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;
}

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