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
25 changes: 17 additions & 8 deletions src/Contracts/ProviderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,30 @@
*
* Every AI provider (Anthropic, OpenAI, etc.) must implement this interface
* to participate in the PapiAI ecosystem. Handles both synchronous and streaming chat.
*
* The chat options bag is defined once here as a reusable Psalm type; providers import it with
* `@psalm-import-type ChatOptions from ProviderInterface` so a new option never drifts across
* per-provider docblocks. `toolChoice` forces the answer channel: "auto" (the default when absent),
* "none", "required", or `["name" => "<tool>"]` to force a specific tool. Providers validate it
* (see PapiAI\Core\ToolChoice) and throw before any HTTP call when it cannot be met.
*
* @psalm-type ChatOptions = array{
* model?: string,
* tools?: array,
* maxTokens?: int,
* temperature?: float,
* stopSequences?: array<string>,
* outputSchema?: array,
* toolChoice?: string|array{name: string},
* }
*/
interface ProviderInterface
{
/**
* Send a chat completion request.
*
* @param array<Message> $messages The conversation messages
* @param array{
* model?: string,
* tools?: array,
* maxTokens?: int,
* temperature?: float,
* stopSequences?: array<string>,
* outputSchema?: array,
* } $options Request options
* @param ChatOptions $options Request options (see the class docblock, incl. toolChoice)
*
* @return Response The completed response with text, tool calls, and usage stats
*/
Expand Down
138 changes: 138 additions & 0 deletions src/ToolChoice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?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);

namespace PapiAI\Core;

use InvalidArgumentException;

/**
* Normalised, validated representation of the provider-agnostic `toolChoice` option.
*
* Callers pass `toolChoice` to a provider's chat() as one of:
* - `'auto'` the model decides (the default when the option is absent)
* - `'none'` the model must not call tools
* - `'required'` the model must call one of the declared tools
* - `['name' => '<tool>']` the model must call exactly that tool
*
* Each provider calls {@see self::fromOption()} to validate the value against the declared tools
* (failing loudly, before any HTTP call) and then maps the normalised `mode`/`toolName` to its own
* API mechanism. Keeping validation here guarantees identical semantics across every provider.
*/
final class ToolChoice
{
public const AUTO = 'auto';
public const NONE = 'none';
public const REQUIRED = 'required';

/**
* @param string $mode One of AUTO, NONE, REQUIRED
* @param string|null $toolName The specific tool to force, when the caller named one
*/
private function __construct(
public readonly string $mode,
public readonly ?string $toolName = null,
) {
}

/**
* Validate and normalise a raw `toolChoice` option against the declared tools.
*
* @param string|array<string, mixed> $value The raw toolChoice option
* @param array<int, array<string, mixed>> $tools The declared tool definitions (each with a `name`)
*
* @throws InvalidArgumentException On an unknown value; on `none`/`required`/specific with no tools
* declared; or when a named tool is not among the declared tools
*/
public static function fromOption(string|array $value, array $tools): self
{
if (is_string($value)) {
if (!in_array($value, [self::AUTO, self::NONE, self::REQUIRED], true)) {
throw new InvalidArgumentException(sprintf(
'Unknown toolChoice "%s". Expected "auto", "none", "required", or ["name" => "<tool>"].',
$value,
));
}

$mode = $value;
$toolName = null;
} else {
$name = $value['name'] ?? null;
if (!is_string($name) || $name === '') {
throw new InvalidArgumentException('Invalid toolChoice array. Expected ["name" => "<tool>"].');
}

$mode = self::REQUIRED;
$toolName = $name;
}

if ($mode !== self::AUTO && $tools === []) {
throw new InvalidArgumentException(sprintf(
'toolChoice "%s" requires at least one declared tool, but none were provided.',
$toolName ?? $mode,
));
}

if ($toolName !== null) {
$names = self::toolNames($tools);
if (!in_array($toolName, $names, true)) {
throw new InvalidArgumentException(sprintf(
'toolChoice names tool "%s", which is not among the declared tools (%s).',
$toolName,
$names === [] ? 'none' : implode(', ', $names),
));
}
}

return new self($mode, $toolName);
}

/**
* Whether this is the default, model-decides choice.
*
* @return bool True for auto with no forced tool
*/
public function isAuto(): bool
{
return $this->mode === self::AUTO && $this->toolName === null;
}

/**
* Whether a specific tool is being forced.
*
* @return bool True when a tool name was given
*/
public function forcesSpecificTool(): bool
{
return $this->toolName !== null;
}

/**
* Extract the declared tool names.
*
* @param array<int, array<string, mixed>> $tools
*
* @return list<string>
*/
private static function toolNames(array $tools): array
{
$names = [];
foreach ($tools as $tool) {
if (isset($tool['name']) && is_string($tool['name'])) {
$names[] = $tool['name'];
}
}

return $names;
}
}
75 changes: 75 additions & 0 deletions tests/Unit/ToolChoiceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

