diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07cf44b6..634b03ca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -375,6 +375,7 @@ direction LR +withFunctionResponse(FunctionResponse $functionResponse) self +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self + +withMessages(...Message $messages) self +usingModel(ModelInterface $model) self +usingModelPreference(...$preferredModels) self +usingModelConfig(ModelConfig $config) self @@ -388,6 +389,8 @@ direction LR +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingFunctionCallResolver(FunctionCallResolverInterface $functionCallResolver) self + +usingMaxFunctionCallIterations(int $maxIterations) self +usingPresencePenalty(float $presencePenalty) self +usingFrequencyPenalty(float $frequencyPenalty) self +usingWebSearch(WebSearch $webSearch) self @@ -567,6 +570,7 @@ direction LR +withFunctionResponse(FunctionResponse $functionResponse) self +withMessageParts(...MessagePart $parts) self +withHistory(...Message $messages) self + +withMessages(...Message $messages) self +usingModel(ModelInterface $model) self +usingModelConfig(ModelConfig $config) self +usingProvider(string $providerIdOrClassName) self @@ -578,6 +582,8 @@ direction LR +usingStopSequences(...string $stopSequences) self +usingCandidateCount(int $candidateCount) self +usingFunctionDeclarations(...FunctionDeclaration $functionDeclarations) self + +usingFunctionCallResolver(FunctionCallResolverInterface $functionCallResolver) self + +usingMaxFunctionCallIterations(int $maxIterations) self +usingPresencePenalty(float $presencePenalty) self +usingFrequencyPenalty(float $frequencyPenalty) self +usingWebSearch(WebSearch $webSearch) self diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 538392db..125301a8 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -30,6 +30,9 @@ use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Tools\Contracts\FunctionCallResolverInterface; +use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -52,11 +55,64 @@ class PromptBuilder { use ModelResolutionTrait; + /** + * Key in the result's additional data under which function call + * resolution details are exposed. + * + * @since n.e.x.t + */ + public const KEY_FUNCTION_CALL_RESOLUTION = 'functionCallResolution'; + + /** + * Default maximum number of function call resolution rounds. + * + * @since n.e.x.t + */ + public const DEFAULT_MAX_FUNCTION_CALL_ITERATIONS = 5; + + /** + * Stop reason: the model produced a response without function calls. + * + * @since n.e.x.t + */ + public const STOP_REASON_COMPLETED = 'completed'; + + /** + * Stop reason: the model requested a function call the resolver cannot resolve. + * + * @since n.e.x.t + */ + public const STOP_REASON_UNRESOLVED_FUNCTION_CALLS = 'unresolvedFunctionCalls'; + + /** + * Stop reason: the model produced one or more incomplete function calls. + * + * @since n.e.x.t + */ + public const STOP_REASON_INCOMPLETE_FUNCTION_CALLS = 'incompleteFunctionCalls'; + + /** + * Stop reason: the maximum number of resolution rounds was reached. + * + * @since n.e.x.t + */ + public const STOP_REASON_MAX_ITERATIONS = 'maxIterations'; + /** * @var list The messages in the conversation. */ protected array $messages = []; + /** + * @var FunctionCallResolverInterface|null The resolver for automatic function call resolution, if enabled. + */ + private ?FunctionCallResolverInterface $functionCallResolver = null; + + /** + * @var int The maximum number of function call resolution rounds. + */ + private int $maxFunctionCallIterations = self::DEFAULT_MAX_FUNCTION_CALL_ITERATIONS; + /** * @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events. */ @@ -216,6 +272,32 @@ public function withHistory(Message ...$messages): self return $this; } + /** + * Appends complete messages to the end of the conversation. + * + * Unlike {@see self::withHistory()}, which prepends messages before the + * current message, this method appends the given messages after all + * existing messages. This allows continuing a conversation, for example by + * appending a model response message and a follow-up user message when + * building a manual function call resolution loop. + * + * The last message must be a user message before generating, so a model + * message should always be followed by a user message. The last appended + * user message becomes the current message. Any parts added afterwards, + * for example with {@see self::withText()}, are added to that message. + * + * @since n.e.x.t + * + * @param Message ...$messages The messages to append. + * @return self + */ + public function withMessages(Message ...$messages): self + { + $this->messages = array_merge($this->messages, $messages); + + return $this; + } + /** * Sets the system instruction. * @@ -331,6 +413,80 @@ public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclar return $this; } + /** + * Enables automatic resolution of function calls during text generation. + * + * When a resolver is set, the text generation methods run a resolution + * loop instead of a single request. Each round executes the function calls + * requested by the model through the resolver, appends the results to the + * conversation, and requests a follow-up response. The loop ends when the + * model produces a response without function calls, when the model response + * contains incomplete function calls, when the resolver cannot resolve a + * requested call (the caller gets that response back to handle it), or when + * the maximum number of rounds is reached. + * + * Resolution follows the first response candidate and only applies to text + * generation. Other capabilities ignore the resolver. When more than one + * candidate is requested, the other candidates are not followed, and the + * final result only contains the candidates of the last round. Token usage + * is aggregated across all rounds. Details about the loop are exposed + * under the {@see self::KEY_FUNCTION_CALL_RESOLUTION} key of the + * additional data of the final result, including the number of rounds, + * the stop reason, the resolved calls, and the full conversation. + * + * Follow-up rounds send the full conversation, so the model must support + * chat history. This capability is added to the requirements when a model + * is discovered. An explicitly set model is used as is, like for every + * other requirement. + * + * Each round dispatches its own {@see BeforeGenerateResultEvent} and + * {@see AfterGenerateResultEvent} with the messages and result of that + * round. No event is dispatched for the final aggregated result. + * + * When the loop stops early, the final response usually contains function + * calls and no text. Use {@see self::generateTextResult()} to receive that + * response, since {@see self::generateText()} throws when the response has + * no text. + * + * The maximum number of rounds can be configured with + * {@see self::usingMaxFunctionCallIterations()}. + * + * @since n.e.x.t + * + * @param FunctionCallResolverInterface $functionCallResolver The resolver executing function calls. + * @return self + */ + public function usingFunctionCallResolver(FunctionCallResolverInterface $functionCallResolver): self + { + $this->functionCallResolver = $functionCallResolver; + return $this; + } + + /** + * Sets the maximum number of function call resolution rounds. + * + * Each round executes the function calls from one model response and + * requests a follow-up response. Only relevant when a resolver is set with + * {@see self::usingFunctionCallResolver()}. Default 5. + * + * @since n.e.x.t + * + * @param int $maxIterations The maximum number of resolution rounds. + * @return self + * @throws InvalidArgumentException If the maximum number of iterations is less than 1. + */ + public function usingMaxFunctionCallIterations(int $maxIterations): self + { + if ($maxIterations < 1) { + throw new InvalidArgumentException( + 'The maximum number of function call resolution iterations must be at least 1.' + ); + } + + $this->maxFunctionCallIterations = $maxIterations; + return $this; + } + /** * Sets the presence penalty for generation. * @@ -618,7 +774,7 @@ public function isSupported(?CapabilityEnum $capability = null): bool } // Build requirements with the specified capability - $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); + $requirements = $this->buildModelRequirements($capability); return $this->modelResolver->isSupported($requirements); } @@ -745,22 +901,232 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi $model = $this->getConfiguredModel($capability); + $result = $this->executeGenerationRound($model, $capability, $this->messages); + + // Run the function call resolution loop if a resolver is configured. + if ($this->functionCallResolver !== null && $capability->isTextGeneration()) { + $result = $this->resolveFunctionCalls($model, $capability, $result); + } + + return $result; + } + + /** + * Executes a single generation round, dispatching lifecycle events. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to use for generation. + * @param CapabilityEnum $capability The capability to use. + * @param list $messages The messages to send. + * @return GenerativeAiResult The generated result. + * @throws RuntimeException If the model doesn't support the required capability. + */ + private function executeGenerationRound( + ModelInterface $model, + CapabilityEnum $capability, + array $messages + ): GenerativeAiResult { // Dispatch BeforeGenerateResultEvent $this->dispatchEvent( - new BeforeGenerateResultEvent($this->messages, $model, $capability) + new BeforeGenerateResultEvent($messages, $model, $capability) ); // Route to the appropriate generation method based on capability - $result = $this->executeModelGeneration($model, $capability, $this->messages); + $result = $this->executeModelGeneration($model, $capability, $messages); // Dispatch AfterGenerateResultEvent $this->dispatchEvent( - new AfterGenerateResultEvent($this->messages, $model, $capability, $result) + new AfterGenerateResultEvent($messages, $model, $capability, $result) ); return $result; } + /** + * Runs the function call resolution loop on a generated result. + * + * Each round executes the function calls requested by the model through + * the configured resolver, appends the model response and the function + * responses to a copy of the conversation, and requests a follow-up + * response. See {@see self::usingFunctionCallResolver()} for the + * termination conditions. The builder's own message list is not modified. + * + * The returned result carries the aggregated token usage of all rounds and + * exposes details about the loop under the + * {@see self::KEY_FUNCTION_CALL_RESOLUTION} key of its additional data. + * + * @since n.e.x.t + * + * @param ModelInterface $model The resolved model to use for follow-up rounds. + * @param CapabilityEnum $capability The capability in use. + * @param GenerativeAiResult $result The result of the initial request. + * @return GenerativeAiResult The final result. + */ + private function resolveFunctionCalls( + ModelInterface $model, + CapabilityEnum $capability, + GenerativeAiResult $result + ): GenerativeAiResult { + /** @var FunctionCallResolverInterface $resolver */ + $resolver = $this->functionCallResolver; + + $messages = $this->messages; + $rounds = 0; + $tokenUsage = $result->getTokenUsage(); + $resolvedCalls = []; + $stopReason = self::STOP_REASON_COMPLETED; + + while (true) { + $candidate = $result->getCandidates()[0]; + $message = $candidate->getMessage(); + $functionCalls = $this->getFunctionCalls($message); + + if (empty($functionCalls)) { + break; + } + + if ($rounds >= $this->maxFunctionCallIterations) { + $stopReason = self::STOP_REASON_MAX_ITERATIONS; + break; + } + + if ( + !$candidate->getFinishReason()->isToolCalls() || + !$this->areFunctionCallsComplete($functionCalls) + ) { + $stopReason = self::STOP_REASON_INCOMPLETE_FUNCTION_CALLS; + break; + } + + /* + * Check all calls before executing any, so that a round is either + * fully executed or handed back to the caller untouched. + */ + foreach ($functionCalls as $functionCall) { + if (!$resolver->canResolve($functionCall)) { + $stopReason = self::STOP_REASON_UNRESOLVED_FUNCTION_CALLS; + break 2; + } + } + + $responseParts = []; + foreach ($functionCalls as $functionCall) { + $functionResponse = $resolver->resolve($functionCall); + $responseParts[] = new MessagePart($functionResponse); + $resolvedCalls[] = [ + 'id' => $functionCall->getId(), + 'name' => $functionCall->getName(), + ]; + } + + $messages[] = $message; + $messages[] = new UserMessage($responseParts); + $rounds++; + + $result = $this->executeGenerationRound($model, $capability, $messages); + $tokenUsage = $this->aggregateTokenUsage($tokenUsage, $result->getTokenUsage()); + } + + $messages[] = $result->toMessage(); + + $additionalData = $result->getAdditionalData(); + $additionalData[self::KEY_FUNCTION_CALL_RESOLUTION] = [ + 'rounds' => $rounds, + 'stopReason' => $stopReason, + 'resolvedCalls' => $resolvedCalls, + 'messages' => array_map( + static function (Message $message): array { + return $message->toArray(); + }, + $messages + ), + ]; + + return new GenerativeAiResult( + $result->getId(), + $result->getCandidates(), + $tokenUsage, + $result->getProviderMetadata(), + $result->getModelMetadata(), + $additionalData + ); + } + + /** + * Retrieves the function calls contained in a message. + * + * @since n.e.x.t + * + * @param Message $message The message to inspect. + * @return list The function calls in the message. + */ + private function getFunctionCalls(Message $message): array + { + $functionCalls = []; + + foreach ($message->getParts() as $part) { + if ($part->getType()->isFunctionCall()) { + $functionCall = $part->getFunctionCall(); + if ($functionCall instanceof FunctionCall) { + $functionCalls[] = $functionCall; + } + } + } + + return $functionCalls; + } + + /** + * Checks whether all function calls contain the data required for execution. + * + * Function call IDs are provider-specific and therefore optional, but a + * non-empty function name is always required to safely resolve a call. + * + * @since n.e.x.t + * + * @param list $functionCalls The function calls to check. + * @return bool True if every function call is complete, false otherwise. + */ + private function areFunctionCallsComplete(array $functionCalls): bool + { + foreach ($functionCalls as $functionCall) { + $name = $functionCall->getName(); + if ($name === null || trim($name) === '') { + return false; + } + } + + return true; + } + + /** + * Adds up two token usage objects. + * + * A missing thought token count counts as zero, since providers omit it + * when no thinking happened. + * + * @since n.e.x.t + * + * @param TokenUsage $total The running total. + * @param TokenUsage $addition The usage to add. + * @return TokenUsage The combined token usage. + */ + private function aggregateTokenUsage(TokenUsage $total, TokenUsage $addition): TokenUsage + { + $thoughtTokens = null; + if ($total->getThoughtTokens() !== null || $addition->getThoughtTokens() !== null) { + $thoughtTokens = (int) $total->getThoughtTokens() + (int) $addition->getThoughtTokens(); + } + + return new TokenUsage( + $total->getPromptTokens() + $addition->getPromptTokens(), + $total->getCompletionTokens() + $addition->getCompletionTokens(), + $total->getTotalTokens() + $addition->getTotalTokens(), + $thoughtTokens + ); + } + /** * Executes the model generation based on capability. * @@ -936,10 +1302,15 @@ public function generateVideoResult(): GenerativeAiResult /** * Generates text from the prompt. * + * When a function call resolver is set and the resolution loop stops + * early, the final response may contain no text. Use + * {@see self::generateTextResult()} to handle that case. + * * @since 0.1.0 * * @return string The generated text. * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the final response contains no text. */ public function generateText(): string { @@ -949,6 +1320,11 @@ public function generateText(): string /** * Generates multiple text candidates from the prompt. * + * When a function call resolver is set and the resolution loop stops + * early, the final response may contain no text, and candidates without + * text are skipped. Use {@see self::generateTextResult()} to handle that + * case. + * * @since 0.1.0 * * @param int|null $candidateCount The number of candidates to generate. @@ -1137,11 +1513,30 @@ protected function appendPartToMessages(MessagePart $part): void */ private function getConfiguredModel(CapabilityEnum $capability): ModelInterface { - $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); + $requirements = $this->buildModelRequirements($capability); return $this->modelResolver->resolve($requirements, $this->modelConfig, 'prompt'); } + /** + * Builds the model requirements for the prompt's complete execution flow. + * + * @since n.e.x.t + * + * @param CapabilityEnum $capability The capability the model will be using. + * @return ModelRequirements The requirements for model selection. + */ + private function buildModelRequirements(CapabilityEnum $capability): ModelRequirements + { + $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); + + if ($this->functionCallResolver !== null && $capability->isTextGeneration()) { + $requirements = $requirements->withRequiredCapability(CapabilityEnum::chatHistory()); + } + + return $requirements; + } + /** * Parses various input types into a Message with the given role. * diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 2441b2e4..7086a0ba 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -85,6 +85,28 @@ public function getRequiredCapabilities(): array return $this->requiredCapabilities; } + /** + * Returns requirements that also include the given capability. + * + * @since n.e.x.t + * + * @param CapabilityEnum $capability The capability to require. + * @return self The updated requirements. + */ + public function withRequiredCapability(CapabilityEnum $capability): self + { + foreach ($this->requiredCapabilities as $requiredCapability) { + if ($requiredCapability->equals($capability)) { + return $this; + } + } + + $requiredCapabilities = $this->requiredCapabilities; + $requiredCapabilities[] = $capability; + + return new self($requiredCapabilities, $this->requiredOptions); + } + /** * Gets the options that the model must support with specific values. * diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..655d2943 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -206,45 +206,60 @@ protected function prepareGenerateTextParams(array $prompt): array */ protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array { - $messagesParam = array_map( - function (Message $message): array { - // Special case: Function response. - $messageParts = $message->getParts(); - if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) { - $functionResponse = $messageParts[0]->getFunctionResponse(); + $messagesParam = []; + foreach ($messages as $message) { + $messageParts = $message->getParts(); + $functionResponseParts = array_values(array_filter( + $messageParts, + static function (MessagePart $part): bool { + return $part->getType()->isFunctionResponse(); + } + )); + + if (!empty($functionResponseParts)) { + if (count($functionResponseParts) !== count($messageParts)) { + throw new InvalidArgumentException( + 'Function responses cannot be combined with other message parts.' + ); + } + + foreach ($functionResponseParts as $part) { + $functionResponse = $part->getFunctionResponse(); if (!$functionResponse) { // This should be impossible due to class internals, but still needs to be checked. throw new RuntimeException( 'The function response typed message part must contain a function response.' ); } - return [ + + $messagesParam[] = [ 'role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId(), ]; } - $messageData = [ - 'role' => $this->getMessageRoleString($message->getRole()), - 'content' => array_values(array_filter(array_map( - [$this, 'getMessagePartContentData'], - $messageParts - ))), - ]; + continue; + } - // Only include tool_calls if there are any (OpenAI rejects empty arrays). - $toolCalls = array_values(array_filter(array_map( - [$this, 'getMessagePartToolCallData'], + $messageData = [ + 'role' => $this->getMessageRoleString($message->getRole()), + 'content' => array_values(array_filter(array_map( + [$this, 'getMessagePartContentData'], $messageParts - ))); - if (!empty($toolCalls)) { - $messageData['tool_calls'] = $toolCalls; - } + ))), + ]; - return $messageData; - }, - $messages - ); + // Only include tool_calls if there are any (OpenAI rejects empty arrays). + $toolCalls = array_values(array_filter(array_map( + [$this, 'getMessagePartToolCallData'], + $messageParts + ))); + if (!empty($toolCalls)) { + $messageData['tool_calls'] = $toolCalls; + } + + $messagesParam[] = $messageData; + } if ($systemInstruction) { array_unshift( @@ -363,9 +378,9 @@ protected function getMessagePartContentData(MessagePart $part): ?array return null; } if ($type->isFunctionResponse()) { - // Special case: Function response. + // Special case: Function responses are handled in `prepareMessagesParam()`. throw new InvalidArgumentException( - 'The API only allows a single function response, as the only content of the message.' + 'Function responses cannot be combined with other message parts.' ); } throw new InvalidArgumentException( diff --git a/src/Tools/Contracts/FunctionCallResolverInterface.php b/src/Tools/Contracts/FunctionCallResolverInterface.php new file mode 100644 index 00000000..bf508bd2 --- /dev/null +++ b/src/Tools/Contracts/FunctionCallResolverInterface.php @@ -0,0 +1,61 @@ + The calls passed to canResolve(). + */ + public array $checkedCalls = []; + + /** + * @var list The calls passed to resolve(). + */ + public array $resolvedCalls = []; + + /** + * @param callable|null $canResolveCallback Optional callback receiving a FunctionCall and returning a bool. + * @param callable|null $resolveCallback Optional callback receiving a FunctionCall and returning a + * FunctionResponse. + */ + public function __construct(?callable $canResolveCallback = null, ?callable $resolveCallback = null) + { + $this->canResolveCallback = $canResolveCallback; + $this->resolveCallback = $resolveCallback; + } + + /** + * {@inheritDoc} + */ + public function canResolve(FunctionCall $functionCall): bool + { + $this->checkedCalls[] = $functionCall; + + if ($this->canResolveCallback !== null) { + return (bool) ($this->canResolveCallback)($functionCall); + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function resolve(FunctionCall $functionCall): FunctionResponse + { + $this->resolvedCalls[] = $functionCall; + + if ($this->resolveCallback !== null) { + return ($this->resolveCallback)($functionCall); + } + + return new FunctionResponse( + $functionCall->getId(), + $functionCall->getName(), + ['status' => 'ok'] + ); + } +} diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index e4fb2c80..25fd5c12 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Tests\traits; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\Enums\ModalityEnum; @@ -54,11 +56,24 @@ protected function createRegistryWithMockProvider(): ProviderRegistry */ protected function createTestResult(string $content = 'Test response'): GenerativeAiResult { - $candidate = new Candidate( - new ModelMessage([new MessagePart($content)]), - FinishReasonEnum::stop() - ); - $tokenUsage = new TokenUsage(10, 20, 30); + return $this->createTestResultWithMessage(new ModelMessage([new MessagePart($content)])); + } + + /** + * Creates a test GenerativeAiResult with the given model message. + * + * @param Message $message The model message. + * @param TokenUsage|null $tokenUsage Optional token usage. Defaults to 10/20/30. + * @param FinishReasonEnum|null $finishReason Optional finish reason. Defaults to stop. + * @return GenerativeAiResult + */ + protected function createTestResultWithMessage( + Message $message, + ?TokenUsage $tokenUsage = null, + ?FinishReasonEnum $finishReason = null + ): GenerativeAiResult { + $candidate = new Candidate($message, $finishReason ?? FinishReasonEnum::stop()); + $tokenUsage = $tokenUsage ?? new TokenUsage(10, 20, 30); $providerMetadata = new ProviderMetadata( 'mock', @@ -212,6 +227,27 @@ protected function createMockTextGenerationModel( GenerativeAiResult $result, ?ModelMetadata $metadata = null ): ModelInterface { + return $this->createScriptedTextGenerationModel([$result], $metadata); + } + + /** + * Creates a mock text generation model that returns scripted results in order. + * + * Each call to generateTextResult() returns the next result from the given + * list. Once the list is exhausted, the last result is returned again. + * + * @param list $results The results to return, in order. Must not be empty. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&TextGenerationModelInterface The mock model. + */ + protected function createScriptedTextGenerationModel( + array $results, + ?ModelMetadata $metadata = null + ): ModelInterface { + if (empty($results)) { + throw new InvalidArgumentException('At least one scripted result must be provided.'); + } + $metadata = $metadata ?? $this->createTestTextModelMetadata(); $providerMetadata = new ProviderMetadata( @@ -223,21 +259,26 @@ protected function createMockTextGenerationModel( return new class ( $metadata, $providerMetadata, - $result + $results ) implements ModelInterface, TextGenerationModelInterface { private ModelMetadata $metadata; private ProviderMetadata $providerMetadata; - private GenerativeAiResult $result; + /** @var list */ + private array $results; + private int $callCount = 0; private ModelConfig $config; + /** + * @param list $results + */ public function __construct( ModelMetadata $metadata, ProviderMetadata $providerMetadata, - GenerativeAiResult $result + array $results ) { $this->metadata = $metadata; $this->providerMetadata = $providerMetadata; - $this->result = $result; + $this->results = $results; $this->config = new ModelConfig(); } @@ -263,7 +304,9 @@ public function getConfig(): ModelConfig public function generateTextResult(array $prompt): GenerativeAiResult { - return $this->result; + $index = min($this->callCount, count($this->results) - 1); + $this->callCount++; + return $this->results[$index]; } }; } diff --git a/tests/unit/Builders/PromptBuilderFunctionCallResolutionTest.php b/tests/unit/Builders/PromptBuilderFunctionCallResolutionTest.php new file mode 100644 index 00000000..e49059d7 --- /dev/null +++ b/tests/unit/Builders/PromptBuilderFunctionCallResolutionTest.php @@ -0,0 +1,574 @@ +registry = $this->createRegistryWithMockProvider(); + $this->dispatcher = new MockEventDispatcher(); + } + + /** + * Creates a result whose message requests the given function calls. + * + * @param FunctionCall ...$functionCalls The function calls to include. + * @return GenerativeAiResult The result. + */ + private function createFunctionCallResult(FunctionCall ...$functionCalls): GenerativeAiResult + { + $parts = []; + foreach ($functionCalls as $functionCall) { + $parts[] = new MessagePart($functionCall); + } + + return $this->createTestResultWithMessage( + new ModelMessage($parts), + null, + FinishReasonEnum::toolCalls() + ); + } + + /** + * Creates a prompt builder with the event dispatcher wired. + * + * @param string $prompt The prompt text. + * @return PromptBuilder The builder. + */ + private function createBuilder(string $prompt = 'Hello, world!'): PromptBuilder + { + return new PromptBuilder($this->registry, $prompt, $this->dispatcher); + } + + /** + * Tests that a function call is resolved and the final answer returned. + * + * @return void + */ + public function testResolvesFunctionCallsAndReturnsFinalResult(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult(new FunctionCall('call-1', 'get_weather', ['city' => 'Berlin'])), + $this->createTestResult('Final answer'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertSame('Final answer', $result->toText()); + $this->assertCount(1, $resolver->resolvedCalls); + $this->assertSame('get_weather', $resolver->resolvedCalls[0]->getName()); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(1, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_COMPLETED, $resolution['stopReason']); + $this->assertSame([['id' => 'call-1', 'name' => 'get_weather']], $resolution['resolvedCalls']); + } + + /** + * Tests the ordering and roles of the transcript sent in follow-up rounds. + * + * @return void + */ + public function testFollowUpRequestContainsFullTranscript(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult(new FunctionCall('call-1', 'get_weather', ['city' => 'Berlin'])), + $this->createTestResult('Final answer'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $this->assertCount(2, $beforeEvents); + + $followUpMessages = $beforeEvents[1]->getMessages(); + $this->assertCount(3, $followUpMessages); + $this->assertTrue($followUpMessages[0]->getRole()->isUser()); + $this->assertTrue($followUpMessages[1]->getRole()->isModel()); + $this->assertTrue($followUpMessages[2]->getRole()->isUser()); + + // The model message carries the function call, the user message the response. + $this->assertTrue($followUpMessages[1]->getParts()[0]->getType()->isFunctionCall()); + $responsePart = $followUpMessages[2]->getParts()[0]; + $this->assertTrue($responsePart->getType()->isFunctionResponse()); + $functionResponse = $responsePart->getFunctionResponse(); + $this->assertNotNull($functionResponse); + $this->assertSame('call-1', $functionResponse->getId()); + $this->assertSame(['status' => 'ok'], $functionResponse->getResponse()); + + // The exposed transcript also contains the final model response. + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertCount(4, $resolution['messages']); + } + + /** + * Tests that multiple function calls in one response are resolved into one message. + * + * @return void + */ + public function testResolvesMultipleFunctionCallsInOneResponse(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult( + new FunctionCall('call-1', 'get_weather', ['city' => 'Berlin']), + new FunctionCall('call-2', 'get_time', ['city' => 'Berlin']) + ), + $this->createTestResult('Final answer'), + ]); + $resolver = new MockFunctionCallResolver(); + + $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertCount(2, $resolver->resolvedCalls); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $responseMessage = $beforeEvents[1]->getMessages()[2]; + $this->assertCount(2, $responseMessage->getParts()); + $this->assertTrue($responseMessage->getParts()[0]->getType()->isFunctionResponse()); + $this->assertTrue($responseMessage->getParts()[1]->getType()->isFunctionResponse()); + } + + /** + * Tests that the loop stops without executing any call when one call cannot be resolved. + * + * @return void + */ + public function testStopsWithoutExecutingWhenACallCannotBeResolved(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult( + new FunctionCall('call-1', 'known_function', []), + new FunctionCall('call-2', 'unknown_function', []) + ), + $this->createTestResult('Never reached'), + ]); + $resolver = new MockFunctionCallResolver( + static function (FunctionCall $functionCall): bool { + return $functionCall->getName() === 'known_function'; + } + ); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + // No call was executed, and the function call response is handed back. + $this->assertSame([], $resolver->resolvedCalls); + $this->assertTrue($result->toMessage()->getParts()[0]->getType()->isFunctionCall()); + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(0, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_UNRESOLVED_FUNCTION_CALLS, $resolution['stopReason']); + $this->assertSame([], $resolution['resolvedCalls']); + } + + /** + * Tests that a truncated function call response is not executed. + * + * @return void + */ + public function testDoesNotResolveFunctionCallsFromTruncatedResponse(): void + { + $truncatedResult = $this->createTestResultWithMessage( + new ModelMessage([ + new MessagePart(new FunctionCall('call-1', 'get_weather', ['city' => 'Ber'])) + ]), + null, + FinishReasonEnum::length() + ); + $model = $this->createScriptedTextGenerationModel([ + $truncatedResult, + $this->createTestResult('Never reached'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertSame([], $resolver->checkedCalls); + $this->assertSame([], $resolver->resolvedCalls); + $this->assertTrue($result->toMessage()->getParts()[0]->getType()->isFunctionCall()); + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(0, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_INCOMPLETE_FUNCTION_CALLS, $resolution['stopReason']); + $this->assertSame([], $resolution['resolvedCalls']); + } + + /** + * Tests that one incomplete call prevents a parallel batch from executing. + * + * @return void + */ + public function testDoesNotResolveParallelCallsWhenOneHasNoName(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult( + new FunctionCall('call-1', 'get_weather', []), + new FunctionCall('call-2', null, []) + ), + $this->createTestResult('Never reached'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertSame([], $resolver->checkedCalls); + $this->assertSame([], $resolver->resolvedCalls); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(0, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_INCOMPLETE_FUNCTION_CALLS, $resolution['stopReason']); + $this->assertSame([], $resolution['resolvedCalls']); + } + + /** + * Tests that a completed name-only function call remains resolvable. + * + * @return void + */ + public function testResolvesCompletedFunctionCallWithoutId(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult(new FunctionCall(null, 'get_weather', [])), + $this->createTestResult('Final answer'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertSame('Final answer', $result->toText()); + $this->assertCount(1, $resolver->checkedCalls); + $this->assertCount(1, $resolver->resolvedCalls); + $this->assertSame('get_weather', $resolver->resolvedCalls[0]->getName()); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(1, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_COMPLETED, $resolution['stopReason']); + } + + /** + * Tests that the loop stops after the maximum number of iterations. + * + * @return void + */ + public function testStopsAtMaxIterations(): void + { + // The model requests a function call on every round. + $model = $this->createScriptedTextGenerationModel([ + $this->createFunctionCallResult(new FunctionCall('call-1', 'get_weather', [])), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->usingMaxFunctionCallIterations(2) + ->generateTextResult(); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(2, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_MAX_ITERATIONS, $resolution['stopReason']); + // Calls from a response beyond the iteration limit must not be inspected or resolved. + $this->assertCount(2, $resolver->checkedCalls); + $this->assertCount(2, $resolver->resolvedCalls); + // Initial request plus one follow-up per round. + $this->assertCount(3, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + } + + /** + * Tests that token usage is aggregated across all rounds. + * + * @return void + */ + public function testAggregatesTokenUsageAcrossRounds(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createTestResultWithMessage( + new ModelMessage([new MessagePart(new FunctionCall('call-1', 'get_weather', []))]), + new TokenUsage(1, 2, 3), + FinishReasonEnum::toolCalls() + ), + $this->createTestResultWithMessage( + new ModelMessage([new MessagePart('Final answer')]), + new TokenUsage(10, 20, 30) + ), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $tokenUsage = $result->getTokenUsage(); + $this->assertSame(11, $tokenUsage->getPromptTokens()); + $this->assertSame(22, $tokenUsage->getCompletionTokens()); + $this->assertSame(33, $tokenUsage->getTotalTokens()); + $this->assertNull($tokenUsage->getThoughtTokens()); + } + + /** + * Tests that a missing thought token count in one round counts as zero. + * + * @return void + */ + public function testAggregatesThoughtTokensWhenOnlySomeRoundsReportThem(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createTestResultWithMessage( + new ModelMessage([new MessagePart(new FunctionCall('call-1', 'get_weather', []))]), + new TokenUsage(1, 2, 3), + FinishReasonEnum::toolCalls() + ), + $this->createTestResultWithMessage( + new ModelMessage([new MessagePart('Final answer')]), + new TokenUsage(10, 20, 30, 5) + ), + ]); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver(new MockFunctionCallResolver()) + ->generateTextResult(); + + $this->assertSame(5, $result->getTokenUsage()->getThoughtTokens()); + } + + /** + * Tests that a response without function calls completes with zero rounds. + * + * @return void + */ + public function testCompletesWithZeroRoundsWithoutFunctionCalls(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createTestResult('Immediate answer'), + ]); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateTextResult(); + + $this->assertSame('Immediate answer', $result->toText()); + + $resolution = $result->getAdditionalData()[PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION]; + $this->assertSame(0, $resolution['rounds']); + $this->assertSame(PromptBuilder::STOP_REASON_COMPLETED, $resolution['stopReason']); + $this->assertSame([], $resolution['resolvedCalls']); + $this->assertCount(2, $resolution['messages']); + } + + /** + * Tests that automatic resolution requires chat history support. + * + * @return void + */ + public function testResolverRequiresChatHistorySupport(): void + { + $metadata = new ModelMetadata( + 'text-only-model', + 'Text-only Model', + [CapabilityEnum::textGeneration()], + [new SupportedOption(OptionEnum::inputModalities())] + ); + $model = $this->createMockTextGenerationModel($this->createTestResult('Answer'), $metadata); + $builder = $this->createBuilder()->usingModel($model); + + $this->assertTrue($builder->isSupportedForTextGeneration()); + + $builder->usingFunctionCallResolver(new MockFunctionCallResolver()); + + $this->assertFalse($builder->isSupportedForTextGeneration()); + } + + /** + * Tests that automatic resolution accepts a model with chat history support. + * + * @return void + */ + public function testResolverSupportsModelWithChatHistory(): void + { + $metadata = new ModelMetadata( + 'chat-model', + 'Chat Model', + [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], + [new SupportedOption(OptionEnum::inputModalities())] + ); + $model = $this->createMockTextGenerationModel($this->createTestResult('Answer'), $metadata); + + $builder = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver(new MockFunctionCallResolver()); + + $this->assertTrue($builder->isSupportedForTextGeneration()); + } + + /** + * Tests that an invalid maximum number of iterations is rejected. + * + * @return void + */ + public function testRejectsInvalidMaxIterations(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createBuilder()->usingMaxFunctionCallIterations(0); + } + + /** + * Tests that the resolver is ignored for non-text generation. + * + * @return void + */ + public function testResolverIsIgnoredForImageGeneration(): void + { + $model = $this->createMockImageGenerationModel($this->createTestResult('image')); + $resolver = new MockFunctionCallResolver(); + + $result = $this->createBuilder() + ->usingModel($model) + ->usingFunctionCallResolver($resolver) + ->generateImageResult(); + + $this->assertArrayNotHasKey( + PromptBuilder::KEY_FUNCTION_CALL_RESOLUTION, + $result->getAdditionalData() + ); + $this->assertSame([], $resolver->checkedCalls); + } + + /** + * Tests that withMessages() appends full messages to the conversation. + * + * @return void + */ + public function testWithMessagesAppendsToConversation(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createTestResult('Answer'), + ]); + + $this->createBuilder('First question') + ->withMessages( + new ModelMessage([new MessagePart('First answer')]), + new UserMessage([new MessagePart('Follow-up question')]) + ) + ->usingModel($model) + ->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $sentMessages = $beforeEvents[0]->getMessages(); + + $this->assertCount(3, $sentMessages); + $this->assertTrue($sentMessages[0]->getRole()->isUser()); + $this->assertSame('First question', $sentMessages[0]->getParts()[0]->getText()); + $this->assertTrue($sentMessages[1]->getRole()->isModel()); + $this->assertSame('First answer', $sentMessages[1]->getParts()[0]->getText()); + $this->assertTrue($sentMessages[2]->getRole()->isUser()); + $this->assertSame('Follow-up question', $sentMessages[2]->getParts()[0]->getText()); + } + + /** + * Tests that withMessages() appends after the current message while withHistory() prepends. + * + * @return void + */ + public function testWithMessagesAndWithHistoryOrdering(): void + { + $model = $this->createScriptedTextGenerationModel([ + $this->createTestResult('Answer'), + ]); + + $this->createBuilder('Current question') + ->withHistory( + new UserMessage([new MessagePart('Historical question')]), + new ModelMessage([new MessagePart('Historical answer')]) + ) + ->withMessages( + new ModelMessage([new MessagePart('Appended answer')]), + new UserMessage([new MessagePart('Appended question')]) + ) + ->usingModel($model) + ->generateTextResult(); + + $beforeEvents = $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class); + $sentMessages = $beforeEvents[0]->getMessages(); + + $this->assertCount(5, $sentMessages); + $this->assertSame('Historical question', $sentMessages[0]->getParts()[0]->getText()); + $this->assertSame('Historical answer', $sentMessages[1]->getParts()[0]->getText()); + $this->assertSame('Current question', $sentMessages[2]->getParts()[0]->getText()); + $this->assertSame('Appended answer', $sentMessages[3]->getParts()[0]->getText()); + $this->assertSame('Appended question', $sentMessages[4]->getParts()[0]->getText()); + } +} diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 23a58959..ee352fb6 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -36,6 +36,7 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\mocks\MockFunctionCallResolver; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -3307,6 +3308,45 @@ public function testIsSupportedForText(): void $this->assertTrue($builder->isSupportedForTextGeneration()); } + /** + * Tests model discovery requires chat history for automatic function call resolution. + * + * @return void + */ + public function testModelDiscoveryRequiresChatHistoryForFunctionCallResolution(): void + { + $result = $this->createTestResult('Answer'); + $metadata = new ModelMetadata( + 'chat-model', + 'Chat Model', + [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], + [new SupportedOption(OptionEnum::inputModalities())] + ); + $model = $this->createMockTextGenerationModel($result, $metadata); + $providerMetadata = $model->providerMetadata(); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->callback(static function (ModelRequirements $requirements): bool { + return $requirements->getRequiredCapabilities() === [ + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory(), + ]; + })) + ->willReturn([new ProviderModelsMetadata($providerMetadata, [$metadata])]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with($providerMetadata->getId(), 'chat-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $actualResult = (new PromptBuilder($this->registry, 'Test prompt')) + ->usingFunctionCallResolver(new MockFunctionCallResolver()) + ->generateTextResult(); + + $this->assertSame('Answer', $actualResult->toText()); + } + /** * Tests isSupportedForImageGeneration convenience method. * diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 16efdddd..dd10dd5e 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -62,6 +62,31 @@ public function testWithEmptyCapabilitiesAndOptions(): void $this->assertEquals([], $requirements->getRequiredOptions()); } + /** + * Tests adding a required capability without mutating the original requirements. + * + * @return void + */ + public function testWithRequiredCapability(): void + { + $options = [new RequiredOption(OptionEnum::temperature(), 0.7)]; + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], $options); + + $updatedRequirements = $requirements->withRequiredCapability(CapabilityEnum::chatHistory()); + + $this->assertNotSame($requirements, $updatedRequirements); + $this->assertSame( + [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], + $updatedRequirements->getRequiredCapabilities() + ); + $this->assertSame($options, $updatedRequirements->getRequiredOptions()); + $this->assertSame([CapabilityEnum::textGeneration()], $requirements->getRequiredCapabilities()); + $this->assertSame( + $updatedRequirements, + $updatedRequirements->withRequiredCapability(CapabilityEnum::chatHistory()) + ); + } + /** * Tests JSON schema generation. * diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..2e766df1 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -548,6 +548,63 @@ public function testPrepareMessagesParamFunctionResponse(): void $this->assertEquals('call_1', $prepared[0]['tool_call_id']); } + /** + * Tests prepareMessagesParam() with multiple function responses from one model turn. + * + * @return void + */ + public function testPrepareMessagesParamMultipleFunctionResponses(): void + { + $message = new Message( + MessageRoleEnum::user(), + [ + new MessagePart(new FunctionResponse('call_1', 'get_weather', ['temperature' => 18])), + new MessagePart(new FunctionResponse('call_2', 'get_time', ['time' => '10:30'])), + ] + ); + $model = $this->createModel(); + + $prepared = $model->exposePrepareMessagesParam([$message]); + + $this->assertSame( + [ + [ + 'role' => 'tool', + 'content' => json_encode(['temperature' => 18]), + 'tool_call_id' => 'call_1', + ], + [ + 'role' => 'tool', + 'content' => json_encode(['time' => '10:30']), + 'tool_call_id' => 'call_2', + ], + ], + $prepared + ); + } + + /** + * Tests prepareMessagesParam() with a function response mixed with other parts (should throw exception). + * + * @return void + */ + public function testPrepareMessagesParamFunctionResponseMixedWithOtherParts(): void + { + $message = new Message( + MessageRoleEnum::user(), + [ + new MessagePart(new FunctionResponse('call_1', 'get_weather', ['temperature' => 18])), + new MessagePart('Some extra text'), + ] + ); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Function responses cannot be combined with other message parts.'); + + $model->exposePrepareMessagesParam([$message]); + } + /** * Tests getMessageRoleString() method. * @@ -713,9 +770,7 @@ public function testGetMessagePartContentDataFunctionResponsePart(): void $model = $this->createModel(); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'The API only allows a single function response, as the only content of the message.' - ); + $this->expectExceptionMessage('Function responses cannot be combined with other message parts.'); $model->exposeGetMessagePartContentData($part); }