diff --git a/functions.php b/functions.php index 79f94d4d9..f6b96f8ac 100644 --- a/functions.php +++ b/functions.php @@ -96,6 +96,10 @@ function generate_fake_data_for_json_schema_type(Type $type): mixed return $result; })(), + 'Illuminate\\JsonSchema\\Types\\AnyOfType' => (function () use ($attributes) { + return generate_fake_data_for_json_schema_type($attributes['schemas'][0] ?? throw new \RuntimeException('AnyOf schema must contain at least one branch.')); + })(), + StringType::class => (function () use ($attributes) { if (isset($attributes['format'])) { return match ($attributes['format']) { diff --git a/src/Gateway/Bedrock/BedrockTextGateway.php b/src/Gateway/Bedrock/BedrockTextGateway.php index 9cd583b4d..ad3d61d00 100644 --- a/src/Gateway/Bedrock/BedrockTextGateway.php +++ b/src/Gateway/Bedrock/BedrockTextGateway.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Laravel\Ai\Contracts\Gateway\EmbeddingGateway; +use Laravel\Ai\Contracts\Gateway\StepTextGateway; use Laravel\Ai\Contracts\Gateway\TextGateway; use Laravel\Ai\Contracts\Providers\EmbeddingProvider; use Laravel\Ai\Contracts\Providers\TextProvider; @@ -15,8 +16,10 @@ use Laravel\Ai\Enums\Lab; use Laravel\Ai\Gateway\Bedrock\Concerns\CreatesBedrockClient; use Laravel\Ai\Gateway\Bedrock\Concerns\MapsAttachments; +use Laravel\Ai\Gateway\Concerns\DelegatesToTextGenerationLoop; use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors; -use Laravel\Ai\Gateway\Concerns\InvokesTools; +use Laravel\Ai\Gateway\StepContext; +use Laravel\Ai\Gateway\StepResponse; use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\Messages\AssistantMessage; use Laravel\Ai\Messages\Message; @@ -26,486 +29,454 @@ use Laravel\Ai\ObjectSchema; use Laravel\Ai\Responses\Data\FinishReason; use Laravel\Ai\Responses\Data\Meta; -use Laravel\Ai\Responses\Data\Step; use Laravel\Ai\Responses\Data\ToolCall; use Laravel\Ai\Responses\Data\ToolResult; use Laravel\Ai\Responses\Data\Usage; use Laravel\Ai\Responses\EmbeddingsResponse; -use Laravel\Ai\Responses\StructuredTextResponse; -use Laravel\Ai\Responses\TextResponse; use Laravel\Ai\Streaming\Events\ReasoningDelta; use Laravel\Ai\Streaming\Events\ReasoningEnd; use Laravel\Ai\Streaming\Events\ReasoningStart; -use Laravel\Ai\Streaming\Events\StreamEnd; +use Laravel\Ai\Streaming\Events\StreamEvent; use Laravel\Ai\Streaming\Events\StreamStart; use Laravel\Ai\Streaming\Events\TextDelta; use Laravel\Ai\Streaming\Events\TextEnd; use Laravel\Ai\Streaming\Events\TextStart; use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; -use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; use Laravel\Ai\Tools\ToolNameResolver; use stdClass; use Throwable; -class BedrockTextGateway implements EmbeddingGateway, TextGateway +class BedrockTextGateway implements EmbeddingGateway, StepTextGateway, TextGateway { use CreatesBedrockClient; + use DelegatesToTextGenerationLoop; use HandlesFailoverErrors; - use InvokesTools; use MapsAttachments; protected const STRUCTURED_OUTPUT_TOOL = 'structured_output'; public function __construct() { - $this->initializeToolCallbacks(); + // } /** - * {@inheritdoc} + * Generate text for a single Converse step. */ - public function generateText( + public function generateTextStep( TextProvider $provider, string $model, ?string $instructions, - array $messages = [], - array $tools = [], - ?array $schema = null, - ?TextGenerationOptions $options = null, - ?int $timeout = null, - ): TextResponse { + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + ?int $timeout, + StepContext $stepContext, + ): StepResponse { $client = $this->createBedrockClient($provider, $timeout); - $conversationMessages = $this->formatMessages($messages); - $maxSteps = $this->resolveMaxSteps($tools, $options); - $schemaTools = $schema ? $this->buildSchemaTools($schema, $tools) : null; - $formattedTools = $schemaTools === null && ! empty($tools) ? $this->formatTools($tools) : null; - $allToolCalls = []; - $allToolResults = []; - $finalOutput = ''; - $totalUsage = new Usage; - $step = 0; - $responseMessages = new Collection; - $steps = new Collection; - $meta = new Meta($provider->name(), $model); - - while ($step < $maxSteps) { - $parameters = $this->buildConverseParameters( - $model, - $instructions, - $conversationMessages, - $schemaTools, - $formattedTools, - empty($tools), - $options, - isFinalStep: ($step + 1) >= $maxSteps, + $parameters = $this->buildStepBody($provider, $model, $instructions, $messages, $tools, $schema, $options, $stepContext); + + try { + $response = $this->withErrorHandling( + $provider->name(), + fn () => $client->converse($parameters), ); - try { - $response = $this->withErrorHandling( - $provider->name(), - fn () => $client->converse($parameters), - ); + $result = $response->toArray(); + } catch (Throwable $e) { + throw BedrockException::toAiException($e, $provider->name(), $model); + } - $result = $response->toArray(); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); - } + return $this->parseTextResponse($result, $provider, $model, filled($schema)); + } - $stepUsage = new Usage( - promptTokens: $result['usage']['inputTokens'] ?? 0, - completionTokens: $result['usage']['outputTokens'] ?? 0, - cacheWriteInputTokens: $result['usage']['cacheWriteInputTokens'] ?? 0, - cacheReadInputTokens: $result['usage']['cacheReadInputTokens'] ?? 0, - ); + /** + * Stream text for a single Converse step. + */ + public function generateStreamStep( + string $invocationId, + TextProvider $provider, + string $model, + ?string $instructions, + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + ?int $timeout, + StepContext $stepContext, + ): Generator { + $client = $this->createBedrockClient($provider, $timeout); - $totalUsage = $totalUsage->add($stepUsage); + $parameters = $this->buildStepBody($provider, $model, $instructions, $messages, $tools, $schema, $options, $stepContext); + + try { + $response = $this->withErrorHandling( + $provider->name(), + fn () => $client->converseStream($parameters), + ); + } catch (Throwable $e) { + throw BedrockException::toAiException($e, $provider->name(), $model); + } - $output = ''; - $toolCalls = []; - $providerContentBlocks = []; + return yield from $this->processTextStream($invocationId, $provider, $model, $response['stream'], filled($schema)); + } - foreach ($result['output']['message']['content'] ?? [] as $block) { - $providerContentBlocks[] = $block; + /** + * Build the Converse request parameters for the current text generation step. + */ + protected function buildStepBody( + TextProvider $provider, + string $model, + ?string $instructions, + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + StepContext $stepContext, + ): array { + $conversationMessages = $this->formatMessages($messages); + $schemaTools = $schema ? $this->buildSchemaTools($schema, $tools) : null; + $formattedTools = $schemaTools === null && ! empty($tools) ? $this->formatTools($tools) : null; - if (isset($block['text'])) { - $output .= $block['text']; + return $this->buildConverseParameters( + $model, + $instructions, + $conversationMessages, + $schemaTools, + $formattedTools, + empty($tools), + $options, + isFinalStep: $stepContext->isFinalStep, + ); + } - continue; - } + /** + * Parse a single Converse response into a step response. + */ + protected function parseTextResponse(array $result, TextProvider $provider, string $model, bool $structured): StepResponse + { + $usage = new Usage( + promptTokens: $result['usage']['inputTokens'] ?? 0, + completionTokens: $result['usage']['outputTokens'] ?? 0, + cacheWriteInputTokens: $result['usage']['cacheWriteInputTokens'] ?? 0, + cacheReadInputTokens: $result['usage']['cacheReadInputTokens'] ?? 0, + ); - if (! isset($block['toolUse'])) { - continue; - } + $output = ''; + $toolCalls = []; + $providerContentBlocks = []; + $structuredOutput = null; - if ($schemaTools && $block['toolUse']['name'] === self::STRUCTURED_OUTPUT_TOOL) { - $finalOutput = json_encode($block['toolUse']['input'] ?? []); + foreach ($result['output']['message']['content'] ?? [] as $block) { + $providerContentBlocks[] = $block; - continue; - } + if (isset($block['text'])) { + $output .= $block['text']; - $toolCalls[] = new ToolCall( - $block['toolUse']['toolUseId'], - $block['toolUse']['name'], - $block['toolUse']['input'] ?? [], - ); + continue; } - if (! $schemaTools) { - $finalOutput = $output; + if (! isset($block['toolUse'])) { + continue; } - $step++; - $finishReason = $this->extractFinishReason($result); - - $responseMessages->push(new AssistantMessage($output, new Collection($toolCalls), $providerContentBlocks)); - - if (empty($toolCalls)) { - if ($schemaTools && $finishReason === FinishReason::ToolCalls) { - $finishReason = FinishReason::Stop; - } - - $steps->push(new Step($output, $toolCalls, [], $finishReason, $stepUsage, $meta)); + if ($structured && $block['toolUse']['name'] === self::STRUCTURED_OUTPUT_TOOL) { + $structuredOutput = json_encode($block['toolUse']['input'] ?? []); - break; + continue; } - $allToolCalls = array_merge($allToolCalls, $toolCalls); - $conversationMessages[] = $this->buildAssistantConversationMessage($output, $toolCalls, $providerContentBlocks); - - $toolResults = $this->executeToolCalls($tools, $toolCalls); - $allToolResults = array_merge($allToolResults, $toolResults); + $toolCalls[] = new ToolCall( + $block['toolUse']['toolUseId'], + $block['toolUse']['name'], + $block['toolUse']['input'] ?? [], + ); + } - $steps->push(new Step($output, $toolCalls, $toolResults, $finishReason, $stepUsage, $meta)); + $finishReason = $this->extractFinishReason($result); - if (! empty($toolResults)) { - $conversationMessages[] = $this->buildToolResultConversationMessage($toolResults); - $responseMessages->push(new ToolResultMessage(new Collection($toolResults))); - } + if (empty($toolCalls) && $structured && $finishReason === FinishReason::ToolCalls) { + $finishReason = FinishReason::Stop; } - if ($schema) { - $structured = json_decode($finalOutput, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - $structured = []; - } + return new StepResponse( + text: $structuredOutput ?? $output, + toolCalls: $toolCalls, + finishReason: $finishReason, + usage: $usage, + meta: new Meta($provider->name(), $model), + structured: $structuredOutput !== null ? $this->decodeStructuredOutput($structuredOutput) : null, + providerContentBlocks: $providerContentBlocks, + ); + } - return (new StructuredTextResponse($structured, $finalOutput, $totalUsage, $meta)) - ->withToolCallsAndResults(new Collection($allToolCalls), new Collection($allToolResults)) - ->withSteps($steps); - } + /** + * Decode the structured output JSON, falling back to an empty array on failure. + */ + protected function decodeStructuredOutput(string $json): array + { + $structured = json_decode($json, true); - return (new TextResponse($finalOutput, $totalUsage, $meta)) - ->withMessages($responseMessages) - ->withSteps($steps); + return json_last_error() === JSON_ERROR_NONE ? $structured : []; } /** - * {@inheritdoc} + * Stream a single Converse step, returning the parsed step response. + * + * @return Generator */ - public function streamText( + protected function processTextStream( string $invocationId, TextProvider $provider, string $model, - ?string $instructions, - array $messages = [], - array $tools = [], - ?array $schema = null, - ?TextGenerationOptions $options = null, - ?int $timeout = null, + $stream, + bool $structured, ): Generator { - $client = $this->createBedrockClient($provider, $timeout); - $conversationMessages = $this->formatMessages($messages); - $maxSteps = $this->resolveMaxSteps($tools, $options); - $schemaTools = $schema ? $this->buildSchemaTools($schema, $tools) : null; - $formattedTools = $schemaTools === null && ! empty($tools) ? $this->formatTools($tools) : null; - $messageId = (string) Str::uuid(); $timestamp = time(); $totalUsage = new Usage; - $step = 0; - - while ($step < $maxSteps) { - $parameters = $this->buildConverseParameters( - $model, - $instructions, - $conversationMessages, - $schemaTools, - $formattedTools, - empty($tools), - $options, - isFinalStep: ($step + 1) >= $maxSteps, - ); - try { - $response = $this->withErrorHandling( - $provider->name(), - fn () => $client->converseStream($parameters), - ); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); + yield (new StreamStart( + (string) Str::uuid(), + $provider->name(), + $model, + $timestamp, + ))->withInvocationId($invocationId); + + $assistantText = ''; + $pendingToolCalls = []; + $toolCalls = []; + $structuredOutput = null; + $currentBlockIndex = null; + $currentBlockType = ''; + $responseContent = []; + $reasoningId = ''; + $textId = ''; + $currentText = ''; + $currentReasoningText = ''; + $currentReasoningSignature = ''; + $currentReasoningRedacted = ''; + $stopReason = 'stop'; + + $emitTextStart = function () use (&$textId, $invocationId, $timestamp) { + if ($textId !== '') { + return null; } - yield (new StreamStart( + $textId = (string) Str::uuid(); + + return (new TextStart( (string) Str::uuid(), - $provider->name(), - $model, + $textId, $timestamp, ))->withInvocationId($invocationId); + }; - $assistantText = ''; - $pendingToolCalls = []; - $toolCalls = []; - $structuredOutput = null; - $currentBlockIndex = null; - $currentBlockType = ''; - $responseContent = []; - $reasoningId = ''; - $textId = ''; - $currentText = ''; - $currentReasoningText = ''; - $currentReasoningSignature = ''; - $currentReasoningRedacted = ''; - $stopReason = 'stop'; - - $emitTextStart = function () use (&$textId, $invocationId, $timestamp) { - if ($textId !== '') { - return null; - } - - $textId = (string) Str::uuid(); - - return (new TextStart( - (string) Str::uuid(), - $textId, - $timestamp, - ))->withInvocationId($invocationId); - }; + $emitReasoningStart = function () use (&$reasoningId, $invocationId, $timestamp) { + if ($reasoningId !== '') { + return null; + } - $emitReasoningStart = function () use (&$reasoningId, $invocationId, $timestamp) { - if ($reasoningId !== '') { - return null; - } + $reasoningId = (string) Str::uuid(); - $reasoningId = (string) Str::uuid(); - - return (new ReasoningStart( - (string) Str::uuid(), - $reasoningId, - $timestamp, - ))->withInvocationId($invocationId); - }; - - foreach ($response['stream'] as $event) { - if (isset($event['contentBlockStart'])) { - $currentBlockIndex = $event['contentBlockStart']['contentBlockIndex'] ?? 0; - $start = $event['contentBlockStart']['start'] ?? []; - $currentBlockType = isset($start['toolUse']) ? 'toolUse' : ''; - - if (isset($start['toolUse'])) { - $pendingToolCalls[$currentBlockIndex] = [ - 'id' => $start['toolUse']['toolUseId'] ?? '', - 'name' => $start['toolUse']['name'] ?? '', - 'input' => '', - ]; - } + return (new ReasoningStart( + (string) Str::uuid(), + $reasoningId, + $timestamp, + ))->withInvocationId($invocationId); + }; - continue; + foreach ($stream as $event) { + if (isset($event['contentBlockStart'])) { + $currentBlockIndex = $event['contentBlockStart']['contentBlockIndex'] ?? 0; + $start = $event['contentBlockStart']['start'] ?? []; + $currentBlockType = isset($start['toolUse']) ? 'toolUse' : ''; + + if (isset($start['toolUse'])) { + $pendingToolCalls[$currentBlockIndex] = [ + 'id' => $start['toolUse']['toolUseId'] ?? '', + 'name' => $start['toolUse']['name'] ?? '', + 'input' => '', + ]; } - if (isset($event['contentBlockDelta'])) { - $index = $event['contentBlockDelta']['contentBlockIndex'] ?? $currentBlockIndex; - $delta = $event['contentBlockDelta']['delta'] ?? []; + continue; + } - if (isset($delta['text'])) { - $currentBlockType = 'text'; + if (isset($event['contentBlockDelta'])) { + $index = $event['contentBlockDelta']['contentBlockIndex'] ?? $currentBlockIndex; + $delta = $event['contentBlockDelta']['delta'] ?? []; - if ($emittedEvent = $emitTextStart()) { - yield $emittedEvent; - } + if (isset($delta['text'])) { + $currentBlockType = 'text'; - $assistantText .= $delta['text']; - $currentText .= $delta['text']; + if ($emittedEvent = $emitTextStart()) { + yield $emittedEvent; + } - yield (new TextDelta( - (string) Str::uuid(), - $textId, - $delta['text'], - $timestamp, - ))->withInvocationId($invocationId); - } elseif (isset($delta['reasoningContent']['text'])) { - $currentBlockType = 'reasoning'; + $assistantText .= $delta['text']; + $currentText .= $delta['text']; - if ($emittedEvent = $emitReasoningStart()) { - yield $emittedEvent; - } + yield (new TextDelta( + (string) Str::uuid(), + $textId, + $delta['text'], + $timestamp, + ))->withInvocationId($invocationId); + } elseif (isset($delta['reasoningContent']['text'])) { + $currentBlockType = 'reasoning'; - $currentReasoningText .= $delta['reasoningContent']['text']; + if ($emittedEvent = $emitReasoningStart()) { + yield $emittedEvent; + } - yield (new ReasoningDelta( - (string) Str::uuid(), - $reasoningId, - $delta['reasoningContent']['text'], - $timestamp, - ))->withInvocationId($invocationId); - } elseif (isset($delta['reasoningContent']['signature'])) { - $currentBlockType = 'reasoning'; + $currentReasoningText .= $delta['reasoningContent']['text']; - if ($emittedEvent = $emitReasoningStart()) { - yield $emittedEvent; - } + yield (new ReasoningDelta( + (string) Str::uuid(), + $reasoningId, + $delta['reasoningContent']['text'], + $timestamp, + ))->withInvocationId($invocationId); + } elseif (isset($delta['reasoningContent']['signature'])) { + $currentBlockType = 'reasoning'; - $currentReasoningSignature .= $delta['reasoningContent']['signature']; - } elseif (isset($delta['reasoningContent']['redactedContent'])) { - $currentBlockType = 'reasoning'; + if ($emittedEvent = $emitReasoningStart()) { + yield $emittedEvent; + } - if ($emittedEvent = $emitReasoningStart()) { - yield $emittedEvent; - } + $currentReasoningSignature .= $delta['reasoningContent']['signature']; + } elseif (isset($delta['reasoningContent']['redactedContent'])) { + $currentBlockType = 'reasoning'; - $currentReasoningRedacted .= $delta['reasoningContent']['redactedContent']; - } elseif (isset($delta['toolUse']['input'], $pendingToolCalls[$index])) { - $pendingToolCalls[$index]['input'] .= $delta['toolUse']['input']; + if ($emittedEvent = $emitReasoningStart()) { + yield $emittedEvent; } - continue; + $currentReasoningRedacted .= $delta['reasoningContent']['redactedContent']; + } elseif (isset($delta['toolUse']['input'], $pendingToolCalls[$index])) { + $pendingToolCalls[$index]['input'] .= $delta['toolUse']['input']; } - if (isset($event['contentBlockStop'])) { - $index = $event['contentBlockStop']['contentBlockIndex'] ?? $currentBlockIndex; + continue; + } - if ($currentBlockType === 'reasoning') { - if ($currentReasoningRedacted !== '') { - $responseContent[$index] = [ - 'reasoningContent' => [ - 'redactedContent' => $currentReasoningRedacted, - ], - ]; - } else { - $responseContent[$index] = [ - 'reasoningContent' => [ - 'reasoningText' => [ - 'text' => $currentReasoningText, - 'signature' => $currentReasoningSignature, - ], - ], - ]; - } + if (isset($event['contentBlockStop'])) { + $index = $event['contentBlockStop']['contentBlockIndex'] ?? $currentBlockIndex; - yield (new ReasoningEnd( - (string) Str::uuid(), - $reasoningId, - $timestamp, - ))->withInvocationId($invocationId); + if ($currentBlockType === 'reasoning') { + if ($currentReasoningRedacted !== '') { + $responseContent[$index] = [ + 'reasoningContent' => [ + 'redactedContent' => $currentReasoningRedacted, + ], + ]; + } else { + $responseContent[$index] = [ + 'reasoningContent' => [ + 'reasoningText' => [ + 'text' => $currentReasoningText, + 'signature' => $currentReasoningSignature, + ], + ], + ]; + } - $currentReasoningText = ''; - $currentReasoningSignature = ''; - $currentReasoningRedacted = ''; - $reasoningId = ''; - } elseif ($currentBlockType === 'text' && $textId !== '') { - $responseContent[$index] = ['text' => $currentText]; + yield (new ReasoningEnd( + (string) Str::uuid(), + $reasoningId, + $timestamp, + ))->withInvocationId($invocationId); + + $currentReasoningText = ''; + $currentReasoningSignature = ''; + $currentReasoningRedacted = ''; + $reasoningId = ''; + } elseif ($currentBlockType === 'text' && $textId !== '') { + $responseContent[$index] = ['text' => $currentText]; + + yield (new TextEnd( + (string) Str::uuid(), + $textId, + $timestamp, + ))->withInvocationId($invocationId); + + $currentText = ''; + $textId = ''; + } elseif ($currentBlockType === 'toolUse' && isset($pendingToolCalls[$index])) { + $pending = $pendingToolCalls[$index]; + $arguments = json_decode($pending['input'] !== '' ? $pending['input'] : '{}', true) ?? []; + + if ($structured && $pending['name'] === self::STRUCTURED_OUTPUT_TOOL) { + $structuredOutput = json_encode($arguments); + } else { + $toolCall = new ToolCall($pending['id'], $pending['name'], $arguments); + $toolCalls[] = $toolCall; + $responseContent[$index] = [ + 'toolUse' => [ + 'toolUseId' => $toolCall->id, + 'name' => $toolCall->name, + 'input' => $arguments, + ], + ]; - yield (new TextEnd( + yield (new ToolCallEvent( (string) Str::uuid(), - $textId, + $toolCall, $timestamp, ))->withInvocationId($invocationId); - - $currentText = ''; - $textId = ''; - } elseif ($currentBlockType === 'toolUse' && isset($pendingToolCalls[$index])) { - $pending = $pendingToolCalls[$index]; - $arguments = json_decode($pending['input'] !== '' ? $pending['input'] : '{}', true) ?? []; - - if ($schemaTools && $pending['name'] === self::STRUCTURED_OUTPUT_TOOL) { - $structuredOutput = json_encode($arguments); - } else { - $toolCall = new ToolCall($pending['id'], $pending['name'], $arguments); - $toolCalls[] = $toolCall; - $responseContent[$index] = [ - 'toolUse' => [ - 'toolUseId' => $toolCall->id, - 'name' => $toolCall->name, - 'input' => $arguments, - ], - ]; - - yield (new ToolCallEvent( - (string) Str::uuid(), - $toolCall, - $timestamp, - ))->withInvocationId($invocationId); - } - - unset($pendingToolCalls[$index]); } - $currentBlockType = ''; - - continue; + unset($pendingToolCalls[$index]); } - if (isset($event['messageStop'])) { - $stopReason = $event['messageStop']['stopReason'] ?? 'stop'; - - continue; - } + $currentBlockType = ''; - if (isset($event['metadata']['usage'])) { - $totalUsage = $totalUsage->add(new Usage( - promptTokens: $event['metadata']['usage']['inputTokens'] ?? 0, - completionTokens: $event['metadata']['usage']['outputTokens'] ?? 0, - cacheWriteInputTokens: $event['metadata']['usage']['cacheWriteInputTokens'] ?? 0, - cacheReadInputTokens: $event['metadata']['usage']['cacheReadInputTokens'] ?? 0, - )); - } + continue; } - $step++; + if (isset($event['messageStop'])) { + $stopReason = $event['messageStop']['stopReason'] ?? 'stop'; - if ($structuredOutput !== null) { - yield (new TextDelta( - (string) Str::uuid(), - $messageId, - $structuredOutput, - $timestamp, - ))->withInvocationId($invocationId); + continue; } - if (empty($toolCalls)) { - break; + if (isset($event['metadata']['usage'])) { + $totalUsage = $totalUsage->add(new Usage( + promptTokens: $event['metadata']['usage']['inputTokens'] ?? 0, + completionTokens: $event['metadata']['usage']['outputTokens'] ?? 0, + cacheWriteInputTokens: $event['metadata']['usage']['cacheWriteInputTokens'] ?? 0, + cacheReadInputTokens: $event['metadata']['usage']['cacheReadInputTokens'] ?? 0, + )); } + } - $conversationMessages[] = $this->buildAssistantConversationMessage($assistantText, $toolCalls, array_values($responseContent)); - - $toolResults = $this->executeToolCalls($tools, $toolCalls); - - foreach ($toolResults as $toolResult) { - yield (new ToolResultEvent( - (string) Str::uuid(), - $toolResult, - true, - null, - $timestamp, - ))->withInvocationId($invocationId); - } + if ($structuredOutput !== null) { + yield (new TextDelta( + (string) Str::uuid(), + $messageId, + $structuredOutput, + $timestamp, + ))->withInvocationId($invocationId); + } - if (! empty($toolResults)) { - $conversationMessages[] = $this->buildToolResultConversationMessage($toolResults); - } + $finishReason = $this->extractFinishReason(['stopReason' => $stopReason]); - if ($stopReason !== 'tool_use') { - break; - } + if (empty($toolCalls) && $structured && $finishReason === FinishReason::ToolCalls) { + $finishReason = FinishReason::Stop; } - yield (new StreamEnd( - $messageId, - 'stop', - $totalUsage, - $timestamp, - ))->withInvocationId($invocationId); + return new StepResponse( + text: $assistantText, + toolCalls: $toolCalls, + finishReason: $finishReason, + usage: $totalUsage, + meta: new Meta($provider->name(), $model), + structured: $structuredOutput !== null ? $this->decodeStructuredOutput($structuredOutput) : null, + providerContentBlocks: array_values($responseContent), + ); } /** @@ -916,42 +887,4 @@ protected function formatTools(array $tools): array ->values() ->all(); } - - /** - * Execute the tool calls against the provided tools and collect results. - * - * @param array $tools - * @param array $toolCalls - * @return array - */ - protected function executeToolCalls(array $tools, array $toolCalls): array - { - $results = []; - - foreach ($toolCalls as $toolCall) { - $tool = $this->findTool($toolCall->name, $tools); - - if ($tool === null) { - $results[] = new ToolResult( - $toolCall->id, - $toolCall->name, - $toolCall->arguments, - 'Error: Tool "'.$toolCall->name.'" not found.', - ); - - continue; - } - - $result = $this->executeTool($tool, $toolCall->arguments); - - $results[] = new ToolResult( - $toolCall->id, - $toolCall->name, - $toolCall->arguments, - $result, - ); - } - - return $results; - } } diff --git a/src/Gateway/Mistral/Concerns/HandlesTextStreaming.php b/src/Gateway/Mistral/Concerns/HandlesTextStreaming.php index d0c0c6167..8ce9a367e 100644 --- a/src/Gateway/Mistral/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/Mistral/Concerns/HandlesTextStreaming.php @@ -4,49 +4,37 @@ use Generator; use Illuminate\Support\Str; -use Laravel\Ai\Gateway\TextGenerationOptions; +use Laravel\Ai\Gateway\StepResponse; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\Responses\Data\Meta; use Laravel\Ai\Responses\Data\ToolCall; -use Laravel\Ai\Responses\Data\ToolResult; use Laravel\Ai\Responses\Data\Usage; use Laravel\Ai\Streaming\Events\Error; -use Laravel\Ai\Streaming\Events\StreamEnd; +use Laravel\Ai\Streaming\Events\StreamEvent; use Laravel\Ai\Streaming\Events\StreamStart; use Laravel\Ai\Streaming\Events\TextDelta; use Laravel\Ai\Streaming\Events\TextEnd; use Laravel\Ai\Streaming\Events\TextStart; use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; -use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; trait HandlesTextStreaming { - /** - * Process a Chat Completions streaming response and yield Laravel stream events. - */ + /** @return Generator */ protected function processTextStream( string $invocationId, Provider $provider, string $model, - array $tools, - ?array $schema, - ?TextGenerationOptions $options, $streamBody, - ?string $instructions = null, - array $originalMessages = [], - int $depth = 0, - ?int $maxSteps = null, - array $priorChatMessages = [], - ?int $timeout = null, ): Generator { - $maxSteps ??= $options?->maxSteps; - $messageId = $this->generateEventId(); $streamStartEmitted = false; $textStartEmitted = false; $currentText = ''; + $toolCalls = []; $pendingToolCalls = []; $usage = null; $finishReason = null; + $responseModel = $model; foreach ($this->parseServerSentEvents($streamBody) as $data) { if (isset($data['error'])) { @@ -58,7 +46,7 @@ protected function processTextStream( time(), ))->withInvocationId($invocationId); - return; + return null; } $choice = $data['choices'][0] ?? null; @@ -75,11 +63,12 @@ protected function processTextStream( if (! $streamStartEmitted) { $streamStartEmitted = true; + $responseModel = $data['model'] ?? $model; yield (new StreamStart( $this->generateEventId(), $provider->name(), - $data['model'] ?? $model, + $responseModel, time(), ))->withInvocationId($invocationId); } @@ -141,173 +130,31 @@ protected function processTextStream( } if (filled($pendingToolCalls) && $finishReason === 'tool_calls') { - $mappedToolCalls = $this->mapStreamToolCalls($pendingToolCalls); + foreach (array_values($pendingToolCalls) as $pending) { + $toolCall = new ToolCall( + $pending['id'] ?? '', + $pending['name'] ?? '', + json_decode($pending['arguments'] ?? '{}', true) ?? [], + $pending['id'] ?? null, + ); + + $toolCalls[] = $toolCall; - foreach ($mappedToolCalls as $toolCall) { yield (new ToolCallEvent( $this->generateEventId(), $toolCall, time(), ))->withInvocationId($invocationId); } - - yield from $this->handleStreamingToolCalls( - $invocationId, - $provider, - $model, - $tools, - $schema, - $options, - $mappedToolCalls, - $currentText, - $instructions, - $originalMessages, - $depth, - $maxSteps, - $priorChatMessages, - $timeout, - ); - - return; } - yield (new StreamEnd( - $this->generateEventId(), - $this->extractFinishReason(['finish_reason' => $finishReason ?? ''])->value, - $usage ?? new Usage(0, 0), - time(), - ))->withInvocationId($invocationId); - } - - /** - * Handle tool calls detected during streaming. - */ - protected function handleStreamingToolCalls( - string $invocationId, - Provider $provider, - string $model, - array $tools, - ?array $schema, - ?TextGenerationOptions $options, - array $mappedToolCalls, - string $currentText, - ?string $instructions, - array $originalMessages, - int $depth, - ?int $maxSteps, - array $priorChatMessages, - ?int $timeout = null, - ): Generator { - $toolResults = []; - - foreach ($mappedToolCalls as $toolCall) { - $tool = $this->findTool($toolCall->name, $tools); - - if ($tool === null) { - continue; - } - - $result = $this->executeTool($tool, $toolCall->arguments); - - $toolResult = new ToolResult( - $toolCall->id, - $toolCall->name, - $toolCall->arguments, - $result, - $toolCall->resultId, - ); - - $toolResults[] = $toolResult; - - yield (new ToolResultEvent( - $this->generateEventId(), - $toolResult, - true, - null, - time(), - ))->withInvocationId($invocationId); - } - - if ($depth + 1 < ($maxSteps ?? round(count($tools) * 1.5))) { - $assistantMsg = ['role' => 'assistant']; - - if (filled($currentText)) { - $assistantMsg['content'] = $currentText; - } - - $assistantMsg['tool_calls'] = array_map( - fn (ToolCall $toolCall) => $this->serializeToolCallToChat($toolCall), $mappedToolCalls - ); - - $toolResultMessages = []; - - foreach ($toolResults as $toolResult) { - $toolResultMessages[] = [ - 'role' => 'tool', - 'tool_call_id' => $toolResult->resultId ?? $toolResult->id, - 'content' => $this->serializeToolResultOutput($toolResult->result), - ]; - } - - $updatedPriorMessages = [...$priorChatMessages, $assistantMsg, ...$toolResultMessages]; - - $chatMessages = [ - ...$this->mapMessagesToChat($originalMessages, $instructions), - ...$updatedPriorMessages, - ]; - - $body = $this->applyTextOptions([ - 'model' => $model, - 'messages' => $chatMessages, - 'stream' => true, - 'stream_options' => ['include_usage' => true], - ], $provider, $tools, $schema, $options); - - $response = $this->withErrorHandling( - $provider->name(), - fn () => $this->client($provider, $timeout) - ->withOptions(['stream' => true]) - ->post('chat/completions', $body), - ); - - yield from $this->processTextStream( - $invocationId, - $provider, - $model, - $tools, - $schema, - $options, - $response->getBody(), - $instructions, - $originalMessages, - $depth + 1, - $maxSteps, - $updatedPriorMessages, - $timeout, - ); - } else { - yield (new StreamEnd( - $this->generateEventId(), - 'stop', - new Usage(0, 0), - time(), - ))->withInvocationId($invocationId); - } - } - - /** - * Map raw streaming tool call data to ToolCall DTOs. - * - * @return array - */ - protected function mapStreamToolCalls(array $toolCalls): array - { - return array_map(fn (array $toolCall) => new ToolCall( - $toolCall['id'] ?? '', - $toolCall['name'] ?? '', - json_decode($toolCall['arguments'] ?? '{}', true) ?? [], - $toolCall['id'] ?? null, - ), array_values($toolCalls)); + return new StepResponse( + text: $currentText, + toolCalls: $toolCalls, + finishReason: $this->extractFinishReason(['finish_reason' => $finishReason ?? '']), + usage: $usage ?? new Usage(0, 0), + meta: new Meta($provider->name(), $responseModel), + ); } /** diff --git a/src/Gateway/Mistral/Concerns/ParsesTextResponses.php b/src/Gateway/Mistral/Concerns/ParsesTextResponses.php index d2ba5570e..4a36c0f13 100644 --- a/src/Gateway/Mistral/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Mistral/Concerns/ParsesTextResponses.php @@ -2,21 +2,13 @@ namespace Laravel\Ai\Gateway\Mistral\Concerns; -use Illuminate\Support\Collection; -use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Exceptions\AiException; -use Laravel\Ai\Gateway\TextGenerationOptions; -use Laravel\Ai\Messages\AssistantMessage; -use Laravel\Ai\Messages\ToolResultMessage; +use Laravel\Ai\Gateway\StepResponse; use Laravel\Ai\Providers\Provider; use Laravel\Ai\Responses\Data\FinishReason; use Laravel\Ai\Responses\Data\Meta; -use Laravel\Ai\Responses\Data\Step; use Laravel\Ai\Responses\Data\ToolCall; -use Laravel\Ai\Responses\Data\ToolResult; use Laravel\Ai\Responses\Data\Usage; -use Laravel\Ai\Responses\StructuredTextResponse; -use Laravel\Ai\Responses\TextResponse; trait ParsesTextResponses { @@ -37,251 +29,34 @@ protected function validateTextResponse(array $data): void } /** - * Parse the Mistral response data into a TextResponse. + * Parse the Mistral response data into a single step response. */ protected function parseTextResponse( array $data, Provider $provider, bool $structured, - array $tools = [], - ?array $schema = null, - ?TextGenerationOptions $options = null, - ?string $instructions = null, - array $originalMessages = [], - ?int $timeout = null, - ): TextResponse { - return $this->processResponse( - $data, - $provider, - $structured, - $tools, - $schema, - new Collection, - new Collection, - instructions: $instructions, - originalMessages: $originalMessages, - maxSteps: $options?->maxSteps, - options: $options, - timeout: $timeout, - ); - } - - /** - * Process a single response, handling tool loops recursively. - */ - protected function processResponse( - array $data, - Provider $provider, - bool $structured, - array $tools, - ?array $schema, - Collection $steps, - Collection $messages, - ?string $instructions = null, - array $originalMessages = [], - int $depth = 0, - ?int $maxSteps = null, - ?TextGenerationOptions $options = null, - ?int $timeout = null, - ): TextResponse { + ): StepResponse { $choice = $data['choices'][0] ?? []; $message = $choice['message'] ?? []; $model = $data['model'] ?? ''; $text = $message['content'] ?? ''; $rawToolCalls = $message['tool_calls'] ?? []; - $usage = $this->extractUsage($data); - $finishReason = $this->extractFinishReason($choice); - $mappedToolCalls = array_map(fn (array $toolCall) => new ToolCall( + $toolCalls = array_map(fn (array $toolCall) => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], $toolCall['id'] ?? null, ), $rawToolCalls); - $step = new Step( - $text, - $mappedToolCalls, - [], - $finishReason, - $usage, - new Meta($provider->name(), $model), - ); - - $steps->push($step); - - $assistantMessage = new AssistantMessage($text, collect($mappedToolCalls)); - - $messages->push($assistantMessage); - - if ($finishReason === FinishReason::ToolCalls && - filled($mappedToolCalls) && - $steps->count() < ($maxSteps ?? round(count($tools) * 1.5))) { - $toolResults = $this->executeToolCalls($mappedToolCalls, $tools); - - $steps->pop(); - - $steps->push(new Step( - $text, - $mappedToolCalls, - $toolResults, - $finishReason, - $usage, - new Meta($provider->name(), $model), - )); - - $toolResultMessage = new ToolResultMessage(collect($toolResults)); - - $messages->push($toolResultMessage); - - return $this->continueWithToolResults( - $model, - $provider, - $structured, - $tools, - $schema, - $steps, - $messages, - $instructions, - $originalMessages, - $depth + 1, - $maxSteps, - $options, - $timeout, - ); - } - - $allToolCalls = $steps->flatMap(fn (Step $s) => $s->toolCalls); - $allToolResults = $steps->flatMap(fn (Step $s) => $s->toolResults); - - if ($structured) { - $structuredData = json_decode($text, true) ?? []; - - return (new StructuredTextResponse( - $structuredData, - $text, - $this->combineUsage($steps), - new Meta($provider->name(), $model), - ))->withToolCallsAndResults( - toolCalls: $allToolCalls, - toolResults: $allToolResults, - )->withSteps($steps); - } - - return (new TextResponse( - $text, - $this->combineUsage($steps), - new Meta($provider->name(), $model), - ))->withMessages($messages)->withSteps($steps); - } - - /** - * Execute tool calls and return tool results. - * - * @param array $toolCalls - * @param array $tools - * @return array - */ - protected function executeToolCalls(array $toolCalls, array $tools): array - { - $results = []; - - foreach ($toolCalls as $toolCall) { - $tool = $this->findTool($toolCall->name, $tools); - - if ($tool === null) { - continue; - } - - $result = $this->executeTool($tool, $toolCall->arguments); - - $results[] = new ToolResult( - $toolCall->id, - $toolCall->name, - $toolCall->arguments, - $result, - $toolCall->resultId, - ); - } - - return $results; - } - - /** - * Continue the conversation with tool results by making a follow-up request. - */ - protected function continueWithToolResults( - string $model, - Provider $provider, - bool $structured, - array $tools, - ?array $schema, - Collection $steps, - Collection $messages, - ?string $instructions, - array $originalMessages, - int $depth, - ?int $maxSteps, - ?TextGenerationOptions $options = null, - ?int $timeout = null, - ): TextResponse { - $chatMessages = $this->mapMessagesToChat($originalMessages, $instructions); - - foreach ($messages as $msg) { - if ($msg instanceof AssistantMessage) { - $mapped = ['role' => 'assistant']; - - if (filled($msg->content)) { - $mapped['content'] = $msg->content; - } - - if ($msg->toolCalls->isNotEmpty()) { - $mapped['tool_calls'] = $msg->toolCalls->map( - fn (ToolCall $toolCall) => $this->serializeToolCallToChat($toolCall) - )->all(); - } - - $chatMessages[] = $mapped; - } elseif ($msg instanceof ToolResultMessage) { - foreach ($msg->toolResults as $toolResult) { - $chatMessages[] = [ - 'role' => 'tool', - 'tool_call_id' => $toolResult->resultId ?? $toolResult->id, - 'content' => $this->serializeToolResultOutput($toolResult->result), - ]; - } - } - } - - $body = $this->applyTextOptions([ - 'model' => $model, - 'messages' => $chatMessages, - ], $provider, $tools, $schema, $options); - - $response = $this->withErrorHandling( - $provider->name(), - fn () => $this->client($provider, $timeout)->post('chat/completions', $body), - ); - - $data = $response->json(); - - $this->validateTextResponse($data); - - return $this->processResponse( - $data, - $provider, - $structured, - $tools, - $schema, - $steps, - $messages, - $instructions, - $originalMessages, - $depth, - $maxSteps, - $options, - $timeout, + return new StepResponse( + text: $text, + toolCalls: $toolCalls, + finishReason: $this->extractFinishReason($choice), + usage: $this->extractUsage($data), + meta: new Meta($provider->name(), $model), + structured: $structured ? (json_decode($text, true) ?? []) : null, ); } @@ -311,15 +86,4 @@ protected function extractFinishReason(array $choice): FinishReason default => FinishReason::Unknown, }; } - - /** - * Combine usage across all steps. - */ - protected function combineUsage(Collection $steps): Usage - { - return $steps->reduce( - fn (Usage $carry, Step $step) => $carry->add($step->usage), - new Usage(0, 0) - ); - } } diff --git a/src/Gateway/Mistral/MistralGateway.php b/src/Gateway/Mistral/MistralGateway.php index daf44f240..e6385328d 100644 --- a/src/Gateway/Mistral/MistralGateway.php +++ b/src/Gateway/Mistral/MistralGateway.php @@ -7,23 +7,25 @@ use Laravel\Ai\Contracts\Files\HasName; use Laravel\Ai\Contracts\Files\TranscribableAudio; use Laravel\Ai\Contracts\Gateway\EmbeddingGateway; +use Laravel\Ai\Contracts\Gateway\StepTextGateway; use Laravel\Ai\Contracts\Gateway\TextGateway; use Laravel\Ai\Contracts\Gateway\TranscriptionGateway; use Laravel\Ai\Contracts\Providers\EmbeddingProvider; use Laravel\Ai\Contracts\Providers\TextProvider; use Laravel\Ai\Contracts\Providers\TranscriptionProvider; +use Laravel\Ai\Gateway\Concerns\DelegatesToTextGenerationLoop; use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors; -use Laravel\Ai\Gateway\Concerns\InvokesTools; use Laravel\Ai\Gateway\Concerns\ParsesServerSentEvents; +use Laravel\Ai\Gateway\StepContext; +use Laravel\Ai\Gateway\StepResponse; use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\Responses\Data\Meta; use Laravel\Ai\Responses\Data\TranscriptionSegment; use Laravel\Ai\Responses\Data\Usage; use Laravel\Ai\Responses\EmbeddingsResponse; -use Laravel\Ai\Responses\TextResponse; use Laravel\Ai\Responses\TranscriptionResponse; -class MistralGateway implements EmbeddingGateway, TextGateway, TranscriptionGateway +class MistralGateway implements EmbeddingGateway, StepTextGateway, TextGateway, TranscriptionGateway { use Concerns\BuildsTextRequests; use Concerns\CreatesMistralClient; @@ -32,37 +34,30 @@ class MistralGateway implements EmbeddingGateway, TextGateway, TranscriptionGate use Concerns\MapsMessages; use Concerns\MapsTools; use Concerns\ParsesTextResponses; + use DelegatesToTextGenerationLoop; use HandlesFailoverErrors; - use InvokesTools; use ParsesServerSentEvents; public function __construct(protected Dispatcher $events) { - $this->initializeToolCallbacks(); + // } /** - * {@inheritdoc} + * Generate text for a single Chat Completions step. */ - public function generateText( + public function generateTextStep( TextProvider $provider, string $model, ?string $instructions, - array $messages = [], - array $tools = [], - ?array $schema = null, - ?TextGenerationOptions $options = null, - ?int $timeout = null, - ): TextResponse { - $body = $this->buildTextRequestBody( - $provider, - $model, - $instructions, - $messages, - $tools, - $schema, - $options, - ); + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + ?int $timeout, + StepContext $stepContext, + ): StepResponse { + $body = $this->buildStepBody($provider, $model, $instructions, $messages, $tools, $schema, $options, $stepContext); $response = $this->withErrorHandling( $provider->name(), @@ -73,43 +68,25 @@ public function generateText( $this->validateTextResponse($data); - return $this->parseTextResponse( - $data, - $provider, - filled($schema), - $tools, - $schema, - $options, - $instructions, - $messages, - $timeout, - ); + return $this->parseTextResponse($data, $provider, filled($schema)); } /** - * {@inheritdoc} + * Stream text for a single Chat Completions step. */ - public function streamText( + public function generateStreamStep( string $invocationId, TextProvider $provider, string $model, ?string $instructions, - array $messages = [], - array $tools = [], - ?array $schema = null, - ?TextGenerationOptions $options = null, - ?int $timeout = null, + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + ?int $timeout, + StepContext $stepContext, ): Generator { - $body = $this->buildTextRequestBody( - $provider, - $model, - $instructions, - $messages, - $tools, - $schema, - $options, - ); - + $body = $this->buildStepBody($provider, $model, $instructions, $messages, $tools, $schema, $options, $stepContext); $body['stream'] = true; $body['stream_options'] = ['include_usage' => true]; @@ -120,18 +97,23 @@ public function streamText( ->post('chat/completions', $body), ); - yield from $this->processTextStream( - $invocationId, - $provider, - $model, - $tools, - $schema, - $options, - $response->getBody(), - $instructions, - $messages, - timeout: $timeout, - ); + return yield from $this->processTextStream($invocationId, $provider, $model, $response->getBody()); + } + + /** + * Build the request body for the current text generation step. + */ + protected function buildStepBody( + TextProvider $provider, + string $model, + ?string $instructions, + array $messages, + array $tools, + ?array $schema, + ?TextGenerationOptions $options, + StepContext $stepContext, + ): array { + return $this->buildTextRequestBody($provider, $model, $instructions, $messages, $tools, $schema, $options); } /** diff --git a/src/ObjectSchema.php b/src/ObjectSchema.php index 9841c03e8..d389ad10a 100644 --- a/src/ObjectSchema.php +++ b/src/ObjectSchema.php @@ -55,6 +55,12 @@ protected static function disableAdditionalProperties(array $schema): array $schema['items'] = static::disableAdditionalProperties($schema['items']); } + foreach ($schema['anyOf'] ?? [] as $key => $branch) { + if (is_array($branch)) { + $schema['anyOf'][$key] = static::disableAdditionalProperties($branch); + } + } + return $schema; } diff --git a/src/Schema/SchemaNormalizer.php b/src/Schema/SchemaNormalizer.php index 338244b54..9d1ef9508 100644 --- a/src/Schema/SchemaNormalizer.php +++ b/src/Schema/SchemaNormalizer.php @@ -58,6 +58,7 @@ private function node(array $schema, array $root, array $seen): array [$schema, $seen] = $this->inlineRefs($schema, $root, $seen); $schema = $this->mergeAllOf($schema, $root, $seen); + $schema = $this->preserveAnyOf($schema, $root, $seen); $schema = $this->collapseUnions($schema, $root, $seen); $schema = $this->collapseMultiType($schema); $schema = $this->normalizeKeywords($schema); @@ -199,6 +200,10 @@ private function collapseUnions(array $schema, array $root, array $seen): array continue; } + if ($key === 'anyOf' && $this->supportsAnyOf()) { + continue; + } + $branches = $schema[$key]; unset($schema[$key]); @@ -209,6 +214,59 @@ private function collapseUnions(array $schema, array $root, array $seen): array return $schema; } + /** + * Keep anyOf compositions intact when the installed deserializer supports them. + * + * @param array $schema + * @param array $root + * @param array $seen + * @return array + */ + private function preserveAnyOf(array $schema, array $root, array $seen): array + { + if (! $this->supportsAnyOf() || ! is_array($schema['anyOf'] ?? null)) { + return $schema; + } + + $carry = ['anyOf' => true, 'title' => true, 'description' => true, 'enum' => true, 'default' => true]; + $base = array_diff_key($schema, $carry); + + $branches = []; + $resolved = false; + + foreach ($schema['anyOf'] as $branch) { + if (! is_array($branch) || $branch === []) { + continue; + } + + [$branch, $branchSeen] = $this->inlineRefs($branch, $root, $seen); + + if ($branch === []) { + continue; + } + + if ($this->isNullBranch($branch)) { + $branches[] = ['type' => 'null']; + + continue; + } + + $branches[] = $this->node($base === [] ? $branch : $this->mergeSchema($base, $branch), $root, $branchSeen); + $resolved = true; + } + + if (! $resolved) { + unset($schema['anyOf']); + + return $schema; + } + + $schema = array_intersect_key($schema, $carry); + $schema['anyOf'] = $branches; + + return $schema; + } + /** * Reduce union branches into the node: scalar multi-type union, merged object variants, * or the first branch (lossy fallback) since the deserializer only accepts a single schema plus null. @@ -235,7 +293,7 @@ private function mergeUnion(array $schema, array $branches, array $root, array $ continue; } - if (in_array($branch['type'] ?? null, ['null', ['null']], true)) { + if ($this->isNullBranch($branch)) { $nullable = true; } else { $resolved[] = $this->node($branch, $root, $branchSeen); @@ -457,6 +515,24 @@ private function isObjectBranch(array $branch): bool return $nonNull === ['object']; } + /** + * Determine if the installed Illuminate JSON schema package supports anyOf. + */ + private function supportsAnyOf(): bool + { + return class_exists('Illuminate\\JsonSchema\\Types\\AnyOfType'); + } + + /** + * Determine whether the branch only represents null. + * + * @param array $schema + */ + private function isNullBranch(array $schema): bool + { + return in_array($schema['type'] ?? null, ['null', ['null']], true); + } + /** * Get the unioned scalar types when every branch is a plain scalar, else null. * diff --git a/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php b/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php new file mode 100644 index 000000000..1c944ad99 --- /dev/null +++ b/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php @@ -0,0 +1,167 @@ + ['message' => ['content' => [ + ['toolUse' => ['toolUseId' => $toolUseId, 'name' => 'FixedNumberGenerator', 'input' => []]], + ]]], + 'usage' => ['inputTokens' => 7, 'outputTokens' => 3], + 'stopReason' => 'tool_use', + ]; +} + +function bedrockTextResponse(string $text): array +{ + return [ + 'output' => ['message' => ['content' => [['text' => $text]]]], + 'usage' => ['inputTokens' => 7, 'outputTokens' => 5], + 'stopReason' => 'end_turn', + ]; +} + +describe('tool call loop', function () { + test('multi step tool loop returns accumulated response shape', function () { + $client = $this->fakeBedrockConverseSequence([ + bedrockToolCallResponse('t1'), + bedrockToolCallResponse('t2'), + bedrockTextResponse('Done'), + ]); + + $gateway = $this->gatewayWithClient($client); + + $response = $gateway->generateText( + $this->bedrockProvider(), + 'anthropic.claude-opus-4-7-v1:0', + null, + messages: [new UserMessage('Generate numbers')], + tools: [new FixedNumberGenerator], + options: new TextGenerationOptions(maxSteps: 5), + ); + + expect((string) $response)->toBe('Done') + ->and($response->messages)->toHaveCount(5) + ->and($response->steps)->toHaveCount(3) + ->and($response->toolCalls)->toHaveCount(2) + ->and($response->toolResults)->toHaveCount(2) + ->and($response->usage->promptTokens)->toBe(21) + ->and($response->usage->completionTokens)->toBe(11); + }); + + test('max steps limits tool call depth', function () { + $client = $this->fakeBedrockConverseSequence([ + bedrockToolCallResponse('t1'), + bedrockToolCallResponse('t2'), + bedrockToolCallResponse('t3'), + bedrockTextResponse('Done'), + ]); + + $gateway = $this->gatewayWithClient($client); + + $response = $gateway->generateText( + $this->bedrockProvider(), + 'anthropic.claude-opus-4-7-v1:0', + null, + messages: [new UserMessage('Generate numbers')], + tools: [new FixedNumberGenerator], + options: new TextGenerationOptions(maxSteps: 2), + ); + + expect($response->steps)->toHaveCount(2); + }); + + test('unknown tool call throws NoSuchToolException', function () { + $client = $this->fakeBedrockConverse([ + 'output' => ['message' => ['content' => [ + ['toolUse' => ['toolUseId' => 't1', 'name' => 'NonExistentTool', 'input' => []]], + ]]], + 'usage' => ['inputTokens' => 7, 'outputTokens' => 3], + 'stopReason' => 'tool_use', + ]); + + $gateway = $this->gatewayWithClient($client); + + expect(fn () => $gateway->generateText( + $this->bedrockProvider(), + 'anthropic.claude-opus-4-7-v1:0', + null, + tools: [new FixedNumberGenerator], + ))->toThrow(NoSuchToolException::class); + }); + + test('structured output is parsed from the synthetic tool call', function () { + $client = $this->fakeBedrockConverse([ + 'output' => ['message' => ['content' => [ + ['toolUse' => ['toolUseId' => 's1', 'name' => 'structured_output', 'input' => ['symbol' => 'Fe']]], + ]]], + 'usage' => ['inputTokens' => 8, 'outputTokens' => 4], + 'stopReason' => 'tool_use', + ]); + + $gateway = $this->gatewayWithClient($client); + + $response = $gateway->generateText( + $this->bedrockProvider(), + 'anthropic.claude-opus-4-7-v1:0', + null, + schema: ['symbol' => (new JsonSchemaTypeFactory)->string()], + ); + + expect($response)->toBeInstanceOf(StructuredTextResponse::class) + ->and($response->structured)->toMatchArray(['symbol' => 'Fe']) + ->and($response->steps)->toHaveCount(1) + ->and($response->usage->promptTokens)->toBe(8) + ->and($response->usage->completionTokens)->toBe(4); + }); + + test('streaming tool loop emits a single stream end with accumulated usage', function () { + $client = $this->fakeBedrockStreamSequence([ + [ + $this->contentBlockStart(0, ['toolUse' => ['toolUseId' => 't1', 'name' => 'FixedNumberGenerator']]), + $this->contentBlockDelta(0, ['toolUse' => ['input' => '{}']]), + $this->contentBlockStop(0), + $this->messageStop('tool_use'), + ['metadata' => ['usage' => ['inputTokens' => 5, 'outputTokens' => 2]]], + ], + [ + $this->contentBlockStart(0), + $this->contentBlockDelta(0, ['text' => 'Done']), + $this->contentBlockStop(0), + $this->messageStop('end_turn'), + ['metadata' => ['usage' => ['inputTokens' => 5, 'outputTokens' => 2]]], + ], + ]); + + $gateway = $this->gatewayWithClient($client); + + $events = iterator_to_array( + $gateway->streamText( + 'inv-1', + $this->bedrockProvider(), + 'anthropic.claude-opus-4-7-v1:0', + null, + tools: [new FixedNumberGenerator], + ), + preserve_keys: false, + ); + + $streamEnds = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $toolResults = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); + + expect($streamEnds)->toHaveCount(1) + ->and($streamEnds[0]->reason)->toBe('stop') + ->and($streamEnds[0]->usage->promptTokens)->toBe(10) + ->and($streamEnds[0]->usage->completionTokens)->toBe(4) + ->and($toolResults)->toHaveCount(1) + ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); + }); +}); diff --git a/tests/Feature/Providers/Mistral/StreamingTest.php b/tests/Feature/Providers/Mistral/StreamingTest.php index fe402ac67..638aec200 100644 --- a/tests/Feature/Providers/Mistral/StreamingTest.php +++ b/tests/Feature/Providers/Mistral/StreamingTest.php @@ -69,10 +69,15 @@ $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); $toolResultEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); + $streamEndEvents = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') - ->and($toolResultEvents)->not->toBeEmpty(); + ->and($toolResultEvents)->not->toBeEmpty() + ->and($streamEndEvents)->toHaveCount(1) + ->and($streamEndEvents[0]->reason)->toBe(FinishReason::Stop->value) + ->and($streamEndEvents[0]->usage->promptTokens)->toBe(30) + ->and($streamEndEvents[0]->usage->completionTokens)->toBe(15); }); test('streaming captures usage', function () { diff --git a/tests/Feature/Providers/Mistral/ToolCallLoopTest.php b/tests/Feature/Providers/Mistral/ToolCallLoopTest.php index 9453cee6b..cd1be600f 100644 --- a/tests/Feature/Providers/Mistral/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Mistral/ToolCallLoopTest.php @@ -1,6 +1,8 @@ toBeLessThanOrEqual(3); }); +test('multi step tool loop returns accumulated response shape', function () { + Http::fake([ + '*' => Http::sequence([ + $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), + $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), + $this->fakeTextResponse('Done'), + ]), + ]); + + $response = (new MultiStepToolAgent)->prompt( + 'Generate numbers', + provider: 'mistral', + ); + + expect((string) $response)->toBe('Done') + ->and($response->messages)->toHaveCount(5) + ->and($response->steps)->toHaveCount(3) + ->and($response->toolCalls)->toHaveCount(2) + ->and($response->toolResults)->toHaveCount(2) + ->and($response->usage->promptTokens)->toBe(30) + ->and($response->usage->completionTokens)->toBe(15); +}); + +test('unregistered tool call throws', function () { + Http::fake([ + '*' => Http::sequence([ + $this->fakeToolCallResponse('NonExistentTool', 'call_'.uniqid()), + ]), + ]); + + expect(fn () => (new MultiStepToolAgent)->prompt( + 'Generate numbers', + provider: 'mistral', + ))->toThrow(NoSuchToolException::class); +}); + test('follow up request includes original messages', function () { Http::fake([ '*' => Http::sequence([ diff --git a/tests/Unit/Schema/SchemaNormalizerTest.php b/tests/Unit/Schema/SchemaNormalizerTest.php index 0f57b01a9..c0dcc4299 100644 --- a/tests/Unit/Schema/SchemaNormalizerTest.php +++ b/tests/Unit/Schema/SchemaNormalizerTest.php @@ -1,9 +1,15 @@ toBe($raw); }); -test('it collapses a nullable anyOf to a nullable single type', function () { +test('it preserves a nullable anyOf when the framework supports it natively', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => [ + 'nickname' => ['anyOf' => [['type' => 'string', 'minLength' => 1], ['type' => 'null']]], + ], + ]); + + expect($normalized['properties']['nickname'])->toMatchArray([ + 'anyOf' => [ + ['type' => 'string', 'minLength' => 1], + ['type' => 'null'], + ], + ]); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it collapses a nullable anyOf to a nullable single type when native support is unavailable', function () { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -38,7 +60,7 @@ function normalizesWithoutThrowing(array $raw): array 'type' => ['string', 'null'], 'minLength' => 1, ]); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback collapse.'); test('it collapses a non-nullable scalar oneOf to a multi-type union', function () { $normalized = normalizesWithoutThrowing([ @@ -226,7 +248,22 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['n'])->toBe(['type' => 'integer']); }); -test('it preserves object shape for a nullable object branch', function () { +test('it preserves object shape inside nullable anyOf when the framework supports it natively', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => [ + 'addr' => ['anyOf' => [ + ['properties' => ['city' => ['type' => 'string']], 'required' => ['city']], + ['type' => 'null'], + ]], + ], + ]); + + expect($normalized['properties']['addr']['anyOf'][0]['type'])->toBe('object'); + expect($normalized['properties']['addr']['anyOf'][0]['properties'])->toHaveKey('city'); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it preserves object shape for a nullable object branch when native support is unavailable', function () { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -239,7 +276,115 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['addr']['type'])->toBe(['object', 'null']); expect($normalized['properties']['addr']['properties'])->toHaveKey('city'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback collapse.'); + +test('it preserves anyOf compositions when the framework deserializer supports them', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => [ + 'content' => ['anyOf' => [ + [ + 'type' => 'object', + 'properties' => ['type' => ['const' => 'article'], 'title' => ['type' => 'string']], + 'required' => ['type', 'title'], + ], + [ + 'type' => 'object', + 'properties' => ['type' => ['const' => 'image'], 'url' => ['type' => 'string']], + 'required' => ['type', 'url'], + ], + ]], + ], + ]); + + expect($normalized['properties']['content']['anyOf'])->toHaveCount(2); + expect($normalized['properties']['content']['anyOf'][0]['properties']['type'])->toBe([ + 'enum' => ['article'], + 'type' => 'string', + ]); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it folds outer object siblings into each branch so the deserializer keeps them', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => ['kind' => ['type' => 'string']], + 'required' => ['kind'], + 'anyOf' => [ + ['type' => 'object', 'properties' => ['name' => ['type' => 'string']], 'required' => ['name']], + ['type' => 'object', 'properties' => ['age' => ['type' => 'integer']], 'required' => ['age']], + ], + ]); + + expect($normalized)->not->toHaveKey('properties'); + expect($normalized['anyOf'])->toHaveCount(2); + + expect($normalized['anyOf'][0]['properties'])->toHaveKeys(['kind', 'name']); + expect($normalized['anyOf'][0]['required'])->toEqualCanonicalizing(['kind', 'name']); + expect($normalized['anyOf'][1]['properties'])->toHaveKeys(['kind', 'age']); + expect($normalized['anyOf'][1]['required'])->toEqualCanonicalizing(['kind', 'age']); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it keeps the shared base property after round-tripping a sibling anyOf through the deserializer', function () { + $type = JsonSchemaFactory::fromArray(SchemaNormalizer::normalize([ + 'type' => 'object', + 'properties' => ['kind' => ['type' => 'string']], + 'required' => ['kind'], + 'anyOf' => [ + ['type' => 'object', 'properties' => ['name' => ['type' => 'string']], 'required' => ['name']], + ['type' => 'object', 'properties' => ['age' => ['type' => 'integer']], 'required' => ['age']], + ], + ])); + + expect(json_encode((new Serializer)->serialize($type)))->toContain('"kind"'); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it keeps the outer description on a preserved anyOf composition', function () { + $normalized = normalizesWithoutThrowing([ + 'description' => 'A union.', + 'anyOf' => [['type' => 'string'], ['type' => 'integer']], + ]); + + expect($normalized['description'])->toBe('A union.'); + expect($normalized['anyOf'])->toHaveCount(2); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it drops empty branches from a preserved anyOf instead of inventing a string branch', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => [ + 'v' => ['anyOf' => [[], ['type' => 'string'], ['type' => 'integer']]], + ], + ]); + + expect($normalized['properties']['v']['anyOf'])->toBe([ + ['type' => 'string'], + ['type' => 'integer'], + ]); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it drops a cyclic anyOf branch instead of inventing a string branch', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => ['node' => ['$ref' => '#/$defs/Node']], + '$defs' => [ + 'Node' => ['anyOf' => [['$ref' => '#/$defs/Node'], ['type' => 'integer']]], + ], + ]); + + expect($normalized['properties']['node']['anyOf'])->toBe([['type' => 'integer']]); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); + +test('it drops a preserved anyOf made up entirely of null branches', function () { + $normalized = normalizesWithoutThrowing([ + 'type' => 'object', + 'properties' => [ + 'v' => ['anyOf' => [['type' => 'null'], ['type' => 'null']]], + ], + ]); + + expect($normalized['properties']['v'])->not->toHaveKey('anyOf'); + expect($normalized['properties']['v']['type'])->toBe('string'); +})->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); test('it types heterogeneous, empty, and homogeneous enums without throwing', function (array $enum, $expected) { $normalized = normalizesWithoutThrowing([ @@ -417,7 +562,7 @@ function normalizesWithoutThrowing(array $raw): array expect($item['type'])->toBe('object'); expect($item['properties'])->toHaveKeys(['type', 'text', 'choices', 'scale']); expect($item['required'])->toBe(['type']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it falls back to the single object branch when fewer than two object variants are present', function () { $normalized = normalizesWithoutThrowing([ @@ -435,7 +580,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value']['type'])->toBe('object'); expect($normalized['properties']['value']['properties'])->toHaveKey('x'); expect($normalized['properties']['value']['properties'])->not->toHaveKey('y'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it merges the object variants and ignores scalar branches when two or more objects are present', function () { $normalized = normalizesWithoutThrowing([ @@ -453,7 +598,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value']['type'])->toBe('object'); expect($normalized['properties']['value']['properties'])->toHaveKeys(['x', 'y']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it recursively merges branch refinements into a generic outer property', function () { $normalized = normalizesWithoutThrowing([ @@ -466,7 +611,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['kind']['enum'])->toBe(['a', 'b']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it recursively merges duplicate nested object properties across object variants', function () { $normalized = normalizesWithoutThrowing([ @@ -482,7 +627,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['item']['properties']['payload']['properties'])->toHaveKeys(['a', 'b']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it ignores a no-opinion variant when intersecting required on a nested object property', function () { $normalized = normalizesWithoutThrowing([ @@ -494,7 +639,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['payload']['required'])->toBe(['a']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it keeps a nested object property open unless every variant closes it', function () { $normalized = normalizesWithoutThrowing([ @@ -505,7 +650,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['payload'])->not->toHaveKey('additionalProperties'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it closes a nested object property only when every variant closes it', function () { $normalized = normalizesWithoutThrowing([ @@ -516,7 +661,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['payload']['additionalProperties'])->toBeFalse(); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it unions scalar property types when the same key differs across object variants', function () { $normalized = normalizesWithoutThrowing([ @@ -527,7 +672,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['id']['type'])->toBe(['string', 'integer']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it keeps the nested object shape when one variant types a property as an object', function () { $normalized = normalizesWithoutThrowing([ @@ -539,7 +684,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['id']['type'])->toBe('object'); expect($normalized['properties']['id']['properties'])->toHaveKey('n'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it does not throw when an object variant has a malformed scalar properties value', function () { $normalized = normalizesWithoutThrowing([ @@ -555,7 +700,7 @@ function normalizesWithoutThrowing(array $raw): array ]); expect($normalized['properties']['item']['properties'])->toHaveKey('b'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it preserves outer schema properties and required when merging anyOf object variants', function () { $normalized = normalizesWithoutThrowing([ @@ -570,7 +715,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['kind', 'name', 'age']); expect($normalized['required'])->toContain('kind'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it treats a branch with no required key as no-opinion rather than zero-required', function () { $normalized = normalizesWithoutThrowing([ @@ -594,7 +739,7 @@ function normalizesWithoutThrowing(array $raw): array expect($item['type'])->toBe('object'); expect($item['properties'])->toHaveKeys(['type', 'text', 'choices', 'scale']); expect($item['required'])->toBe(['type']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it merges nullable object variants and marks result nullable', function () { $normalized = normalizesWithoutThrowing([ @@ -611,7 +756,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['payload']['type'])->toBe(['object', 'null']); expect($normalized['properties']['payload']['properties'])->toHaveKeys(['x', 'y']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it does not promote required fields when only one of multiple branches declares required', function () { $normalized = normalizesWithoutThrowing([ @@ -623,7 +768,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized)->not->toHaveKey('required'); expect($normalized['properties'])->toHaveKeys(['id', 'name', 'code']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it unions enum values for overlapping discriminator properties across anyOf variants', function () { $normalized = normalizesWithoutThrowing([ @@ -638,7 +783,7 @@ function normalizesWithoutThrowing(array $raw): array ->toContain('choice') ->toContain('rating'); expect($normalized['properties'])->toHaveKeys(['kind', 'text', 'choices', 'scale']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it does not bleed branch-specific keys like additionalProperties into the merged result', function () { $normalized = normalizesWithoutThrowing([ @@ -655,7 +800,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value']['properties'])->toHaveKeys(['x', 'y']); expect($normalized['properties']['value'])->not->toHaveKey('additionalProperties'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it prefers the object branch over a scalar branch regardless of branch order', function () { $stringFirst = normalizesWithoutThrowing([ @@ -676,7 +821,7 @@ function normalizesWithoutThrowing(array $raw): array expect($stringFirst['properties'])->toHaveKey('id'); expect($stringFirst['required'])->toBe(['id']); expect($objectFirst)->toBe($stringFirst); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it skips an empty no-opinion branch when intersecting required across object variants', function () { $normalized = normalizesWithoutThrowing([ @@ -691,7 +836,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['type'])->toBe('object'); expect($normalized['required'])->toBe(['type', 'key']); expect($normalized['properties']['type']['enum'])->toBe(['behavioral', 'person', 'cohort']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it skips a leading empty branch instead of degrading the union to a string', function () { $normalized = normalizesWithoutThrowing([ @@ -704,7 +849,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['type'])->toBe('object'); expect($normalized['properties'])->toHaveKeys(['a', 'b']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it carries the first object branch description into the merged result', function () { $normalized = normalizesWithoutThrowing([ @@ -716,7 +861,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['description'])->toBe('Variant A'); expect($normalized['properties'])->toHaveKeys(['a', 'b']); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it keeps additionalProperties false only when every merged object branch agrees', function () { $unanimous = normalizesWithoutThrowing([ @@ -735,7 +880,7 @@ function normalizesWithoutThrowing(array $raw): array expect($unanimous['additionalProperties'])->toBeFalse(); expect($mixed)->not->toHaveKey('additionalProperties'); -}); +})->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); test('it deep-merges overlapping properties across allOf branches', function () { $normalized = normalizesWithoutThrowing([