diff --git a/src/Contracts/ProviderInterface.php b/src/Contracts/ProviderInterface.php index 3e12c9b..d88f3a5 100644 --- a/src/Contracts/ProviderInterface.php +++ b/src/Contracts/ProviderInterface.php @@ -23,6 +23,22 @@ * * 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" => ""]` 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, + * outputSchema?: array, + * toolChoice?: string|array{name: string}, + * } */ interface ProviderInterface { @@ -30,14 +46,7 @@ interface ProviderInterface * Send a chat completion request. * * @param array $messages The conversation messages - * @param array{ - * model?: string, - * tools?: array, - * maxTokens?: int, - * temperature?: float, - * stopSequences?: array, - * 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 */ diff --git a/src/ToolChoice.php b/src/ToolChoice.php new file mode 100644 index 0000000..fdde4f4 --- /dev/null +++ b/src/ToolChoice.php @@ -0,0 +1,138 @@ + + * + * 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' => '']` 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 $value The raw toolChoice option + * @param array> $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" => ""].', + $value, + )); + } + + $mode = $value; + $toolName = null; + } else { + $name = $value['name'] ?? null; + if (!is_string($name) || $name === '') { + throw new InvalidArgumentException('Invalid toolChoice array. Expected ["name" => ""].'); + } + + $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> $tools + * + * @return list + */ + 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; + } +} diff --git a/tests/Unit/ToolChoiceTest.php b/tests/Unit/ToolChoiceTest.php new file mode 100644 index 0000000..4b31673 --- /dev/null +++ b/tests/Unit/ToolChoiceTest.php @@ -0,0 +1,75 @@ + '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'); + }); + }); +});