diff --git a/cli.php b/cli.php index dfb9fec3..1ec21f30 100755 --- a/cli.php +++ b/cli.php @@ -9,6 +9,10 @@ * GOOGLE_API_KEY=123456 php cli.php 'Your prompt here' --providerId=google --modelId=gemini-2.5-flash * OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai * GOOGLE_API_KEY=123456 OPENAI_API_KEY=123456 php cli.php 'Your prompt here' + * + * For large prompts (e.g., with images), use stdin or file input: + * cat prompt.json | php cli.php - --providerId=openai --modelId=gpt-4o + * php cli.php @prompt.json --providerId=openai --modelId=gpt-4o */ declare(strict_types=1); @@ -89,7 +93,23 @@ function logError(string $message, int $exit_code = 1): void } // Prompt input. Allow complex input as a JSON string. +// Use "-" to read from stdin, or "@/path/to/file" to read from a file. $promptInput = $positional_args[0]; +if ($promptInput === '-') { + $promptInput = file_get_contents('php://stdin'); + if ($promptInput === false) { + logError('Failed to read prompt from stdin.'); + } +} elseif (str_starts_with($promptInput, '@')) { + $filePath = substr($promptInput, 1); + if (!file_exists($filePath)) { + logError("Prompt file not found: {$filePath}"); + } + $promptInput = file_get_contents($filePath); + if ($promptInput === false) { + logError("Failed to read prompt from file: {$filePath}"); + } +} if (str_starts_with($promptInput, '{') || str_starts_with($promptInput, '[')) { $decodedInput = json_decode($promptInput, true); if (json_last_error() === JSON_ERROR_NONE) { diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 6f162eb4..d5a7f5f1 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -107,13 +107,15 @@ public function withPart(MessagePart $part): Message private function validateParts(): void { foreach ($this->parts as $part) { - if ($this->role->isUser() && $part->getType()->isFunctionCall()) { + $type = $part->getType(); + + if ($this->role->isUser() && $type->isFunctionCall()) { throw new InvalidArgumentException( 'User messages cannot contain function calls.' ); } - if ($this->role->isModel() && $part->getType()->isFunctionResponse()) { + if ($this->role->isModel() && $type->isFunctionResponse()) { throw new InvalidArgumentException( 'Model messages cannot contain function responses.' ); diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 5049e964..6ab3b13d 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -4,22 +4,32 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; /** - * Class for an OpenAI image generation model. + * Class for an OpenAI image generation model using the Images API. + * + * This uses the Images API directly to generate images with GPT image models + * (gpt-image-1, etc.) and DALL-E models (dall-e-2, dall-e-3). * * @since 0.1.0 */ class OpenAiImageGenerationModel extends AbstractOpenAiCompatibleImageGenerationModel { /** - * @inheritDoc + * {@inheritDoc} + * + * @since 0.1.0 */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request - { + protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request { return new Request( $method, OpenAiProvider::url($path), @@ -30,7 +40,9 @@ protected function createRequest(HttpMethodEnum $method, string $path, array $he } /** - * @inheritDoc + * {@inheritDoc} + * + * @since 0.1.0 */ protected function prepareGenerateImageParams(array $prompt): array { @@ -40,7 +52,7 @@ protected function prepareGenerateImageParams(array $prompt): array * Only the newer 'gpt-image-' models support passing a MIME type ('output_format'). * Conversely, they do not support 'response_format', but always return a base64 encoded image. */ - if (isset($params['model']) && is_string($params['model']) && str_starts_with($params['model'], 'gpt-image-')) { + if ($this->isGptImageModel($params['model'])) { unset($params['response_format']); } else { unset($params['output_format']); @@ -48,4 +60,136 @@ protected function prepareGenerateImageParams(array $prompt): array return $params; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + $modelId = $this->metadata()->getId(); + + if ($this->isGptImageModel($modelId)) { + return $this->prepareGptImageSizeParam($orientation, $aspectRatio); + } + + return $this->prepareDalleSizeParam($modelId, $orientation, $aspectRatio); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + protected function getResultId(array $responseData): string + { + // The Images API returns `created` timestamp instead of `id`. + return isset($responseData['created']) && is_int($responseData['created']) + ? 'img-' . $responseData['created'] + : ''; + } + + /** + * Checks if the given model ID is a GPT image model. + * + * @since n.e.x.t + * + * @param string $modelId The model ID to check. + * @return bool True if it's a GPT image model, false otherwise. + */ + protected function isGptImageModel(string $modelId): bool + { + return str_starts_with($modelId, 'gpt-image-'); + } + + /** + * Prepares the size parameter for GPT image models. + * + * @since n.e.x.t + * + * @param MediaOrientationEnum|null $orientation The desired media orientation. + * @param string|null $aspectRatio The desired media aspect ratio. + * @return string The size parameter value. + */ + protected function prepareGptImageSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + // If aspect ratio is provided, map it to OpenAI size format. + if ($aspectRatio !== null) { + $aspectRatioMap = [ + '1:1' => '1024x1024', + '3:2' => '1536x1024', + '2:3' => '1024x1536', + ]; + if (isset($aspectRatioMap[$aspectRatio])) { + return $aspectRatioMap[$aspectRatio]; + } + } + + // Map orientation to size. + if ($orientation !== null) { + if ($orientation->isLandscape()) { + return '1536x1024'; + } + if ($orientation->isPortrait()) { + return '1024x1536'; + } + } + + // Default to square. + return '1024x1024'; + } + + /** + * Prepares the size parameter for DALL-E models. + * + * @since n.e.x.t + * + * @param string $modelId The model ID (dall-e-2 or dall-e-3). + * @param MediaOrientationEnum|null $orientation The desired media orientation. + * @param string|null $aspectRatio The desired media aspect ratio. + * @return string The size parameter value. + */ + protected function prepareDalleSizeParam( + string $modelId, + ?MediaOrientationEnum $orientation, + ?string $aspectRatio + ): string { + $isDalle3 = $modelId === 'dall-e-3'; + + // If aspect ratio is provided, map it to size. + if ($aspectRatio !== null) { + if ($isDalle3) { + $aspectRatioMap = [ + '1:1' => '1024x1024', + '7:4' => '1792x1024', + '4:7' => '1024x1792', + ]; + } else { + // DALL-E 2 only supports square images at various resolutions. + $aspectRatioMap = [ + '1:1' => '1024x1024', + ]; + } + if (isset($aspectRatioMap[$aspectRatio])) { + return $aspectRatioMap[$aspectRatio]; + } + } + + // Map orientation to size. + if ($orientation !== null) { + if ($isDalle3) { + if ($orientation->isLandscape()) { + return '1792x1024'; + } + if ($orientation->isPortrait()) { + return '1024x1792'; + } + } + // DALL-E 2 only supports square, so orientation doesn't change the size. + } + + // Default to square. + return '1024x1024'; + } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index fc522260..a4c878b3 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -75,6 +75,7 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']), new SupportedOption(OptionEnum::outputSchema()), new SupportedOption(OptionEnum::functionDeclarations()), + new SupportedOption(OptionEnum::webSearch()), new SupportedOption(OptionEnum::customOptions()), ]; $gptOptions = array_merge($gptBaseOptions, [ @@ -88,6 +89,8 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + [ModalityEnum::text(), ModalityEnum::document()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::document()], ] ), new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]), @@ -99,6 +102,8 @@ protected function parseResponseToModelMetadataList(Response $response): array [ModalityEnum::text()], [ModalityEnum::text(), ModalityEnum::image()], [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::audio()], + [ModalityEnum::text(), ModalityEnum::document()], + [ModalityEnum::text(), ModalityEnum::image(), ModalityEnum::document()], ] ), new SupportedOption( diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 6d679e4b..e714acae 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -4,30 +4,682 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Common\Exception\RuntimeException; +use WordPress\AiClient\Messages\DTO\Message; +use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Util\ResponseUtil; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Results\DTO\Candidate; +use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tools\DTO\FunctionCall; +use WordPress\AiClient\Tools\DTO\FunctionDeclaration; +use WordPress\AiClient\Tools\DTO\WebSearch; /** - * Class for an OpenAI text generation model. + * Class for an OpenAI text generation model using the Responses API. * - * @since 0.1.0 + * @since n.e.x.t + * + * @phpstan-type OutputContentData array{ + * type: string, + * text?: string, + * call_id?: string, + * name?: string, + * arguments?: string + * } + * @phpstan-type OutputItemData array{ + * type: string, + * id?: string, + * role?: string, + * status?: string, + * content?: list + * } + * @phpstan-type UsageData array{ + * input_tokens?: int, + * output_tokens?: int, + * total_tokens?: int + * } + * @phpstan-type ResponseData array{ + * id?: string, + * status?: string, + * output?: list, + * output_text?: string, + * usage?: UsageData + * } */ -class OpenAiTextGenerationModel extends AbstractOpenAiCompatibleTextGenerationModel +class OpenAiTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface { /** * {@inheritDoc} * - * @since 0.1.0 + * @since n.e.x.t */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request + final public function generateTextResult(array $prompt): GenerativeAiResult { - return new Request( - $method, - OpenAiProvider::url($path), - $headers, - $data, + $httpTransporter = $this->getHttpTransporter(); + + $params = $this->prepareGenerateTextParams($prompt); + + $request = new Request( + HttpMethodEnum::POST(), + OpenAiProvider::url('responses'), + ['Content-Type' => 'application/json'], + $params, $this->getRequestOptions() ); + + // Add authentication credentials to the request. + $request = $this->getRequestAuthentication()->authenticateRequest($request); + + // Send and process the request. + $response = $httpTransporter->send($request); + ResponseUtil::throwIfNotSuccessful($response); + return $this->parseResponseToGenerativeAiResult($response); + } + + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since n.e.x.t + * + * @param list $prompt The prompt to generate text for. Either a single message or a list of messages + * from a chat. + * @return array The parameters for the API request. + */ + protected function prepareGenerateTextParams(array $prompt): array + { + $config = $this->getConfig(); + + $params = [ + 'model' => $this->metadata()->getId(), + 'input' => $this->prepareInputParam($prompt), + ]; + + $systemInstruction = $config->getSystemInstruction(); + if ($systemInstruction) { + $params['instructions'] = $systemInstruction; + } + + $maxTokens = $config->getMaxTokens(); + if ($maxTokens !== null) { + $params['max_output_tokens'] = $maxTokens; + } + + $temperature = $config->getTemperature(); + if ($temperature !== null) { + $params['temperature'] = $temperature; + } + + $topP = $config->getTopP(); + if ($topP !== null) { + $params['top_p'] = $topP; + } + + // Note: OpenAI does not support top_k parameter. + + $outputMimeType = $config->getOutputMimeType(); + $outputSchema = $config->getOutputSchema(); + if ($outputMimeType === 'application/json' && $outputSchema) { + $params['text'] = [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'response_schema', + 'schema' => $outputSchema, + 'strict' => true, + ], + ]; + } + + $functionDeclarations = $config->getFunctionDeclarations(); + $webSearch = $config->getWebSearch(); + $customOptions = $config->getCustomOptions(); + + if (is_array($functionDeclarations) || $webSearch) { + $params['tools'] = $this->prepareToolsParam( + $functionDeclarations, + $webSearch + ); + } + + /* + * Any custom options are added to the parameters as well. + * This allows developers to pass other options that may be more niche or not yet supported by the SDK. + */ + foreach ($customOptions as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException( + sprintf( + 'The custom option "%s" conflicts with an existing parameter.', + $key + ) + ); + } + $params[$key] = $value; + } + + return $params; + } + + /** + * Prepares the input parameter for the API request. + * + * @since n.e.x.t + * + * @param list $messages The messages to prepare. + * @return list> The prepared input parameter. + */ + protected function prepareInputParam(array $messages): array + { + $this->validateMessages($messages); + + $input = []; + foreach ($messages as $message) { + $inputItem = $this->getMessageInputItem($message); + if ($inputItem !== null) { + $input[] = $inputItem; + } + } + return $input; + } + + /** + * Validates that the messages are appropriate for the OpenAI Responses API. + * + * The OpenAI Responses API requires function calls and function responses to be + * sent as top-level input items rather than nested in message content. As such, + * they must be the only part in a message. + * + * @since n.e.x.t + * + * @param list $messages The messages to validate. + * @return void + * @throws InvalidArgumentException If validation fails. + */ + protected function validateMessages(array $messages): void + { + foreach ($messages as $message) { + $parts = $message->getParts(); + + if (count($parts) <= 1) { + continue; + } + + foreach ($parts as $part) { + $type = $part->getType(); + + if ($type->isFunctionCall()) { + throw new InvalidArgumentException( + 'Function call parts must be the only part in a message for the OpenAI Responses API.' + ); + } + + if ($type->isFunctionResponse()) { + throw new InvalidArgumentException( + 'Function response parts must be the only part in a message for the OpenAI Responses API.' + ); + } + } + } + } + + /** + * Converts a Message object to a Responses API input item. + * + * @since n.e.x.t + * + * @param Message $message The message to convert. + * @return array|null The input item, or null if the message is empty. + */ + protected function getMessageInputItem(Message $message): ?array + { + $parts = $message->getParts(); + + if (empty($parts)) { + return null; + } + + $content = []; + foreach ($parts as $part) { + $partData = $this->getMessagePartData($part); + + // Function calls and responses are top-level items, not wrapped in a message. + // validateMessages() ensures these are the only part in a message. + $partType = $partData['type'] ?? ''; + if ($partType === 'function_call' || $partType === 'function_call_output') { + return $partData; + } + + $content[] = $partData; + } + + return [ + 'role' => $this->getMessageRoleString($message->getRole()), + 'content' => $content, + ]; + } + + /** + * Returns the OpenAI API specific role string for the given message role. + * + * @since n.e.x.t + * + * @param MessageRoleEnum $role The message role. + * @return string The role for the API request. + */ + protected function getMessageRoleString(MessageRoleEnum $role): string + { + if ($role === MessageRoleEnum::model()) { + return 'assistant'; + } + return 'user'; + } + + /** + * Returns the OpenAI API specific data for a message part. + * + * @since n.e.x.t + * + * @param MessagePart $part The message part to get the data for. + * @return array The data for the message part. + * @throws InvalidArgumentException If the message part type or data is unsupported. + */ + protected function getMessagePartData(MessagePart $part): array + { + $type = $part->getType(); + if ($type->isText()) { + return [ + 'type' => 'input_text', + 'text' => $part->getText(), + ]; + } + if ($type->isFile()) { + $file = $part->getFile(); + if (!$file) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The file typed message part must contain a file.' + ); + } + if ($file->isRemote()) { + $fileUrl = $file->getUrl(); + if (!$fileUrl) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The remote file must contain a URL.' + ); + } + if ($file->isImage()) { + return [ + 'type' => 'input_image', + 'image_url' => $fileUrl, + ]; + } + // For other file types, use input_file with URL. + return [ + 'type' => 'input_file', + 'file_url' => $fileUrl, + ]; + } + // Else, it is an inline file. + $dataUri = $file->getDataUri(); + if (!$dataUri) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The inline file must contain base64 data.' + ); + } + if ($file->isImage()) { + return [ + 'type' => 'input_image', + 'image_url' => $dataUri, + ]; + } + // For other file types (like PDF), use input_file. + return [ + 'type' => 'input_file', + 'filename' => 'file', + 'file_data' => $dataUri, + ]; + } + if ($type->isFunctionCall()) { + $functionCall = $part->getFunctionCall(); + if (!$functionCall) { + throw new RuntimeException( + 'The function_call typed message part must contain a function call.' + ); + } + return [ + 'type' => 'function_call', + 'call_id' => $functionCall->getId(), + 'name' => $functionCall->getName(), + 'arguments' => json_encode($functionCall->getArgs()), + ]; + } + if ($type->isFunctionResponse()) { + $functionResponse = $part->getFunctionResponse(); + if (!$functionResponse) { + throw new RuntimeException( + 'The function_response typed message part must contain a function response.' + ); + } + return [ + 'type' => 'function_call_output', + 'call_id' => $functionResponse->getId(), + 'output' => json_encode($functionResponse->getResponse()), + ]; + } + throw new InvalidArgumentException( + sprintf( + 'Unsupported message part type "%s".', + $type + ) + ); + } + + /** + * Prepares the tools parameter for the API request. + * + * @since n.e.x.t + * + * @param list|null $functionDeclarations The function declarations, or null if none. + * @param WebSearch|null $webSearch The web search config, or null if none. + * @return list> The prepared tools parameter. + */ + protected function prepareToolsParam( + ?array $functionDeclarations, + ?WebSearch $webSearch + ): array { + $tools = []; + + if (is_array($functionDeclarations)) { + foreach ($functionDeclarations as $functionDeclaration) { + $tools[] = [ + 'type' => 'function', + 'name' => $functionDeclaration->getName(), + 'description' => $functionDeclaration->getDescription(), + 'parameters' => $functionDeclaration->getParameters(), + ]; + } + } + + if ($webSearch) { + $webSearchTool = ['type' => 'web_search']; + // Note: The OpenAI Responses API web_search tool may have different filtering options. + // For now, we use the basic form. + $tools[] = $webSearchTool; + } + + return $tools; + } + + /** + * Parses the response from the API endpoint to a generative AI result. + * + * @since n.e.x.t + * + * @param Response $response The response from the API endpoint. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult + { + /** @var ResponseData $responseData */ + $responseData = $response->getData(); + + if (!isset($responseData['output']) || !$responseData['output']) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'output'); + } + if (!is_array($responseData['output']) || !array_is_list($responseData['output'])) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'output', + 'The value must be an indexed array.' + ); + } + + $candidates = []; + foreach ($responseData['output'] as $index => $outputItem) { + if (!is_array($outputItem) || array_is_list($outputItem)) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + "output[{$index}]", + 'The value must be an associative array.' + ); + } + + $candidate = $this->parseOutputItemToCandidate($outputItem, $index, $responseData['status'] ?? 'completed'); + if ($candidate !== null) { + $candidates[] = $candidate; + } + } + + $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + + if (isset($responseData['usage']) && is_array($responseData['usage'])) { + $usage = $responseData['usage']; + $tokenUsage = new TokenUsage( + $usage['input_tokens'] ?? 0, + $usage['output_tokens'] ?? 0, + $usage['total_tokens'] ?? (($usage['input_tokens'] ?? 0) + ($usage['output_tokens'] ?? 0)) + ); + } else { + $tokenUsage = new TokenUsage(0, 0, 0); + } + + // Use any other data from the response as provider-specific response metadata. + $additionalData = $responseData; + unset($additionalData['id'], $additionalData['output'], $additionalData['usage']); + + return new GenerativeAiResult( + $id, + $candidates, + $tokenUsage, + $this->providerMetadata(), + $this->metadata(), + $additionalData + ); + } + + /** + * Parses a single output item from the API response into a Candidate object. + * + * @since n.e.x.t + * + * @param OutputItemData $outputItem The output item data from the API response. + * @param int $index The index of the output item in the output array. + * @param string $responseStatus The overall response status. + * @return Candidate|null The parsed candidate, or null if the output item should be skipped. + */ + protected function parseOutputItemToCandidate(array $outputItem, int $index, string $responseStatus): ?Candidate + { + $type = $outputItem['type'] ?? ''; + + // Handle message output type. + if ($type === 'message') { + return $this->parseMessageOutputToCandidate($outputItem, $index, $responseStatus); + } + + // Handle function_call output type (top-level function call). + if ($type === 'function_call') { + return $this->parseFunctionCallOutputToCandidate($outputItem, $index); + } + + // Skip other output types for now (e.g., image_generation_call is handled in image model). + return null; + } + + /** + * Parses a message output item into a Candidate object. + * + * @since n.e.x.t + * + * @param OutputItemData $outputItem The output item data. + * @param int $index The index of the output item. + * @param string $responseStatus The overall response status. + * @return Candidate The parsed candidate. + */ + protected function parseMessageOutputToCandidate( + array $outputItem, + int $index, + string $responseStatus + ): Candidate { + $role = isset($outputItem['role']) && $outputItem['role'] === 'user' + ? MessageRoleEnum::user() + : MessageRoleEnum::model(); + + $parts = []; + $hasFunctionCalls = false; + + if (isset($outputItem['content']) && is_array($outputItem['content'])) { + foreach ($outputItem['content'] as $contentIndex => $contentItem) { + try { + $part = $this->parseOutputContentToPart($contentItem); + if ($part !== null) { + $parts[] = $part; + if ($part->getType()->isFunctionCall()) { + $hasFunctionCalls = true; + } + } + } catch (InvalidArgumentException $e) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + "output[{$index}].content[{$contentIndex}]", + $e->getMessage() + ); + } + } + } + + $message = new Message($role, $parts); + $finishReason = $this->parseStatusToFinishReason($responseStatus, $hasFunctionCalls); + + return new Candidate($message, $finishReason); + } + + /** + * Parses a function_call output item into a Candidate object. + * + * @since n.e.x.t + * + * @param OutputItemData $outputItem The output item data. + * @param int $index The index of the output item. + * @return Candidate The parsed candidate. + */ + protected function parseFunctionCallOutputToCandidate(array $outputItem, int $index): Candidate + { + if (!isset($outputItem['call_id']) || !is_string($outputItem['call_id'])) { + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + "output[{$index}].call_id" + ); + } + if (!isset($outputItem['name']) || !is_string($outputItem['name'])) { + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + "output[{$index}].name" + ); + } + + $args = []; + if (isset($outputItem['arguments']) && is_string($outputItem['arguments'])) { + $decoded = json_decode($outputItem['arguments'], true); + if (is_array($decoded)) { + $args = $decoded; + } + } + + $functionCall = new FunctionCall( + $outputItem['call_id'], + $outputItem['name'], + $args + ); + + $part = new MessagePart($functionCall); + $message = new Message(MessageRoleEnum::model(), [$part]); + + return new Candidate($message, FinishReasonEnum::toolCalls()); + } + + /** + * Parses an output content item into a MessagePart. + * + * @since n.e.x.t + * + * @param array $contentItem The content item data. + * @return MessagePart|null The parsed message part, or null to skip. + */ + protected function parseOutputContentToPart(array $contentItem): ?MessagePart + { + $type = $contentItem['type'] ?? ''; + + if ($type === 'output_text') { + if (!isset($contentItem['text']) || !is_string($contentItem['text'])) { + throw new InvalidArgumentException('Content has an invalid output_text shape.'); + } + return new MessagePart($contentItem['text']); + } + + if ($type === 'function_call') { + if ( + !isset($contentItem['call_id']) || + !is_string($contentItem['call_id']) || + !isset($contentItem['name']) || + !is_string($contentItem['name']) + ) { + throw new InvalidArgumentException('Content has an invalid function_call shape.'); + } + + $args = []; + if (isset($contentItem['arguments']) && is_string($contentItem['arguments'])) { + $decoded = json_decode($contentItem['arguments'], true); + if (is_array($decoded)) { + $args = $decoded; + } + } + + return new MessagePart( + new FunctionCall( + $contentItem['call_id'], + $contentItem['name'], + $args + ) + ); + } + + // Skip unknown content types. + return null; + } + + /** + * Parses the response status to a finish reason. + * + * @since n.e.x.t + * + * @param string $status The response status. + * @param bool $hasFunctionCalls Whether the response contains function calls. + * @return FinishReasonEnum The finish reason. + */ + protected function parseStatusToFinishReason(string $status, bool $hasFunctionCalls): FinishReasonEnum + { + switch ($status) { + case 'completed': + return $hasFunctionCalls ? FinishReasonEnum::toolCalls() : FinishReasonEnum::stop(); + case 'incomplete': + return FinishReasonEnum::length(); + case 'failed': + case 'cancelled': + return FinishReasonEnum::error(); + default: + // Default to stop for unknown statuses. + return FinishReasonEnum::stop(); + } } } diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index e8d926a3..7cfcbee1 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -32,6 +32,15 @@ * * @since 0.1.0 * + * @phpstan-type ImageGenerationParams array{ + * model: string, + * prompt: string, + * n?: int, + * response_format?: string, + * output_format?: string|null, + * size?: string, + * ... + * } * @phpstan-type ChoiceData array{ * url?: string, * b64_json?: string @@ -90,7 +99,7 @@ public function generateImageResult(array $prompt): GenerativeAiResult * @param list $prompt The prompt to generate an image for. Either a single message or a list of messages * from a chat. However as of today, OpenAI compatible image generation endpoints only * support a single user message. - * @return array The parameters for the API request. + * @return ImageGenerationParams The parameters for the API request. */ protected function prepareGenerateImageParams(array $prompt): array { @@ -142,6 +151,7 @@ protected function prepareGenerateImageParams(array $prompt): array $params[$key] = $value; } + /** @var ImageGenerationParams $params */ return $params; } @@ -305,7 +315,7 @@ protected function parseResponseToGenerativeAiResult( $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); } - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + $id = $this->getResultId($responseData); if (isset($responseData['usage']) && is_array($responseData['usage'])) { $usage = $responseData['usage']; @@ -367,4 +377,19 @@ protected function parseResponseChoiceToCandidate( return new Candidate($message, FinishReasonEnum::stop()); } + + /** + * Extracts the result ID from the API response data. + * + * @since n.e.x.t + * + * @param array $responseData The response data from the API. + * @return string The result ID. + */ + protected function getResultId(array $responseData): string + { + return isset($responseData['id']) && is_string($responseData['id']) + ? $responseData['id'] + : ''; + } } diff --git a/tests/unit/Builders/MessageBuilderTest.php b/tests/unit/Builders/MessageBuilderTest.php index 7ec01e54..18617729 100644 --- a/tests/unit/Builders/MessageBuilderTest.php +++ b/tests/unit/Builders/MessageBuilderTest.php @@ -380,19 +380,20 @@ public function testConstructorWithTextAndRole(): void */ public function testValidationAllowsValidCombinations(): void { - // User message with function response - should work + // User message with function response only - should work + // (Function responses must be the only part in a message) $functionResponse = new FunctionResponse('resp_id', 'test', ['result' => 'ok']); $builder1 = new MessageBuilder(); $message1 = $builder1 ->usingUserRole() - ->withText('Here is the result:') ->withFunctionResponse($functionResponse) ->get(); $this->assertTrue($message1->getRole()->isUser()); - $this->assertCount(2, $message1->getParts()); + $this->assertCount(1, $message1->getParts()); - // Model message with function call - should work + // Model message with text and function call - should work + // (Model can combine text with function calls) $functionCall = new FunctionCall(null, 'test', ['param' => 'value']); $builder2 = new MessageBuilder(); $message2 = $builder2 diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 899b6eb5..ac839857 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -2503,14 +2503,12 @@ public function testComplexMultimodalPromptBuilding(): void { $file1 = new File('https://example.com/img1.jpg', 'image/jpeg'); $file2 = new File('https://example.com/audio.mp3', 'audio/mp3'); - $functionResponse = new FunctionResponse('func1', 'getData', ['result' => 'data']); $builder = new PromptBuilder($this->registry); $builder->withText('Analyze this data:') ->withFile($file1) ->withText(' and this audio:') ->withFile($file2) - ->withFunctionResponse($functionResponse) ->withHistory( new UserMessage([new MessagePart('Previous question')]), new ModelMessage([new MessagePart('Previous answer')]) @@ -2532,13 +2530,12 @@ public function testComplexMultimodalPromptBuilding(): void // Check current message being built (now at the end) $currentParts = $messages[2]->getParts(); - $this->assertCount(6, $currentParts); // text, image, text, audio, function response, final text + $this->assertCount(5, $currentParts); // text, image, text, audio, final text $this->assertEquals('Analyze this data:', $currentParts[0]->getText()); $this->assertSame($file1, $currentParts[1]->getFile()); $this->assertEquals(' and this audio:', $currentParts[2]->getText()); $this->assertSame($file2, $currentParts[3]->getFile()); - $this->assertSame($functionResponse, $currentParts[4]->getFunctionResponse()); - $this->assertEquals(' Final instruction', $currentParts[5]->getText()); + $this->assertEquals(' Final instruction', $currentParts[4]->getText()); } /** diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index 4f2c7024..83edfe86 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -103,35 +103,28 @@ public function roleProvider(): array } /** - * Tests complex message with all part types. + * Tests complex message with multiple content types (text and files). * * @return void */ - public function testComplexMessageWithAllPartTypes(): void + public function testComplexMessageWithMultipleContentTypes(): void { - // Test with user role since it can have function responses but not function calls + // Test user message with text and files $role = MessageRoleEnum::user(); $parts = [ new MessagePart('I need help with searching.'), - new MessagePart(new FunctionResponse('search_123', 'webSearch', ['results' => ['item1', 'item2']])), new MessagePart('Here is additional information:'), new MessagePart(new File('data:text/plain;base64,SGVsbG8=', 'text/plain')), ]; $message = new Message($role, $parts); - $this->assertCount(4, $message->getParts()); - - // Verify each part type - $this->assertEquals( - 'I need help with searching.', - $message->getParts()[0]->getText() - ); - $this->assertInstanceOf(FunctionResponse::class, $message->getParts()[1]->getFunctionResponse()); - $this->assertEquals('Here is additional information:', $message->getParts()[2]->getText()); - $this->assertInstanceOf(File::class, $message->getParts()[3]->getFile()); + $this->assertCount(3, $message->getParts()); + $this->assertEquals('I need help with searching.', $message->getParts()[0]->getText()); + $this->assertEquals('Here is additional information:', $message->getParts()[1]->getText()); + $this->assertInstanceOf(File::class, $message->getParts()[2]->getFile()); - // Also test model role with function calls + // Test model message with text and function calls (model can combine these) $modelRole = MessageRoleEnum::model(); $modelParts = [ new MessagePart('I\'ll help you with that. Let me search for the information.'), diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php new file mode 100644 index 00000000..b72e974c --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php @@ -0,0 +1,160 @@ +mockHttpTransporter = $mockHttpTransporter; + $this->mockRequestAuthentication = $mockRequestAuthentication; + } + + /** + * {@inheritDoc} + */ + public function getHttpTransporter(): HttpTransporterInterface + { + return $this->mockHttpTransporter; + } + + /** + * {@inheritDoc} + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + return $this->mockRequestAuthentication; + } + + /** + * Sets a mock generative AI result to be returned by parseResponseToGenerativeAiResult. + * + * @param GenerativeAiResult $result + */ + public function setMockGenerativeAiResult(GenerativeAiResult $result): void + { + $this->mockGenerativeAiResult = $result; + } + + /** + * {@inheritDoc} + */ + protected function parseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'image/png' + ): GenerativeAiResult { + if ($this->mockGenerativeAiResult) { + return $this->mockGenerativeAiResult; + } + return parent::parseResponseToGenerativeAiResult($response, $expectedMimeType); + } + + // Expose protected methods for testing. + + /** + * Exposes prepareGenerateImageParams for testing. + * + * @param list $prompt + * @return array + */ + public function exposePrepareGenerateImageParams(array $prompt): array + { + return $this->prepareGenerateImageParams($prompt); + } + + /** + * Exposes isGptImageModel for testing. + * + * @param string $modelId + * @return bool + */ + public function exposeIsGptImageModel(string $modelId): bool + { + return $this->isGptImageModel($modelId); + } + + /** + * Exposes preparePromptParam for testing. + * + * @param list $messages + * @return string + */ + public function exposePreparePromptParam(array $messages): string + { + return $this->preparePromptParam($messages); + } + + /** + * Exposes prepareSizeParam for testing. + * + * @param MediaOrientationEnum|null $orientation + * @param string|null $aspectRatio + * @return string + */ + public function exposePrepareSize( + ?MediaOrientationEnum $orientation, + ?string $aspectRatio + ): string { + return $this->prepareSizeParam($orientation, $aspectRatio); + } + + /** + * Exposes parseResponseChoiceToCandidate for testing. + * + * @param array $choiceData + * @param int $index + * @param string $expectedMimeType + * @return Candidate + */ + public function exposeParseResponseChoiceToCandidate( + array $choiceData, + int $index, + string $expectedMimeType = 'image/png' + ): Candidate { + return $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); + } +} diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php new file mode 100644 index 00000000..e4dfd563 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -0,0 +1,235 @@ +mockHttpTransporter = $mockHttpTransporter; + $this->mockRequestAuthentication = $mockRequestAuthentication; + } + + /** + * {@inheritDoc} + */ + public function getHttpTransporter(): HttpTransporterInterface + { + return $this->mockHttpTransporter; + } + + /** + * {@inheritDoc} + */ + public function getRequestAuthentication(): RequestAuthenticationInterface + { + return $this->mockRequestAuthentication; + } + + /** + * Sets a mock generative AI result to be returned by parseResponseToGenerativeAiResult. + * + * @param GenerativeAiResult $result + */ + public function setMockGenerativeAiResult(GenerativeAiResult $result): void + { + $this->mockGenerativeAiResult = $result; + } + + /** + * {@inheritDoc} + */ + public function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult + { + if ($this->mockGenerativeAiResult) { + return $this->mockGenerativeAiResult; + } + return parent::parseResponseToGenerativeAiResult($response); + } + + // Expose protected methods for testing. + + /** + * Exposes prepareGenerateTextParams for testing. + * + * @param list $prompt + * @return array + */ + public function exposePrepareGenerateTextParams(array $prompt): array + { + return $this->prepareGenerateTextParams($prompt); + } + + /** + * Exposes prepareInputParam for testing. + * + * @param list $messages + * @return list> + */ + public function exposePrepareInputParam(array $messages): array + { + return $this->prepareInputParam($messages); + } + + /** + * Exposes getMessageInputItem for testing. + * + * @param Message $message + * @return array|null + */ + public function exposeGetMessageInputItem(Message $message): ?array + { + return $this->getMessageInputItem($message); + } + + /** + * Exposes getMessageRoleString for testing. + * + * @param MessageRoleEnum $role + * @return string + */ + public function exposeGetMessageRoleString(MessageRoleEnum $role): string + { + return $this->getMessageRoleString($role); + } + + /** + * Exposes getMessagePartData for testing. + * + * @param MessagePart $part + * @return array + */ + public function exposeGetMessagePartData(MessagePart $part): array + { + return $this->getMessagePartData($part); + } + + /** + * Exposes prepareToolsParam for testing. + * + * @param list|null $functionDeclarations + * @param WebSearch|null $webSearch + * @return list> + */ + public function exposePrepareToolsParam( + ?array $functionDeclarations, + ?WebSearch $webSearch + ): array { + return $this->prepareToolsParam($functionDeclarations, $webSearch); + } + + /** + * Exposes parseOutputItemToCandidate for testing. + * + * @param array $outputItem + * @param int $index + * @param string $responseStatus + * @return Candidate|null + */ + public function exposeParseOutputItemToCandidate( + array $outputItem, + int $index, + string $responseStatus + ): ?Candidate { + return $this->parseOutputItemToCandidate($outputItem, $index, $responseStatus); + } + + /** + * Exposes parseMessageOutputToCandidate for testing. + * + * @param array $outputItem + * @param int $index + * @param string $responseStatus + * @return Candidate + */ + public function exposeParseMessageOutputToCandidate( + array $outputItem, + int $index, + string $responseStatus + ): Candidate { + return $this->parseMessageOutputToCandidate($outputItem, $index, $responseStatus); + } + + /** + * Exposes parseFunctionCallOutputToCandidate for testing. + * + * @param array $outputItem + * @param int $index + * @return Candidate + */ + public function exposeParseFunctionCallOutputToCandidate(array $outputItem, int $index): Candidate + { + return $this->parseFunctionCallOutputToCandidate($outputItem, $index); + } + + /** + * Exposes parseOutputContentToPart for testing. + * + * @param array $contentItem + * @return MessagePart|null + */ + public function exposeParseOutputContentToPart(array $contentItem): ?MessagePart + { + return $this->parseOutputContentToPart($contentItem); + } + + /** + * Exposes parseStatusToFinishReason for testing. + * + * @param string $status + * @param bool $hasFunctionCalls + * @return FinishReasonEnum + */ + public function exposeParseStatusToFinishReason(string $status, bool $hasFunctionCalls): FinishReasonEnum + { + return $this->parseStatusToFinishReason($status, $hasFunctionCalls); + } +} diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php new file mode 100644 index 00000000..1b5b6bc9 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php @@ -0,0 +1,431 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn('gpt-image-1'); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->providerMetadata->method('getName')->willReturn('OpenAI'); + $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Creates a mock instance of OpenAiImageGenerationModel. + * + * @param ModelConfig|null $modelConfig + * @param string $modelId + * @return MockOpenAiImageGenerationModel + */ + private function createModel( + ?ModelConfig $modelConfig = null, + string $modelId = 'gpt-image-1' + ): MockOpenAiImageGenerationModel { + $this->modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn($modelId); + + $model = new MockOpenAiImageGenerationModel( + $this->modelMetadata, + $this->providerMetadata, + $this->mockHttpTransporter, + $this->mockRequestAuthentication + ); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * A minimal valid 1x1 pixel PNG image encoded in base64. + */ + private const VALID_BASE64_IMAGE = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + /** + * Tests generateImageResult() method on success. + * + * @return void + */ + public function testGenerateImageResultSuccess(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + $response = new Response( + 200, + [], + json_encode([ + 'created' => 1234567890, + 'data' => [ + [ + 'b64_json' => self::VALID_BASE64_IMAGE, + ], + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateImageResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('img-1234567890', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $candidate = $result->getCandidates()[0]; + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $parts = $candidate->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $file = $parts[0]->getFile(); + $this->assertNotNull($file); + $this->assertEquals(self::VALID_BASE64_IMAGE, $file->getBase64Data()); + } + + /** + * Tests generateImageResult() method on API failure. + * + * @return void + */ + public function testGenerateImageResultApiFailure(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + $response = new Response(400, [], '{"error": "Invalid request."}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad Request (400) - Invalid request.'); + + $model->generateImageResult($prompt); + } + + /** + * Tests isGptImageModel() method. + * + * @return void + */ + public function testIsGptImageModel(): void + { + $model = $this->createModel(); + + // GPT image models should return true. + $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1')); + $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1-mini')); + $this->assertTrue($model->exposeIsGptImageModel('gpt-image-1.5')); + + // DALL-E models should return false. + $this->assertFalse($model->exposeIsGptImageModel('dall-e-2')); + $this->assertFalse($model->exposeIsGptImageModel('dall-e-3')); + + // Other models should return false. + $this->assertFalse($model->exposeIsGptImageModel('gpt-4o')); + } + + /** + * Tests preparePromptParam() with valid single message. + * + * @return void + */ + public function testPreparePromptParamWithValidMessage(): void + { + $model = $this->createModel(); + $messages = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + + $prompt = $model->exposePreparePromptParam($messages); + + $this->assertEquals('Generate a cat', $prompt); + } + + /** + * Tests preparePromptParam() with multiple messages throws exception. + * + * @return void + */ + public function testPreparePromptParamWithMultipleMessagesThrowsException(): void + { + $model = $this->createModel(); + $messages = [ + new Message(MessageRoleEnum::user(), [new MessagePart('Message 1')]), + new Message(MessageRoleEnum::user(), [new MessagePart('Message 2')]), + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API requires a single user message as prompt.'); + + $model->exposePreparePromptParam($messages); + } + + /** + * Tests preparePromptParam() with non-user message throws exception. + * + * @return void + */ + public function testPreparePromptParamWithNonUserMessageThrowsException(): void + { + $model = $this->createModel(); + $messages = [new Message(MessageRoleEnum::model(), [new MessagePart('Response')])]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API requires a user message as prompt.'); + + $model->exposePreparePromptParam($messages); + } + + /** + * Tests prepareSizeParam() with GPT image model aspect ratios. + * + * @return void + */ + public function testPrepareSizeParamWithGptImageModelAspectRatios(): void + { + $model = $this->createModel(null, 'gpt-image-1'); + + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1536x1024', $model->exposePrepareSize(null, '3:2')); + $this->assertEquals('1024x1536', $model->exposePrepareSize(null, '2:3')); + } + + /** + * Tests prepareSizeParam() with GPT image model orientations. + * + * @return void + */ + public function testPrepareSizeParamWithGptImageModelOrientations(): void + { + $model = $this->createModel(null, 'gpt-image-1'); + + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + $square = MediaOrientationEnum::square(); + + $this->assertEquals('1536x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1536', $model->exposePrepareSize($portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); + } + + /** + * Tests prepareSizeParam() with DALL-E 3 aspect ratios. + * + * @return void + */ + public function testPrepareSizeParamWithDalle3AspectRatios(): void + { + $model = $this->createModel(null, 'dall-e-3'); + + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1792x1024', $model->exposePrepareSize(null, '7:4')); + $this->assertEquals('1024x1792', $model->exposePrepareSize(null, '4:7')); + } + + /** + * Tests prepareSizeParam() with DALL-E 3 orientations. + * + * @return void + */ + public function testPrepareSizeParamWithDalle3Orientations(): void + { + $model = $this->createModel(null, 'dall-e-3'); + + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + $square = MediaOrientationEnum::square(); + + $this->assertEquals('1792x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1792', $model->exposePrepareSize($portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); + } + + /** + * Tests prepareSizeParam() with DALL-E 2 (only supports square). + * + * @return void + */ + public function testPrepareSizeParamWithDalle2(): void + { + $model = $this->createModel(null, 'dall-e-2'); + + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + + // DALL-E 2 only supports square images. + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1024x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($portrait, null)); + } + + /** + * Tests prepareSizeParam() defaults to square. + * + * @return void + */ + public function testPrepareSizeParamDefaultsToSquare(): void + { + $gptModel = $this->createModel(null, 'gpt-image-1'); + $dalleModel = $this->createModel(null, 'dall-e-3'); + + $this->assertEquals('1024x1024', $gptModel->exposePrepareSize(null, null)); + $this->assertEquals('1024x1024', $dalleModel->exposePrepareSize(null, null)); + } + + /** + * Tests parseResponseChoiceToCandidate() method. + * + * @return void + */ + public function testParseResponseChoiceToCandidate(): void + { + $model = $this->createModel(); + + $candidate = $model->exposeParseResponseChoiceToCandidate([ + 'b64_json' => self::VALID_BASE64_IMAGE, + ], 0); + + $this->assertNotNull($candidate); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $parts = $candidate->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFile()); + $file = $parts[0]->getFile(); + $this->assertNotNull($file); + $this->assertEquals(self::VALID_BASE64_IMAGE, $file->getBase64Data()); + $this->assertEquals('image/png', $file->getMimeType()); + } + + /** + * Tests parseResponseChoiceToCandidate() with custom MIME type. + * + * @return void + */ + public function testParseResponseChoiceToCandidateWithCustomMimeType(): void + { + $model = $this->createModel(); + + $candidate = $model->exposeParseResponseChoiceToCandidate([ + 'b64_json' => self::VALID_BASE64_IMAGE, + ], 0, 'image/jpeg'); + + $file = $candidate->getMessage()->getParts()[0]->getFile(); + $this->assertNotNull($file); + $this->assertEquals('image/jpeg', $file->getMimeType()); + } + + /** + * Tests prepareGenerateImageParams() for GPT image model. + * + * @return void + */ + public function testPrepareGenerateImageParamsForGptImageModel(): void + { + $config = new ModelConfig(); + $config->setOutputMimeType('image/webp'); + $model = $this->createModel($config, 'gpt-image-1'); + + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertEquals('gpt-image-1', $params['model']); + $this->assertEquals('Generate a cat', $params['prompt']); + $this->assertEquals('webp', $params['output_format']); + $this->assertArrayNotHasKey('response_format', $params); + } + + /** + * Tests prepareGenerateImageParams() for DALL-E model. + * + * @return void + */ + public function testPrepareGenerateImageParamsForDalleModel(): void + { + $model = $this->createModel(null, 'dall-e-3'); + + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertEquals('dall-e-3', $params['model']); + $this->assertEquals('Generate a cat', $params['prompt']); + $this->assertEquals('b64_json', $params['response_format']); + $this->assertArrayNotHasKey('output_format', $params); + } + + /** + * Tests prepareGenerateImageParams() with size configuration. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithSizeConfig(): void + { + $config = new ModelConfig(); + $config->setOutputMediaAspectRatio('3:2'); + $model = $this->createModel($config, 'gpt-image-1'); + + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertEquals('1536x1024', $params['size']); + } +} diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php new file mode 100644 index 00000000..12a679e3 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -0,0 +1,703 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn('gpt-4o'); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->providerMetadata->method('getName')->willReturn('OpenAI'); + $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Creates a mock instance of OpenAiTextGenerationModel. + * + * @param ModelConfig|null $modelConfig + * @return MockOpenAiTextGenerationModel + */ + private function createModel(?ModelConfig $modelConfig = null): MockOpenAiTextGenerationModel + { + $model = new MockOpenAiTextGenerationModel( + $this->modelMetadata, + $this->providerMetadata, + $this->mockHttpTransporter, + $this->mockRequestAuthentication + ); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * Tests generateTextResult() method on success. + * + * @return void + */ + public function testGenerateTextResultSuccess(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'resp_123', + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'role' => 'assistant', + 'content' => [ + ['type' => 'output_text', 'text' => 'Hi there!'], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 5, + 'total_tokens' => 15, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateTextResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('resp_123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals('Hi there!', $result->getCandidates()[0]->getMessage()->getParts()[0]->getText()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(5, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(15, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests generateTextResult() method on API failure. + * + * @return void + */ + public function testGenerateTextResultApiFailure(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response(400, [], '{"error": "Invalid parameter."}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); + + $model->generateTextResult($prompt); + } + + /** + * Tests prepareGenerateTextParams() with basic text prompt. + * + * @return void + */ + public function testPrepareGenerateTextParamsBasicText(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test message')])]; + $model = $this->createModel(); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('model', $params); + $this->assertEquals('gpt-4o', $params['model']); + $this->assertArrayHasKey('input', $params); + $this->assertCount(1, $params['input']); + $this->assertArrayNotHasKey('type', $params['input'][0]); + $this->assertEquals('user', $params['input'][0]['role']); + $this->assertCount(1, $params['input'][0]['content']); + $this->assertEquals('input_text', $params['input'][0]['content'][0]['type']); + $this->assertEquals('Test message', $params['input'][0]['content'][0]['text']); + } + + /** + * Tests prepareGenerateTextParams() with system instruction. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithSystemInstruction(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $config = new ModelConfig(); + $config->setSystemInstruction('You are a helpful assistant.'); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('instructions', $params); + $this->assertEquals('You are a helpful assistant.', $params['instructions']); + } + + /** + * Tests prepareGenerateTextParams() with max tokens. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithMaxTokens(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $config = new ModelConfig(); + $config->setMaxTokens(1000); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('max_output_tokens', $params); + $this->assertEquals(1000, $params['max_output_tokens']); + } + + /** + * Tests prepareGenerateTextParams() with temperature and topP. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithTemperatureAndTopP(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $config = new ModelConfig(); + $config->setTemperature(0.7); + $config->setTopP(0.9); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('temperature', $params); + $this->assertEquals(0.7, $params['temperature']); + $this->assertArrayHasKey('top_p', $params); + $this->assertEquals(0.9, $params['top_p']); + } + + /** + * Tests prepareGenerateTextParams() with function declarations. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithFunctionDeclarations(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('What is the weather?')])]; + $functionDeclaration = new FunctionDeclaration( + 'get_weather', + 'Get the current weather', + ['type' => 'object', 'properties' => ['location' => ['type' => 'string']]] + ); + $config = new ModelConfig(); + $config->setFunctionDeclarations([$functionDeclaration]); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('tools', $params); + $this->assertCount(1, $params['tools']); + $this->assertEquals('function', $params['tools'][0]['type']); + $this->assertEquals('get_weather', $params['tools'][0]['name']); + $this->assertEquals('Get the current weather', $params['tools'][0]['description']); + } + + /** + * Tests prepareGenerateTextParams() with web search. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithWebSearch(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Search for news')])]; + $webSearch = new WebSearch(); + $config = new ModelConfig(); + $config->setWebSearch($webSearch); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('tools', $params); + $this->assertCount(1, $params['tools']); + $this->assertEquals('web_search', $params['tools'][0]['type']); + } + + /** + * Tests prepareGenerateTextParams() with previous_response_id for conversation state. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithPreviousResponseId(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Continue the conversation')])]; + $config = new ModelConfig(); + $config->setCustomOptions(['previous_response_id' => 'resp_abc123']); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('previous_response_id', $params); + $this->assertEquals('resp_abc123', $params['previous_response_id']); + } + + /** + * Tests prepareGenerateTextParams() with JSON output schema. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithJsonOutput(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Return JSON')])]; + $schema = [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + ]; + $config = new ModelConfig(); + $config->setOutputMimeType('application/json'); + $config->setOutputSchema($schema); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('text', $params); + $this->assertArrayHasKey('format', $params['text']); + $this->assertEquals('json_schema', $params['text']['format']['type']); + $this->assertEquals($schema, $params['text']['format']['schema']); + } + + /** + * Tests getMessageRoleString() method. + * + * @return void + */ + public function testGetMessageRoleString(): void + { + $model = $this->createModel(); + + $this->assertEquals('user', $model->exposeGetMessageRoleString(MessageRoleEnum::user())); + $this->assertEquals('assistant', $model->exposeGetMessageRoleString(MessageRoleEnum::model())); + } + + /** + * Tests getMessagePartData() with text part. + * + * @return void + */ + public function testGetMessagePartDataWithText(): void + { + $model = $this->createModel(); + $part = new MessagePart('Hello world'); + + $data = $model->exposeGetMessagePartData($part); + + $this->assertEquals('input_text', $data['type']); + $this->assertEquals('Hello world', $data['text']); + } + + /** + * Tests getMessagePartData() with remote image. + * + * @return void + */ + public function testGetMessagePartDataWithRemoteImage(): void + { + $model = $this->createModel(); + $file = new File('https://example.com/image.png', 'image/png'); + $part = new MessagePart($file); + + $data = $model->exposeGetMessagePartData($part); + + $this->assertEquals('input_image', $data['type']); + $this->assertEquals('https://example.com/image.png', $data['image_url']); + } + + /** + * Tests getMessagePartData() with inline image. + * + * @return void + */ + public function testGetMessagePartDataWithInlineImage(): void + { + $model = $this->createModel(); + // A minimal 1x1 pixel PNG image encoded in base64. + $b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + $file = new File($b64, 'image/png'); + $part = new MessagePart($file); + + $data = $model->exposeGetMessagePartData($part); + + $this->assertEquals('input_image', $data['type']); + $this->assertStringStartsWith('data:image/png;base64,', $data['image_url']); + } + + /** + * Tests getMessageInputItem() with function response message. + * + * @return void + */ + public function testGetMessageInputItemWithFunctionResponse(): void + { + $model = $this->createModel(); + $functionResponse = new FunctionResponse('call_123', 'get_weather', ['temperature' => 72]); + $part = new MessagePart($functionResponse); + $message = new Message(MessageRoleEnum::user(), [$part]); + + $data = $model->exposeGetMessageInputItem($message); + + $this->assertNotNull($data); + $this->assertEquals('function_call_output', $data['type']); + $this->assertEquals('call_123', $data['call_id']); + $this->assertEquals('{"temperature":72}', $data['output']); + } + + /** + * Tests getMessageInputItem() with function call message. + * + * @return void + */ + public function testGetMessageInputItemWithFunctionCall(): void + { + $model = $this->createModel(); + $functionCall = new FunctionCall('call_456', 'search', ['query' => 'test']); + $part = new MessagePart($functionCall); + $message = new Message(MessageRoleEnum::model(), [$part]); + + $data = $model->exposeGetMessageInputItem($message); + + $this->assertNotNull($data); + $this->assertEquals('function_call', $data['type']); + $this->assertEquals('call_456', $data['call_id']); + $this->assertEquals('search', $data['name']); + $this->assertEquals('{"query":"test"}', $data['arguments']); + } + + /** + * Tests that function response must be the only part in a message for OpenAI. + * + * @return void + */ + public function testValidateMessagesRejectsFunctionResponseMixedWithText(): void + { + $model = $this->createModel(); + $messages = [ + new Message( + MessageRoleEnum::user(), + [ + new MessagePart('Some text'), + new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data'])), + ] + ), + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Function response parts must be the only part in a message for the OpenAI Responses API.' + ); + + $model->exposePrepareInputParam($messages); + } + + /** + * Tests that function call must be the only part in a message for OpenAI. + * + * @return void + */ + public function testValidateMessagesRejectsFunctionCallMixedWithText(): void + { + $model = $this->createModel(); + $messages = [ + new Message( + MessageRoleEnum::model(), + [ + new MessagePart('Some text'), + new MessagePart(new FunctionCall('call_123', 'search', ['query' => 'test'])), + ] + ), + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Function call parts must be the only part in a message for the OpenAI Responses API.' + ); + + $model->exposePrepareInputParam($messages); + } + + /** + * Tests prepareToolsParam() with function declarations and web search. + * + * @return void + */ + public function testPrepareToolsParamWithFunctionAndWebSearch(): void + { + $model = $this->createModel(); + $functionDeclaration = new FunctionDeclaration( + 'test_func', + 'A test function', + ['type' => 'object'] + ); + $webSearch = new WebSearch(); + + $tools = $model->exposePrepareToolsParam( + [$functionDeclaration], + $webSearch + ); + + $this->assertCount(2, $tools); + $toolTypes = array_column($tools, 'type'); + $this->assertContains('function', $toolTypes); + $this->assertContains('web_search', $toolTypes); + } + + /** + * Tests parseStatusToFinishReason() method. + * + * @return void + */ + public function testParseStatusToFinishReason(): void + { + $model = $this->createModel(); + + $this->assertEquals( + FinishReasonEnum::stop(), + $model->exposeParseStatusToFinishReason('completed', false) + ); + $this->assertEquals( + FinishReasonEnum::toolCalls(), + $model->exposeParseStatusToFinishReason('completed', true) + ); + $this->assertEquals( + FinishReasonEnum::length(), + $model->exposeParseStatusToFinishReason('incomplete', false) + ); + $this->assertEquals( + FinishReasonEnum::error(), + $model->exposeParseStatusToFinishReason('failed', false) + ); + $this->assertEquals( + FinishReasonEnum::error(), + $model->exposeParseStatusToFinishReason('cancelled', false) + ); + } + + /** + * Tests parseOutputContentToPart() with text content. + * + * @return void + */ + public function testParseOutputContentToPartWithText(): void + { + $model = $this->createModel(); + + $part = $model->exposeParseOutputContentToPart([ + 'type' => 'output_text', + 'text' => 'Hello world', + ]); + + $this->assertNotNull($part); + $this->assertTrue($part->getType()->isText()); + $this->assertEquals('Hello world', $part->getText()); + } + + /** + * Tests parseOutputContentToPart() with function call. + * + * @return void + */ + public function testParseOutputContentToPartWithFunctionCall(): void + { + $model = $this->createModel(); + + $part = $model->exposeParseOutputContentToPart([ + 'type' => 'function_call', + 'call_id' => 'call_123', + 'name' => 'get_weather', + 'arguments' => '{"location": "Paris"}', + ]); + + $this->assertNotNull($part); + $this->assertTrue($part->getType()->isFunctionCall()); + $functionCall = $part->getFunctionCall(); + $this->assertNotNull($functionCall); + $this->assertEquals('call_123', $functionCall->getId()); + $this->assertEquals('get_weather', $functionCall->getName()); + $this->assertEquals(['location' => 'Paris'], $functionCall->getArgs()); + } + + /** + * Tests parseMessageOutputToCandidate() method. + * + * @return void + */ + public function testParseMessageOutputToCandidate(): void + { + $model = $this->createModel(); + + $candidate = $model->exposeParseMessageOutputToCandidate( + [ + 'type' => 'message', + 'role' => 'assistant', + 'content' => [ + ['type' => 'output_text', 'text' => 'Hello!'], + ], + ], + 0, + 'completed' + ); + + $this->assertNotNull($candidate); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $this->assertEquals(MessageRoleEnum::model(), $candidate->getMessage()->getRole()); + $this->assertEquals('Hello!', $candidate->getMessage()->getParts()[0]->getText()); + } + + /** + * Tests parseFunctionCallOutputToCandidate() method. + * + * @return void + */ + public function testParseFunctionCallOutputToCandidate(): void + { + $model = $this->createModel(); + + $candidate = $model->exposeParseFunctionCallOutputToCandidate( + [ + 'type' => 'function_call', + 'call_id' => 'call_abc', + 'name' => 'search', + 'arguments' => '{"query": "test"}', + ], + 0 + ); + + $this->assertNotNull($candidate); + $this->assertEquals(FinishReasonEnum::toolCalls(), $candidate->getFinishReason()); + $parts = $candidate->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + } + + /** + * Tests generateTextResult() with function call response. + * + * @return void + */ + public function testGenerateTextResultWithFunctionCallResponse(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('What is the weather?')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'resp_456', + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'function_call', + 'call_id' => 'call_789', + 'name' => 'get_weather', + 'arguments' => '{"location": "Paris"}', + ], + ], + 'usage' => [ + 'input_tokens' => 20, + 'output_tokens' => 10, + 'total_tokens' => 30, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->generateTextResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertCount(1, $result->getCandidates()); + $candidate = $result->getCandidates()[0]; + $this->assertEquals(FinishReasonEnum::toolCalls(), $candidate->getFinishReason()); + $parts = $candidate->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertTrue($parts[0]->getType()->isFunctionCall()); + $functionCall = $parts[0]->getFunctionCall(); + $this->assertNotNull($functionCall); + $this->assertEquals('get_weather', $functionCall->getName()); + } +}