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
39 changes: 29 additions & 10 deletions src/Gateway/Anthropic/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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],
};
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/Gateway/Gemini/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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<string, mixed>
*/
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],
],
};
}
}
4 changes: 3 additions & 1 deletion src/Gateway/Groq/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion src/Gateway/Mistral/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
23 changes: 21 additions & 2 deletions src/Gateway/OpenAi/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Laravel\Ai\Messages\ToolResultMessage;
use Laravel\Ai\ObjectSchema;
use Laravel\Ai\Providers\Provider;
use Laravel\Ai\ToolChoice;

trait BuildsTextRequests
{
Expand All @@ -25,7 +26,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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -120,6 +123,22 @@ protected function mergeSharedResponsesRequestOptions(
return $body;
}

/**
* Map a tool choice to the OpenAI Responses tool_choice shape.
*
* @return string|array<string, mixed>
*/
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.
*/
Expand Down
15 changes: 12 additions & 3 deletions src/Gateway/OpenAi/Concerns/MapsAttachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
9 changes: 5 additions & 4 deletions src/Gateway/OpenAi/Concerns/MapsMessages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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),
};
Expand All @@ -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[] = [
Expand Down
4 changes: 3 additions & 1 deletion src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
19 changes: 19 additions & 0 deletions src/Gateway/OpenAiCompatible/Concerns/MapsChatCompletionTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -64,4 +65,22 @@ protected function mapTool(Tool $tool): array
],
];
}

/**
* Map a tool choice to the Chat Completions tool_choice shape.
*
* @return string|array<string, mixed>
*/
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,
],
],
};
}
}
4 changes: 3 additions & 1 deletion src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/Gateway/TextGenerationLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function generate(
$allMessages,
$tools,
$schema,
$options,
$options?->forStep($step),
$timeout,
$stepContext,
);
Expand Down Expand Up @@ -151,7 +151,7 @@ public function stream(
$allMessages,
$tools,
$schema,
$options,
$options?->forStep($step),
$timeout,
$stepContext,
);
Expand Down
Loading
Loading