use PapiAI\Core\ToolChoice;

$tools = [
['name' => 'get_weather', 'description' => 'Weather', 'parameters' => []],
['name' => 'search', 'description' => 'Search', 'parameters' => []],
];

describe('ToolChoice', function () use ($tools) {
it('normalises auto', function () {
$choice = ToolChoice::fromOption('auto', []);

expect($choice->mode)->toBe(ToolChoice::AUTO);
expect($choice->toolName)->toBeNull();
expect($choice->isAuto())->toBeTrue();
expect($choice->forcesSpecificTool())->toBeFalse();
});

it('normalises none', function () use ($tools) {
expect(ToolChoice::fromOption('none', $tools)->mode)->toBe(ToolChoice::NONE);
});

it('normalises required', function () use ($tools) {
$choice = ToolChoice::fromOption('required', $tools);

expect($choice->mode)->toBe(ToolChoice::REQUIRED);
expect($choice->toolName)->toBeNull();
expect($choice->forcesSpecificTool())->toBeFalse();
});

it('normalises a specific tool to required + name', function () use ($tools) {
$choice = ToolChoice::fromOption(['name' => 'search'], $tools);

expect($choice->mode)->toBe(ToolChoice::REQUIRED);
expect($choice->toolName)->toBe('search');
expect($choice->forcesSpecificTool())->toBeTrue();
});

describe('validation (fails loud, before any HTTP)', function () use ($tools) {
it('rejects an unknown string value', function () use ($tools) {
expect(fn () => ToolChoice::fromOption('always', $tools))
->toThrow(InvalidArgumentException::class, 'Unknown toolChoice');
});

it('rejects a malformed array', function () use ($tools) {
expect(fn () => ToolChoice::fromOption(['tool' => 'search'], $tools))
->toThrow(InvalidArgumentException::class, 'Invalid toolChoice array');
expect(fn () => ToolChoice::fromOption(['name' => ''], $tools))
->toThrow(InvalidArgumentException::class);
});

it('rejects required with no tools declared', function () {
expect(fn () => ToolChoice::fromOption('required', []))
->toThrow(InvalidArgumentException::class, 'requires at least one declared tool');
});

it('rejects none with no tools declared', function () {
expect(fn () => ToolChoice::fromOption('none', []))
->toThrow(InvalidArgumentException::class, 'requires at least one declared tool');
});

it('rejects a specific tool with no tools declared', function () {
expect(fn () => ToolChoice::fromOption(['name' => 'search'], []))
->toThrow(InvalidArgumentException::class);
});

it('rejects a specific tool name not among the declared tools', function () use ($tools) {
expect(fn () => ToolChoice::fromOption(['name' => 'unknown_tool'], $tools))
->toThrow(InvalidArgumentException::class, 'not among the declared tools');
});
});
});
Loading