From 30fcf88ee3c8ca37e3a7808472cb14bef5fbe5c9 Mon Sep 17 00:00:00 2001 From: Denis Krestinin Date: Thu, 9 Jul 2026 14:27:27 +0200 Subject: [PATCH 1/3] Forward attachment provider options to OpenAI text generation requests (#768) * Forward attachment provider options to OpenAI text generation requests * Prevent attachment provider options from overwriting structural keys --------- Co-authored-by: Pushpak Chhajed --- .../OpenAi/Concerns/BuildsTextRequests.php | 2 +- .../OpenAi/Concerns/MapsAttachments.php | 15 ++- src/Gateway/OpenAi/Concerns/MapsMessages.php | 9 +- .../AzureOpenAi/MessageMappingTest.php | 28 ++++ .../Providers/OpenAi/MessageMappingTest.php | 125 ++++++++++++++++++ 5 files changed, 171 insertions(+), 8 deletions(-) diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index aa983b800..0cf935fdd 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -25,7 +25,7 @@ protected function buildTextRequestBody( ): array { $body = [ 'model' => $model, - 'input' => $this->mapMessagesToInput($messages, $instructions), + 'input' => $this->mapMessagesToInput($messages, $instructions, $provider), ]; return $this->mergeSharedResponsesRequestOptions($body, $tools, $schema, $options, $provider); diff --git a/src/Gateway/OpenAi/Concerns/MapsAttachments.php b/src/Gateway/OpenAi/Concerns/MapsAttachments.php index e8076a10e..63e5b071b 100644 --- a/src/Gateway/OpenAi/Concerns/MapsAttachments.php +++ b/src/Gateway/OpenAi/Concerns/MapsAttachments.php @@ -6,6 +6,8 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; +use Laravel\Ai\Contracts\HasProviderOptions; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\Files\Base64Document; use Laravel\Ai\Files\Base64Image; use Laravel\Ai\Files\File; @@ -17,22 +19,25 @@ use Laravel\Ai\Files\RemoteImage; use Laravel\Ai\Files\StoredDocument; use Laravel\Ai\Files\StoredImage; +use Laravel\Ai\Providers\Provider; trait MapsAttachments { /** * Map the given Laravel attachments to OpenAI content parts. */ - protected function mapAttachments(Collection $attachments): array + protected function mapAttachments(Collection $attachments, Provider $provider): array { - return $attachments->map(function ($attachment) { + $providerKey = Lab::tryFrom($provider->driver()) ?? $provider->driver(); + + return $attachments->map(function ($attachment) use ($providerKey) { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( 'Unsupported attachment type ['.get_class($attachment).']' ); } - return match (true) { + $part = match (true) { $attachment instanceof ProviderImage => [ 'type' => 'input_image', 'file_id' => $attachment->id, @@ -92,6 +97,10 @@ protected function mapAttachments(Collection $attachments): array ], default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), }; + + return $attachment instanceof HasProviderOptions + ? array_merge($attachment->providerOptions($providerKey), $part) + : $part; })->all(); } diff --git a/src/Gateway/OpenAi/Concerns/MapsMessages.php b/src/Gateway/OpenAi/Concerns/MapsMessages.php index 19e025a5f..c8e051294 100644 --- a/src/Gateway/OpenAi/Concerns/MapsMessages.php +++ b/src/Gateway/OpenAi/Concerns/MapsMessages.php @@ -8,13 +8,14 @@ use Laravel\Ai\Messages\MessageRole; use Laravel\Ai\Messages\ToolResultMessage; use Laravel\Ai\Messages\UserMessage; +use Laravel\Ai\Providers\Provider; trait MapsMessages { /** * Map the given Laravel messages to OpenAI Responses API input format. */ - protected function mapMessagesToInput(array $messages, ?string $instructions = null): array + protected function mapMessagesToInput(array $messages, ?string $instructions, Provider $provider): array { $input = []; @@ -29,7 +30,7 @@ protected function mapMessagesToInput(array $messages, ?string $instructions = n $message = Message::tryFrom($message); match ($message->role) { - MessageRole::User => $this->mapUserMessage($message, $input), + MessageRole::User => $this->mapUserMessage($message, $input, $provider), MessageRole::Assistant => $this->mapAssistantMessage($message, $input), MessageRole::ToolResult => $this->mapToolResultMessage($message, $input), }; @@ -41,14 +42,14 @@ protected function mapMessagesToInput(array $messages, ?string $instructions = n /** * Map a user message to OpenAI format. */ - protected function mapUserMessage(UserMessage|Message $message, array &$input): void + protected function mapUserMessage(UserMessage|Message $message, array &$input, Provider $provider): void { $content = [ ['type' => 'input_text', 'text' => $message->content], ]; if ($message instanceof UserMessage && $message->attachments->isNotEmpty()) { - $content = array_merge($content, $this->mapAttachments($message->attachments)); + $content = array_merge($content, $this->mapAttachments($message->attachments, $provider)); } $input[] = [ diff --git a/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php b/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php index e53e1360a..cf30e73c0 100644 --- a/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php @@ -2,6 +2,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\Files\Base64Document; use Laravel\Ai\Files\Base64Image; use Tests\Fixtures\Agents\AssistantAgent; @@ -124,6 +125,33 @@ }); }); +test('attachment provider options closure receives the azure provider', function () { + Http::fake([ + 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse('I see an image'), + ]); + + $image = (new Base64Image(base64_encode('fake-image-data'), 'image/png')) + ->withProviderOptions(fn (Lab $provider) => match ($provider) { + Lab::Azure => ['detail' => 'low'], + default => ['detail' => 'high'], + }); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [$image], + provider: 'azure', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); + + return $imageBlock !== null + && ($imageBlock['detail'] ?? null) === 'low'; + }); +}); + test('document attachment maps to input_file content block', function () { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse('I see a PDF'), diff --git a/tests/Feature/Providers/OpenAi/MessageMappingTest.php b/tests/Feature/Providers/OpenAi/MessageMappingTest.php index 3ae2272ec..fe20fab64 100644 --- a/tests/Feature/Providers/OpenAi/MessageMappingTest.php +++ b/tests/Feature/Providers/OpenAi/MessageMappingTest.php @@ -3,6 +3,7 @@ use Illuminate\Http\Client\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; +use Laravel\Ai\Enums\Lab; use Laravel\Ai\Files; use Laravel\Ai\Files\Base64Document; use Laravel\Ai\Files\LocalImage; @@ -320,6 +321,130 @@ }); }); +test('image attachment provider options are forwarded to the content part', function () { + Http::fake([ + 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), + ]); + + $image = (new LocalImage(__DIR__.'/../../../Fixtures/Images/red.png')) + ->withProviderOptions(['detail' => 'low']); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [$image], + provider: 'openai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); + + return $imageBlock !== null + && ($imageBlock['detail'] ?? null) === 'low' + && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,'); + }); +}); + +test('document attachment provider options are forwarded to the content part', function () { + Http::fake([ + 'api.openai.com/*' => fakeOpenAiResponse('I see a document'), + ]); + + $document = Files\Document::fromString('hello world', 'text/plain') + ->withProviderOptions(['detail' => 'high']); + + agent('You are helpful.')->prompt( + 'Read this.', + attachments: [$document], + provider: 'openai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $fileBlock = collect($userMessage['content'])->firstWhere('type', 'input_file'); + + return $fileBlock !== null + && ($fileBlock['detail'] ?? null) === 'high' + && str_contains($fileBlock['file_data'], base64_encode('hello world')); + }); +}); + +test('attachment provider options resolve from a closure scoped to the provider', function () { + Http::fake([ + 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), + ]); + + $image = (new LocalImage(__DIR__.'/../../../Fixtures/Images/red.png')) + ->withProviderOptions(fn (Lab $provider) => match ($provider) { + Lab::OpenAI => ['detail' => 'low'], + default => [], + }); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [$image], + provider: 'openai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); + + return $imageBlock !== null + && ($imageBlock['detail'] ?? null) === 'low'; + }); +}); + +test('attachments without provider options map unchanged', function () { + Http::fake([ + 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), + ]); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [new LocalImage(__DIR__.'/../../../Fixtures/Images/red.png')], + provider: 'openai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); + + return $imageBlock !== null + && ! array_key_exists('detail', $imageBlock); + }); +}); + +test('provider options cannot overwrite the mapped structural keys', function () { + Http::fake([ + 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), + ]); + + $image = (new LocalImage(__DIR__.'/../../../Fixtures/Images/red.png')) + ->withProviderOptions(['type' => 'input_text', 'detail' => 'low']); + + agent('You are helpful.')->prompt( + 'What is in this image?', + attachments: [$image], + provider: 'openai', + ); + + Http::assertSent(function (Request $request) { + $body = json_decode($request->body(), true); + $userMessage = collect($body['input'])->firstWhere('role', 'user'); + $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); + + return $imageBlock !== null + && $imageBlock['type'] === 'input_image' + && ($imageBlock['detail'] ?? null) === 'low' + && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,'); + }); +}); + test('system instructions are in input array as system role', function () { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse(), From 1b11a666ba164445b1f6949d405db8c908a08b92 Mon Sep 17 00:00:00 2001 From: Javier Villatoro <35673133+JVillator0@users.noreply.github.com> Date: Thu, 9 Jul 2026 06:29:13 -0600 Subject: [PATCH 2/3] Add tool choice support for Gemini, OpenAI, and Anthropic (#780) * Add tool choice support for Gemini, OpenAI, and Anthropic * Merge ToolChoice attribute into value object and extend providers * Shorten resolveToolChoice docblock to match style * Remove redundant auto, none, and required tool choice factories * Release forced tool choice after the first generation step --------- Co-authored-by: Pushpak Chhajed --- .../Anthropic/Concerns/BuildsTextRequests.php | 39 ++++-- .../DeepSeek/Concerns/BuildsTextRequests.php | 4 +- .../Gemini/Concerns/BuildsTextRequests.php | 25 ++++ .../Groq/Concerns/BuildsTextRequests.php | 4 +- .../Mistral/Concerns/BuildsTextRequests.php | 4 +- .../OpenAi/Concerns/BuildsTextRequests.php | 21 +++- .../Concerns/BuildsTextRequests.php | 4 +- .../Concerns/MapsChatCompletionTools.php | 19 +++ .../Concerns/BuildsTextRequests.php | 4 +- src/Gateway/TextGenerationLoop.php | 4 +- src/Gateway/TextGenerationOptions.php | 48 ++++++++ .../Xai/Concerns/BuildsTextRequests.php | 21 +++- src/ToolChoice.php | 70 +++++++++++ .../Anthropic/RequestMappingTest.php | 65 ++++++++++ .../Providers/DeepSeek/RequestMappingTest.php | 39 ++++++ .../Providers/Gemini/RequestMappingTest.php | 43 +++++++ .../Providers/Groq/RequestMappingTest.php | 39 ++++++ .../Providers/Mistral/RequestMappingTest.php | 39 ++++++ .../Providers/OpenAi/RequestMappingTest.php | 45 +++++++ .../Providers/OpenAi/ToolCallLoopTest.php | 41 +++++++ .../OpenAiCompatible/OpenAiCompatibleTest.php | 39 ++++++ .../OpenRouter/RequestMappingTest.php | 39 ++++++ .../Providers/Xai/RequestMappingTest.php | 39 ++++++ .../Agents/AttributeToolChoiceAgent.php | 27 ++++ .../Agents/ThinkingToolChoiceAgent.php | 39 ++++++ tests/Fixtures/Agents/ToolChoiceAgent.php | 37 ++++++ tests/Unit/ToolChoiceTest.php | 116 ++++++++++++++++++ 27 files changed, 895 insertions(+), 19 deletions(-) create mode 100644 src/ToolChoice.php create mode 100644 tests/Fixtures/Agents/AttributeToolChoiceAgent.php create mode 100644 tests/Fixtures/Agents/ThinkingToolChoiceAgent.php create mode 100644 tests/Fixtures/Agents/ToolChoiceAgent.php create mode 100644 tests/Unit/ToolChoiceTest.php diff --git a/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php b/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php index 22a85a4c9..f99e5d844 100644 --- a/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php @@ -3,9 +3,11 @@ namespace Laravel\Ai\Gateway\Anthropic\Concerns; use Illuminate\Support\Arr; +use InvalidArgumentException; use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -54,7 +56,7 @@ protected function buildTextRequestBody( if (filled($mappedTools)) { $body['tools'] = $mappedTools; - $body['tool_choice'] = $this->resolveToolChoice($schema, $tools, $providerOptions); + $body['tool_choice'] = $this->resolveToolChoice($schema, $tools, $providerOptions, $options?->toolChoice); } } @@ -68,20 +70,37 @@ protected function buildTextRequestBody( /** * Determine the tool_choice strategy for the request. - * - * Thinking mode only supports "auto" -- forced tool selection causes an API error. - * - * Without thinking: structured-only forces the synthetic tool, tools+schema uses "any". */ - protected function resolveToolChoice(?array $schema, array $tools, array $providerOptions): array + protected function resolveToolChoice(?array $schema, array $tools, array $providerOptions, ?ToolChoice $toolChoice = null): array { - if (! filled($schema) || isset($providerOptions['thinking'])) { + $thinking = isset($providerOptions['thinking']); + + if (filled($schema)) { + if ($thinking) { + return ['type' => 'auto']; + } + + return filled($tools) + ? ['type' => 'any'] + : ['type' => 'tool', 'name' => 'output_structured_data']; + } + + if (! $toolChoice) { return ['type' => 'auto']; } - return filled($tools) - ? ['type' => 'any'] - : ['type' => 'tool', 'name' => 'output_structured_data']; + if ($thinking && in_array($toolChoice->mode, [ToolChoice::required, ToolChoice::tool], true)) { + throw new InvalidArgumentException( + 'Anthropic cannot force tool use while extended thinking is enabled. Use ToolChoice::auto or ToolChoice::none, or disable thinking.' + ); + } + + return match ($toolChoice->mode) { + ToolChoice::auto => ['type' => 'auto'], + ToolChoice::none => ['type' => 'none'], + ToolChoice::required => ['type' => 'any'], + ToolChoice::tool => ['type' => 'tool', 'name' => $toolChoice->toolName], + }; } /** diff --git a/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php b/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php index 1a48030b1..80dc6384c 100644 --- a/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php +++ b/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php @@ -52,7 +52,9 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $mappedTools; } } diff --git a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php index a265f7943..1b7f2b4c8 100644 --- a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php @@ -8,6 +8,7 @@ use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Responses\Data\ToolResult; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -64,6 +65,12 @@ private function assembleRequestBody( if (filled($tools)) { $body['tools'] = $this->mapTools($tools, $provider); + + if ($options?->toolChoice) { + $body['tool_config'] = [ + 'function_calling_config' => $this->functionCallingConfig($options->toolChoice), + ]; + } } $generationConfig = []; @@ -135,4 +142,22 @@ protected function buildResponseSchema(array $schema): array { return (new ObjectSchema($schema))->toSchema(); } + + /** + * Map a tool choice to the Gemini function_calling_config block. + * + * @return array + */ + protected function functionCallingConfig(ToolChoice $choice): array + { + return match ($choice->mode) { + ToolChoice::auto => ['mode' => 'AUTO'], + ToolChoice::none => ['mode' => 'NONE'], + ToolChoice::required => ['mode' => 'ANY'], + ToolChoice::tool => [ + 'mode' => 'ANY', + 'allowed_function_names' => [$choice->toolName], + ], + }; + } } diff --git a/src/Gateway/Groq/Concerns/BuildsTextRequests.php b/src/Gateway/Groq/Concerns/BuildsTextRequests.php index 4c45f0b59..bcb8d4bf7 100644 --- a/src/Gateway/Groq/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Groq/Concerns/BuildsTextRequests.php @@ -49,7 +49,9 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $mappedTools; $hasTools = true; } diff --git a/src/Gateway/Mistral/Concerns/BuildsTextRequests.php b/src/Gateway/Mistral/Concerns/BuildsTextRequests.php index 4854f2de3..2046658ef 100644 --- a/src/Gateway/Mistral/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Mistral/Concerns/BuildsTextRequests.php @@ -43,7 +43,9 @@ protected function applyTextOptions( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $mappedTools; } } diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index 0cf935fdd..b6d871c10 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -8,6 +8,7 @@ use Laravel\Ai\Messages\ToolResultMessage; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -83,7 +84,9 @@ protected function mergeSharedResponsesRequestOptions( Provider $provider, ): array { if (filled($tools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $this->mapTools($tools, $provider); } @@ -120,6 +123,22 @@ protected function mergeSharedResponsesRequestOptions( return $body; } + /** + * Map a tool choice to the OpenAI Responses tool_choice shape. + * + * @return string|array + */ + protected function mapToolChoice(ToolChoice $choice): string|array + { + return match ($choice->mode) { + ToolChoice::auto, ToolChoice::none, ToolChoice::required => $choice->mode, + ToolChoice::tool => [ + 'type' => 'function', + 'name' => $choice->toolName, + ], + }; + } + /** * Determine if OpenAI should receive full stateless conversation history. */ diff --git a/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php index 2a519425a..c72ac4eec 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php @@ -29,7 +29,9 @@ protected function buildStepBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $mappedTools; } } diff --git a/src/Gateway/OpenAiCompatible/Concerns/MapsChatCompletionTools.php b/src/Gateway/OpenAiCompatible/Concerns/MapsChatCompletionTools.php index 11385b585..49cd7bcbc 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/MapsChatCompletionTools.php +++ b/src/Gateway/OpenAiCompatible/Concerns/MapsChatCompletionTools.php @@ -8,6 +8,7 @@ use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Providers\Tools\ProviderTool; +use Laravel\Ai\ToolChoice; use Laravel\Ai\Tools\ToolNameResolver; use RuntimeException; @@ -64,4 +65,22 @@ protected function mapTool(Tool $tool): array ], ]; } + + /** + * Map a tool choice to the Chat Completions tool_choice shape. + * + * @return string|array + */ + protected function mapToolChoice(ToolChoice $choice): string|array + { + return match ($choice->mode) { + ToolChoice::auto, ToolChoice::none, ToolChoice::required => $choice->mode, + ToolChoice::tool => [ + 'type' => 'function', + 'function' => [ + 'name' => $choice->toolName, + ], + ], + }; + } } diff --git a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php index ad26b7f3c..e0d3a2d36 100644 --- a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php @@ -47,7 +47,9 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $mappedTools; } } diff --git a/src/Gateway/TextGenerationLoop.php b/src/Gateway/TextGenerationLoop.php index d693b42a5..dbfa0e6d8 100644 --- a/src/Gateway/TextGenerationLoop.php +++ b/src/Gateway/TextGenerationLoop.php @@ -66,7 +66,7 @@ public function generate( $allMessages, $tools, $schema, - $options, + $options?->forStep($step), $timeout, $stepContext, ); @@ -151,7 +151,7 @@ public function stream( $allMessages, $tools, $schema, - $options, + $options?->forStep($step), $timeout, $stepContext, ); diff --git a/src/Gateway/TextGenerationOptions.php b/src/Gateway/TextGenerationOptions.php index 4c782c14f..89a0ba4e1 100644 --- a/src/Gateway/TextGenerationOptions.php +++ b/src/Gateway/TextGenerationOptions.php @@ -9,6 +9,7 @@ use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Contracts\HasProviderOptions; use Laravel\Ai\Enums\Lab; +use Laravel\Ai\ToolChoice; use ReflectionClass; class TextGenerationOptions @@ -19,6 +20,7 @@ public function __construct( public readonly ?float $temperature = null, public readonly ?Agent $agent = null, public readonly ?float $topP = null, + public readonly ?ToolChoice $toolChoice = null, ) { // } @@ -39,6 +41,29 @@ public function providerOptions(Lab|string $provider): ?array return null; } + /** + * Resolve the options for the given step, releasing a forced tool choice after the first step so the model can answer. + */ + public function forStep(int $stepNumber): self + { + if ($stepNumber === 0 || $this->toolChoice === null) { + return $this; + } + + if (! in_array($this->toolChoice->mode, [ToolChoice::required, ToolChoice::tool], true)) { + return $this; + } + + return new self( + maxSteps: $this->maxSteps, + maxTokens: $this->maxTokens, + temperature: $this->temperature, + agent: $this->agent, + topP: $this->topP, + toolChoice: null, + ); + } + /** * Create a new TextGenerationOptions instance for the given agent. */ @@ -52,9 +77,32 @@ public static function forAgent(Agent $agent): self temperature: self::resolve($agent, $reflection, 'temperature', Temperature::class), agent: $agent, topP: self::resolve($agent, $reflection, 'topP', TopP::class), + toolChoice: self::resolveToolChoice($agent, $reflection), ); } + /** + * Resolve the tool choice from the agent's method, falling back to the attribute. + */ + private static function resolveToolChoice(Agent $agent, ReflectionClass $reflection): ?ToolChoice + { + if (method_exists($agent, 'toolChoice')) { + try { + $value = $agent->toolChoice(); + } catch (\ArgumentCountError|\Error) { + $value = null; + } + + if (! is_null($value)) { + return ToolChoice::from($value); + } + } + + $attributes = $reflection->getAttributes(ToolChoice::class); + + return ! empty($attributes) ? $attributes[0]->newInstance() : null; + } + /** * Resolve an option from the agent's method, falling back to the attribute. * diff --git a/src/Gateway/Xai/Concerns/BuildsTextRequests.php b/src/Gateway/Xai/Concerns/BuildsTextRequests.php index b00ac5236..1d8317a0d 100644 --- a/src/Gateway/Xai/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Xai/Concerns/BuildsTextRequests.php @@ -8,6 +8,7 @@ use Laravel\Ai\Messages\ToolResultMessage; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -82,7 +83,9 @@ protected function mergeSharedResponsesRequestOptions( Provider $provider, ): array { if (filled($tools)) { - $body['tool_choice'] = 'auto'; + $body['tool_choice'] = $options?->toolChoice + ? $this->mapToolChoice($options->toolChoice) + : 'auto'; $body['tools'] = $this->mapTools($tools, $provider); } @@ -108,6 +111,22 @@ protected function mergeSharedResponsesRequestOptions( return $body; } + /** + * Map a tool choice to the xAI Responses tool_choice shape. + * + * @return string|array + */ + protected function mapToolChoice(ToolChoice $choice): string|array + { + return match ($choice->mode) { + ToolChoice::auto, ToolChoice::none, ToolChoice::required => $choice->mode, + ToolChoice::tool => [ + 'type' => 'function', + 'name' => $choice->toolName, + ], + }; + } + /** * Extract tool result items from the trailing ToolResultMessage for a continuation request. */ diff --git a/src/ToolChoice.php b/src/ToolChoice.php new file mode 100644 index 000000000..9aab7513d --- /dev/null +++ b/src/ToolChoice.php @@ -0,0 +1,70 @@ + 'name'] array into a ToolChoice instance. + * + * @param self|string|array $value + */ + public static function from(self|string|array $value): self + { + if ($value instanceof self) { + return $value; + } + + if (is_string($value)) { + return new self($value); + } + + foreach (['toolName', 'tool', 'name'] as $key) { + if (isset($value[$key]) && is_string($value[$key])) { + return self::tool($value[$key]); + } + } + + throw new InvalidArgumentException('Unrecognized tool choice value.'); + } +} diff --git a/tests/Feature/Providers/Anthropic/RequestMappingTest.php b/tests/Feature/Providers/Anthropic/RequestMappingTest.php index 7d0cc7a49..eb70d3254 100644 --- a/tests/Feature/Providers/Anthropic/RequestMappingTest.php +++ b/tests/Feature/Providers/Anthropic/RequestMappingTest.php @@ -3,8 +3,11 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; use Tests\Fixtures\Agents\StructuredWithThinkingAgent; +use Tests\Fixtures\Agents\ThinkingToolChoiceAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Agents\ToolUsingAgent; describe('request structure', function () { @@ -342,3 +345,65 @@ ->completionTokens->toBe(15); }); }); + +describe('tool choice', function () { + test('required tool choice maps to any', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new ToolChoiceAgent('required'))->prompt('Generate a number', provider: 'anthropic'); + + Http::assertSent(function ($request) { + return $request->data()['tool_choice'] === ['type' => 'any']; + }); + }); + + test('required tool choice can be set via attribute', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new AttributeToolChoiceAgent)->prompt('Generate a number', provider: 'anthropic'); + + Http::assertSent(function ($request) { + return $request->data()['tool_choice'] === ['type' => 'any']; + }); + }); + + test('named tool choice maps to a specific tool', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Generate a number', provider: 'anthropic'); + + Http::assertSent(function ($request) { + return $request->data()['tool_choice'] === ['type' => 'tool', 'name' => 'custom_named_tool']; + }); + }); + + test('none tool choice prevents tool calls', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('Sure'), + ]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'anthropic'); + + Http::assertSent(function ($request) { + return $request->data()['tool_choice'] === ['type' => 'none']; + }); + }); + + test('forcing a tool while thinking is enabled throws', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + expect(fn () => (new ThinkingToolChoiceAgent)->prompt('Generate a number', provider: 'anthropic')) + ->toThrow( + InvalidArgumentException::class, + 'Anthropic cannot force tool use while extended thinking is enabled.', + ); + }); +}); diff --git a/tests/Feature/Providers/DeepSeek/RequestMappingTest.php b/tests/Feature/Providers/DeepSeek/RequestMappingTest.php index 507275f10..b85a9dd2b 100644 --- a/tests/Feature/Providers/DeepSeek/RequestMappingTest.php +++ b/tests/Feature/Providers/DeepSeek/RequestMappingTest.php @@ -5,7 +5,9 @@ use Laravel\Ai\Files\LocalImage; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -98,6 +100,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeDeepSeekResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'deepseek'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeDeepSeekResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'deepseek'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeDeepSeekResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'deepseek'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeDeepSeekResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'deepseek'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('structured output uses json object response format', function () { Http::fake(['*' => fakeDeepSeekResponse('{"symbol": "Au"}')]); diff --git a/tests/Feature/Providers/Gemini/RequestMappingTest.php b/tests/Feature/Providers/Gemini/RequestMappingTest.php index 2f8844cd4..3d4aef533 100644 --- a/tests/Feature/Providers/Gemini/RequestMappingTest.php +++ b/tests/Feature/Providers/Gemini/RequestMappingTest.php @@ -3,9 +3,11 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Responses\Data\FinishReason; use Tests\Fixtures\Agents\AssistantAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\NestedStructuredAgent; use Tests\Fixtures\Agents\NullableStructuredAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Agents\ToolUsingAgent; describe('request structure', function () { @@ -408,3 +410,44 @@ expect($response->meta->citations)->toHaveCount(1); }); }); + +describe('tool choice', function () { + test('required tool choice sends function calling config in ANY mode', function () { + Http::fake([ + 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new ToolChoiceAgent('required'))->prompt('Generate a number', provider: 'gemini'); + + Http::assertSent(function ($request) { + return $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']; + }); + }); + + test('required tool choice can be set via attribute', function () { + Http::fake([ + 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new AttributeToolChoiceAgent)->prompt('Generate a number', provider: 'gemini'); + + Http::assertSent(function ($request) { + return $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']; + }); + }); + + test('named tool choice restricts the allowed function names', function () { + Http::fake([ + 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), + ]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Generate a number', provider: 'gemini'); + + Http::assertSent(function ($request) { + return $request->data()['tool_config']['function_calling_config'] === [ + 'mode' => 'ANY', + 'allowed_function_names' => ['custom_named_tool'], + ]; + }); + }); +}); diff --git a/tests/Feature/Providers/Groq/RequestMappingTest.php b/tests/Feature/Providers/Groq/RequestMappingTest.php index 5fff81974..19603a1a8 100644 --- a/tests/Feature/Providers/Groq/RequestMappingTest.php +++ b/tests/Feature/Providers/Groq/RequestMappingTest.php @@ -4,7 +4,9 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -97,6 +99,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeGroqResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'groq'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeGroqResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'groq'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeGroqResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'groq'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeGroqResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'groq'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('structured output includes json schema response format', function () { Http::fake(['*' => fakeGroqResponse('{"symbol": "Au"}')]); diff --git a/tests/Feature/Providers/Mistral/RequestMappingTest.php b/tests/Feature/Providers/Mistral/RequestMappingTest.php index 8ca83efba..fde023f59 100644 --- a/tests/Feature/Providers/Mistral/RequestMappingTest.php +++ b/tests/Feature/Providers/Mistral/RequestMappingTest.php @@ -4,7 +4,9 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -97,6 +99,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => $this->fakeTextResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'mistral'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => $this->fakeTextResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'mistral'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => $this->fakeTextResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'mistral'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => $this->fakeTextResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'mistral'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('structured output includes json schema response format', function () { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); diff --git a/tests/Feature/Providers/OpenAi/RequestMappingTest.php b/tests/Feature/Providers/OpenAi/RequestMappingTest.php index 768d92004..497d00bdb 100644 --- a/tests/Feature/Providers/OpenAi/RequestMappingTest.php +++ b/tests/Feature/Providers/OpenAi/RequestMappingTest.php @@ -4,8 +4,10 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\NestedStructuredAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -281,3 +283,46 @@ ->and($response->meta->citations[0]->startIndex)->toBeNull() ->and($response->meta->citations[0]->endIndex)->toBeNull(); }); + +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeOpenAiResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openai'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === 'required'; + }); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeOpenAiResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openai'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === 'required'; + }); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeOpenAiResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openai'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'name' => 'custom_named_tool', + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeOpenAiResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openai'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === 'none'; + }); +}); diff --git a/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php b/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php index 7224f6e9d..8dc3b6afe 100644 --- a/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php +++ b/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php @@ -3,6 +3,7 @@ use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\MultiStepToolAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Agents\ToolUsingAgent; beforeEach(function () { @@ -87,6 +88,46 @@ ->and($response->usage->completionTokens)->toBe(11); }); +test('a forced tool choice is released on the follow up request', function () { + Http::fake([ + 'api.openai.com/*' => Http::sequence([ + fakeOpenAiRandomNumberToolCallResponse(), + fakeOpenAiResponse('The number is 7'), + ]), + ]); + + (new ToolChoiceAgent('required'))->prompt('Generate a random number', provider: 'openai'); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2) + ->and(json_decode($recorded[0][0]->body(), true)['tool_choice'])->toBe('required') + ->and(json_decode($recorded[1][0]->body(), true)['tool_choice'])->toBe('auto'); +}); + +function fakeOpenAiRandomNumberToolCallResponse(): PromiseInterface +{ + $id = uniqid(); + + return Http::response([ + 'id' => 'resp_tool_'.$id, + 'status' => 'completed', + 'model' => 'gpt-5.4', + 'output' => [[ + 'type' => 'function_call', + 'id' => 'fc_'.$id, + 'call_id' => 'call_'.$id, + 'name' => 'RandomNumberGenerator', + 'arguments' => '{"min":1,"max":10}', + 'status' => 'completed', + ]], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 5, + ], + ]); +} + function fakeUniqueOpenAiToolCallResponse(): PromiseInterface { $id = uniqid(); diff --git a/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php b/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php index f44b77327..1c1318135 100644 --- a/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php +++ b/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php @@ -7,7 +7,9 @@ use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use function Laravel\Ai\agent; @@ -99,6 +101,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openai-compatible'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openai-compatible'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openai-compatible'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeOpenAiCompatibleResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openai-compatible'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('max tokens uses the max_tokens field by default', function () { Http::fake(['*' => fakeOpenAiCompatibleResponse('Hello')]); diff --git a/tests/Feature/Providers/OpenRouter/RequestMappingTest.php b/tests/Feature/Providers/OpenRouter/RequestMappingTest.php index 987f09cfa..aa44536be 100644 --- a/tests/Feature/Providers/OpenRouter/RequestMappingTest.php +++ b/tests/Feature/Providers/OpenRouter/RequestMappingTest.php @@ -4,7 +4,9 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -97,6 +99,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeOpenRouterResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openrouter'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeOpenRouterResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openrouter'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeOpenRouterResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openrouter'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeOpenRouterResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openrouter'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('structured output includes json schema response format', function () { Http::fake(['*' => fakeOpenRouterResponse('{"symbol": "Au"}')]); diff --git a/tests/Feature/Providers/Xai/RequestMappingTest.php b/tests/Feature/Providers/Xai/RequestMappingTest.php index f7a4711a8..2dc1483ea 100644 --- a/tests/Feature/Providers/Xai/RequestMappingTest.php +++ b/tests/Feature/Providers/Xai/RequestMappingTest.php @@ -5,7 +5,9 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\AttributeAgent; +use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; +use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; @@ -99,6 +101,43 @@ }); }); +test('required tool choice forces the model to call a tool', function () { + Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); + + (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'xai'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('required tool choice can be set via attribute', function () { + Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); + + (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'xai'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); +}); + +test('named tool choice forces a specific function', function () { + Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); + + (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'xai'); + + Http::assertSent(function (Request $request) { + return json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'name' => 'custom_named_tool', + ]; + }); +}); + +test('none tool choice prevents tool calls', function () { + Http::fake(['*' => fakeXaiRequestMappingResponse('Sure')]); + + (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'xai'); + + Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); +}); + test('structured output includes json schema text format', function () { Http::fake(['*' => fakeXaiRequestMappingResponse('{"symbol": "Au"}')]); diff --git a/tests/Fixtures/Agents/AttributeToolChoiceAgent.php b/tests/Fixtures/Agents/AttributeToolChoiceAgent.php new file mode 100644 index 000000000..39208f0dc --- /dev/null +++ b/tests/Fixtures/Agents/AttributeToolChoiceAgent.php @@ -0,0 +1,27 @@ + [ + 'type' => 'enabled', + 'budget_tokens' => 10_000, + ], + ]; + } +} diff --git a/tests/Fixtures/Agents/ToolChoiceAgent.php b/tests/Fixtures/Agents/ToolChoiceAgent.php new file mode 100644 index 000000000..04a973893 --- /dev/null +++ b/tests/Fixtures/Agents/ToolChoiceAgent.php @@ -0,0 +1,37 @@ +toolChoice; + } + + public function tools(): iterable + { + return [ + new RandomNumberGenerator, + new NamedTool('custom_named_tool'), + ]; + } +} diff --git a/tests/Unit/ToolChoiceTest.php b/tests/Unit/ToolChoiceTest.php new file mode 100644 index 000000000..0b449291d --- /dev/null +++ b/tests/Unit/ToolChoiceTest.php @@ -0,0 +1,116 @@ +mode)->toBe(ToolChoice::auto) + ->and((new ToolChoice(ToolChoice::auto))->toolName)->toBeNull() + ->and((new ToolChoice(ToolChoice::none))->mode)->toBe(ToolChoice::none) + ->and((new ToolChoice(ToolChoice::required))->mode)->toBe(ToolChoice::required) + ->and(ToolChoice::tool('calculator')->mode)->toBe(ToolChoice::tool) + ->and(ToolChoice::tool('calculator')->toolName)->toBe('calculator'); +}); + +test('tool mode requires a non-empty tool name', function () { + expect(fn () => new ToolChoice(ToolChoice::tool))->toThrow(InvalidArgumentException::class); + expect(fn () => new ToolChoice(ToolChoice::tool, ''))->toThrow(InvalidArgumentException::class); +}); + +test('non-tool modes reject a tool name', function () { + expect(fn () => new ToolChoice(ToolChoice::auto, 'x'))->toThrow(InvalidArgumentException::class); + expect(fn () => new ToolChoice(ToolChoice::required, 'x'))->toThrow(InvalidArgumentException::class); +}); + +test('from coerces instances and strings', function () { + $choice = new ToolChoice(ToolChoice::required); + + expect(ToolChoice::from($choice))->toBe($choice) + ->and(ToolChoice::from('auto')->mode)->toBe(ToolChoice::auto) + ->and(ToolChoice::from('required')->mode)->toBe(ToolChoice::required); +}); + +test('from accepts array shorthand for tool selection', function () { + expect(ToolChoice::from(['tool' => 'calculator'])->toolName)->toBe('calculator') + ->and(ToolChoice::from(['toolName' => 'calculator'])->toolName)->toBe('calculator') + ->and(ToolChoice::from(['name' => 'calculator'])->toolName)->toBe('calculator'); +}); + +test('from rejects invalid values', function () { + expect(fn () => ToolChoice::from('bogus'))->toThrow(InvalidArgumentException::class); + expect(fn () => ToolChoice::from(['unexpected' => 'value']))->toThrow(InvalidArgumentException::class); +}); + +test('options resolve tool choice from the attribute', function () { + $options = TextGenerationOptions::forAgent(new AttributeToolChoiceAgent); + + expect($options->toolChoice)->not->toBeNull() + ->and($options->toolChoice->mode)->toBe(ToolChoice::required); +}); + +test('options resolve tool choice from the method over the attribute', function () { + $options = TextGenerationOptions::forAgent(new ToolChoiceAgent(ToolChoice::tool('custom_named_tool'))); + + expect($options->toolChoice->mode)->toBe(ToolChoice::tool) + ->and($options->toolChoice->toolName)->toBe('custom_named_tool'); +}); + +test('options coerce a plain string from the method', function () { + $options = TextGenerationOptions::forAgent(new ToolChoiceAgent('required')); + + expect($options->toolChoice->mode)->toBe(ToolChoice::required); +}); + +test('options leave tool choice null when the agent declares none', function () { + expect(TextGenerationOptions::forAgent(new AssistantAgent)->toolChoice)->toBeNull(); + expect(TextGenerationOptions::forAgent(new ToolChoiceAgent)->toolChoice)->toBeNull(); +}); + +test('forStep releases a forced tool choice after the first step', function () { + foreach ([ToolChoice::required, ToolChoice::tool] as $mode) { + $options = new TextGenerationOptions( + toolChoice: $mode === ToolChoice::tool ? ToolChoice::tool('calculator') : new ToolChoice($mode), + ); + + expect($options->forStep(0))->toBe($options) + ->and($options->forStep(0)->toolChoice->mode)->toBe($mode) + ->and($options->forStep(1)->toolChoice)->toBeNull() + ->and($options->forStep(2)->toolChoice)->toBeNull(); + } +}); + +test('forStep keeps auto and none tool choices on every step', function () { + foreach ([ToolChoice::auto, ToolChoice::none] as $mode) { + $options = new TextGenerationOptions(toolChoice: new ToolChoice($mode)); + + expect($options->forStep(0)->toolChoice->mode)->toBe($mode) + ->and($options->forStep(3)->toolChoice->mode)->toBe($mode); + } +}); + +test('forStep preserves other options when releasing the tool choice', function () { + $options = new TextGenerationOptions( + maxSteps: 4, + maxTokens: 256, + temperature: 0.7, + topP: 0.9, + toolChoice: new ToolChoice(ToolChoice::required), + ); + + $stepped = $options->forStep(1); + + expect($stepped->toolChoice)->toBeNull() + ->and($stepped->maxSteps)->toBe(4) + ->and($stepped->maxTokens)->toBe(256) + ->and($stepped->temperature)->toBe(0.7) + ->and($stepped->topP)->toBe(0.9); +}); + +test('forStep is a no-op when no tool choice is set', function () { + $options = new TextGenerationOptions(maxSteps: 3); + + expect($options->forStep(2))->toBe($options); +}); From 8d384afa010e2958315bc4c490074f147302d14d Mon Sep 17 00:00:00 2001 From: Denys Finchenko Date: Thu, 9 Jul 2026 14:29:56 +0200 Subject: [PATCH 3/3] Add tests for the image portrait and custom size helpers (#783) --- tests/Feature/ImageFakeTest.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Feature/ImageFakeTest.php b/tests/Feature/ImageFakeTest.php index b4304e081..977fb9114 100644 --- a/tests/Feature/ImageFakeTest.php +++ b/tests/Feature/ImageFakeTest.php @@ -105,6 +105,28 @@ }); }); +test('image portrait aspect ratio is recorded', function () { + Image::fake(); + + Image::of('A sunset')->portrait()->generate(); + + Image::assertGenerated(function (ImagePrompt $prompt) { + return $prompt->prompt === 'A sunset' + && $prompt->size === '2:3'; + }); +}); + +test('image custom size is recorded', function () { + Image::fake(); + + Image::of('A sunset')->size('16:9')->generate(); + + Image::assertGenerated(function (ImagePrompt $prompt) { + return $prompt->prompt === 'A sunset' + && $prompt->size === '16:9'; + }); +}); + test('queued images can be faked', function () { Image::fake();