From 6e1ae13cc88375d8c3c4389fea191948d6700544 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 30 Dec 2025 15:12:34 -0700 Subject: [PATCH 01/22] feat: updates OpenAI to use Responses API --- .../OpenAi/OpenAiImageGenerationModel.php | 376 +++++++++- .../OpenAi/OpenAiModelMetadataDirectory.php | 1 + .../OpenAi/OpenAiTextGenerationModel.php | 676 +++++++++++++++++- .../OpenAi/MockOpenAiImageGenerationModel.php | 175 +++++ .../OpenAi/MockOpenAiTextGenerationModel.php | 239 +++++++ .../OpenAi/OpenAiImageGenerationModelTest.php | 386 ++++++++++ .../OpenAi/OpenAiTextGenerationModelTest.php | 668 +++++++++++++++++ 7 files changed, 2491 insertions(+), 30 deletions(-) create mode 100644 tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php create mode 100644 tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php create mode 100644 tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php create mode 100644 tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 5049e964..73153708 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -4,48 +4,386 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; +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\AbstractOpenAiCompatibleImageGenerationModel; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Util\ResponseUtil; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Results\DTO\Candidate; +use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** - * Class for an OpenAI image generation model. + * Class for an OpenAI image generation model using the Responses API. * - * @since 0.1.0 + * This uses the Responses API with the built-in image_generation tool. + * + * @since n.e.x.t + * + * @phpstan-type ImageGenerationCallData array{ + * type: string, + * result?: 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, + * usage?: UsageData + * } */ -class OpenAiImageGenerationModel extends AbstractOpenAiCompatibleImageGenerationModel +class OpenAiImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface { /** - * @inheritDoc + * {@inheritDoc} + * + * @since n.e.x.t */ - protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request + public function generateImageResult(array $prompt): GenerativeAiResult { - return new Request( - $method, - OpenAiProvider::url($path), - $headers, - $data, + $httpTransporter = $this->getHttpTransporter(); + + $params = $this->prepareGenerateImageParams($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); } /** - * @inheritDoc + * 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 an image for. Should be a single user message. + * @return array The parameters for the API request. */ protected function prepareGenerateImageParams(array $prompt): array { - $params = parent::prepareGenerateImageParams($prompt); + $config = $this->getConfig(); + $modelId = $this->metadata()->getId(); + + // The Responses API with image_generation tool requires a model that supports it. + // We use a capable model like gpt-4o to process the request with the image_generation tool. + $params = [ + 'model' => $this->getHostModelForImageGeneration($modelId), + 'input' => $this->preparePromptParam($prompt), + 'tools' => [ + $this->prepareImageGenerationTool($modelId), + ], + ]; /* - * 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. + * 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. */ - if (isset($params['model']) && is_string($params['model']) && str_starts_with($params['model'], 'gpt-image-')) { - unset($params['response_format']); - } else { - unset($params['output_format']); + $customOptions = $config->getCustomOptions(); + 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; } + + /** + * Gets the host model to use for image generation requests. + * + * The image_generation tool runs within a host model's context. For dedicated + * image generation models like gpt-image-1, we use a capable host model. + * + * @since n.e.x.t + * + * @param string $modelId The requested model ID. + * @return string The host model ID to use for the request. + */ + protected function getHostModelForImageGeneration(string $modelId): string + { + // If this is a gpt-image-* model, we need a host model to run the tool. + // Otherwise, the model itself can host the image_generation tool. + if (str_starts_with($modelId, 'gpt-image-')) { + return 'gpt-4o'; + } + return $modelId; + } + + /** + * Prepares the image_generation tool configuration. + * + * @since n.e.x.t + * + * @param string $modelId The model ID for image generation. + * @return array The tool configuration. + */ + protected function prepareImageGenerationTool(string $modelId): array + { + $config = $this->getConfig(); + $tool = ['type' => 'image_generation']; + + // If a specific image model is requested, include it in the tool config. + if (str_starts_with($modelId, 'gpt-image-')) { + $tool['model'] = $modelId; + } + + // Add size configuration if available. + $outputMediaOrientation = $config->getOutputMediaOrientation(); + $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); + if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { + $tool['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio); + } + + // Add output format configuration if available. + $outputMimeType = $config->getOutputMimeType(); + if ($outputMimeType !== null) { + // Map MIME type to OpenAI format. + $formatMap = [ + 'image/png' => 'png', + 'image/jpeg' => 'jpeg', + 'image/webp' => 'webp', + ]; + if (isset($formatMap[$outputMimeType])) { + $tool['output_format'] = $formatMap[$outputMimeType]; + } + } + + return $tool; + } + + /** + * Prepares the prompt parameter for the API request. + * + * @since n.e.x.t + * + * @param list $messages The messages to prepare. Should be a single user message. + * @return string The prepared prompt string. + */ + protected function preparePromptParam(array $messages): string + { + if (count($messages) !== 1) { + throw new InvalidArgumentException( + 'The API requires a single user message as prompt.' + ); + } + $message = $messages[0]; + if (!$message->getRole()->isUser()) { + throw new InvalidArgumentException( + 'The API requires a user message as prompt.' + ); + } + + $text = null; + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + break; + } + } + + if ($text === null) { + throw new InvalidArgumentException( + 'The API requires a text message part as prompt.' + ); + } + + return $text; + } + + /** + * Prepares the size parameter for the image generation tool. + * + * @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 prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + // If aspect ratio is provided, map it to OpenAI size format. + if ($aspectRatio !== null) { + $aspectRatioMap = [ + '1:1' => '1024x1024', + '16:9' => '1792x1024', + '9:16' => '1024x1792', + '4:3' => '1024x768', + '3:4' => '768x1024', + ]; + if (isset($aspectRatioMap[$aspectRatio])) { + return $aspectRatioMap[$aspectRatio]; + } + } + + // Map orientation to size. + if ($orientation !== null) { + if ($orientation->isLandscape()) { + return '1792x1024'; + } + if ($orientation->isPortrait()) { + return '1024x1792'; + } + } + + // Default to square. + return '1024x1024'; + } + + /** + * 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); + 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. + * @return Candidate|null The parsed candidate, or null if the output item should be skipped. + */ + protected function parseOutputItemToCandidate(array $outputItem, int $index): ?Candidate + { + $type = $outputItem['type'] ?? ''; + + // Handle image_generation_call output type. + if ($type === 'image_generation_call') { + return $this->parseImageGenerationCallToCandidate($outputItem, $index); + } + + // Skip other output types. + return null; + } + + /** + * Parses an image_generation_call output item into a Candidate object. + * + * @since n.e.x.t + * + * @param ImageGenerationCallData $outputItem The output item data. + * @param int $index The index of the output item. + * @return Candidate The parsed candidate. + */ + protected function parseImageGenerationCallToCandidate(array $outputItem, int $index): Candidate + { + if (!isset($outputItem['result']) || !is_string($outputItem['result'])) { + throw ResponseException::fromMissingData( + $this->providerMetadata()->getName(), + "output[{$index}].result" + ); + } + + // The result is base64-encoded image data. + $base64Data = $outputItem['result']; + + // Determine MIME type from config or default to PNG. + $config = $this->getConfig(); + $mimeType = $config->getOutputMimeType() ?? 'image/png'; + + $imageFile = new File($base64Data, $mimeType); + $parts = [new MessagePart($imageFile)]; + $message = new Message(MessageRoleEnum::model(), $parts); + + return new Candidate($message, FinishReasonEnum::stop()); + } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index fc522260..e0022529 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, [ diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 6d679e4b..01211313 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -4,30 +4,684 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; +use Generator; +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); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + final public function streamGenerateTextResult(array $prompt): Generator + { + // TODO: Implement streaming support. + throw new RuntimeException( + 'Streaming is not yet implemented for OpenAI Responses API.' + ); + } + + /** + * 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(); + + // Check for built-in tools via customOptions. + $codeInterpreter = isset($customOptions['code_interpreter']) && $customOptions['code_interpreter']; + $imageGeneration = isset($customOptions['image_generation']) && $customOptions['image_generation']; + + if (is_array($functionDeclarations) || $webSearch || $codeInterpreter || $imageGeneration) { + $params['tools'] = $this->prepareToolsParam( + $functionDeclarations, + $webSearch, + $codeInterpreter, + $imageGeneration + ); + } + + /* + * 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. + * Skip the built-in tool options we've already processed. + */ + foreach ($customOptions as $key => $value) { + if ($key === 'code_interpreter' || $key === 'image_generation') { + continue; + } + 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 + { + $input = []; + foreach ($messages as $message) { + $inputItem = $this->getMessageInputItem($message); + if ($inputItem !== null) { + $input[] = $inputItem; + } + } + return $input; + } + + /** + * 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 should be skipped. + */ + protected function getMessageInputItem(Message $message): ?array + { + $parts = $message->getParts(); + $content = []; + $functionOutputs = []; + + foreach ($parts as $part) { + $partData = $this->getMessagePartData($part); + if ($partData !== null) { + // Function call outputs are handled separately. + if (isset($partData['type']) && $partData['type'] === 'function_call_output') { + $functionOutputs[] = $partData; + } else { + $content[] = $partData; + } + } + } + + // If there are function outputs, return them as separate items (they're top-level in the input array). + if (!empty($functionOutputs)) { + // Function outputs are returned directly, not wrapped in a message. + // For now, we only return the first one (the caller should handle multiple). + return $functionOutputs[0]; + } + + if (empty($content)) { + return null; + } + + return [ + 'type' => 'message', + '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, or null if not applicable. + * @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. + $fileBase64Data = $file->getBase64Data(); + if (!$fileBase64Data) { + // This should be impossible due to class internals, but still needs to be checked. + throw new RuntimeException( + 'The inline file must contain base64 data.' + ); + } + $mimeType = $file->getMimeType(); + if ($file->isImage()) { + return [ + 'type' => 'input_image', + 'image_url' => "data:{$mimeType};base64,{$fileBase64Data}", + ]; + } + // For other file types (like PDF), use input_file. + return [ + 'type' => 'input_file', + 'filename' => 'file', + 'file_data' => "data:{$mimeType};base64,{$fileBase64Data}", + ]; + } + if ($type->isFunctionCall()) { + // Function calls in input are typically from assistant messages in conversation history. + // The Responses API handles this differently - we include them as part of the message. + $functionCall = $part->getFunctionCall(); + if (!$functionCall) { + throw new RuntimeException( + 'The function_call typed message part must contain a function call.' + ); + } + // Skip function calls in input - they're part of the conversation flow. + return null; + } + if ($type->isFunctionResponse()) { + $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 [ + '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. + * @param bool $codeInterpreter Whether to include the code interpreter tool. + * @param bool $imageGeneration Whether to include the image generation tool. + * @return list> The prepared tools parameter. + */ + protected function prepareToolsParam( + ?array $functionDeclarations, + ?WebSearch $webSearch, + bool $codeInterpreter = false, + bool $imageGeneration = false + ): 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; + } + + if ($codeInterpreter) { + $tools[] = ['type' => 'code_interpreter']; + } + + if ($imageGeneration) { + $tools[] = ['type' => 'image_generation']; + } + + 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/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php new file mode 100644 index 00000000..a36d8458 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php @@ -0,0 +1,175 @@ +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 prepareGenerateImageParams for testing. + * + * @param list $prompt + * @return array + */ + public function exposePrepareGenerateImageParams(array $prompt): array + { + return $this->prepareGenerateImageParams($prompt); + } + + /** + * Exposes getHostModelForImageGeneration for testing. + * + * @param string $modelId + * @return string + */ + public function exposeGetHostModelForImageGeneration(string $modelId): string + { + return $this->getHostModelForImageGeneration($modelId); + } + + /** + * Exposes prepareImageGenerationTool for testing. + * + * @param string $modelId + * @return array + */ + public function exposePrepareImageGenerationTool(string $modelId): array + { + return $this->prepareImageGenerationTool($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 parseOutputItemToCandidate for testing. + * + * @param array $outputItem + * @param int $index + * @return Candidate|null + */ + public function exposeParseOutputItemToCandidate(array $outputItem, int $index): ?Candidate + { + return $this->parseOutputItemToCandidate($outputItem, $index); + } + + /** + * Exposes parseImageGenerationCallToCandidate for testing. + * + * @param array $outputItem + * @param int $index + * @return Candidate + */ + public function exposeParseImageGenerationCallToCandidate(array $outputItem, int $index): Candidate + { + return $this->parseImageGenerationCallToCandidate($outputItem, $index); + } +} diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php new file mode 100644 index 00000000..0b24e0a8 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -0,0 +1,239 @@ +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|null + */ + public function exposeGetMessagePartData(MessagePart $part): ?array + { + return $this->getMessagePartData($part); + } + + /** + * Exposes prepareToolsParam for testing. + * + * @param list|null $functionDeclarations + * @param WebSearch|null $webSearch + * @param bool $codeInterpreter + * @param bool $imageGeneration + * @return list> + */ + public function exposePrepareToolsParam( + ?array $functionDeclarations, + ?WebSearch $webSearch, + bool $codeInterpreter = false, + bool $imageGeneration = false + ): array { + return $this->prepareToolsParam($functionDeclarations, $webSearch, $codeInterpreter, $imageGeneration); + } + + /** + * 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..1e414abf --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php @@ -0,0 +1,386 @@ +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 + * @return MockOpenAiImageGenerationModel + */ + private function createModel(?ModelConfig $modelConfig = null): MockOpenAiImageGenerationModel + { + $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([ + 'id' => 'resp_img_123', + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'image_generation_call', + 'result' => self::VALID_BASE64_IMAGE, + ], + ], + 'usage' => [ + 'input_tokens' => 50, + 'output_tokens' => 1000, + 'total_tokens' => 1050, + ], + ]) + ); + + $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('resp_img_123', $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 getHostModelForImageGeneration() method. + * + * @return void + */ + public function testGetHostModelForImageGeneration(): void + { + $model = $this->createModel(); + + // For gpt-image-* models, should return gpt-4o as host. + $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-image-1')); + $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-image-1-mini')); + + // For other models, should return the model itself. + $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-4o')); + $this->assertEquals('gpt-5', $model->exposeGetHostModelForImageGeneration('gpt-5')); + } + + /** + * Tests prepareImageGenerationTool() with gpt-image model. + * + * @return void + */ + public function testPrepareImageGenerationToolWithGptImageModel(): void + { + $model = $this->createModel(); + + $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + + $this->assertEquals('image_generation', $tool['type']); + $this->assertEquals('gpt-image-1', $tool['model']); + } + + /** + * Tests prepareImageGenerationTool() with size configuration. + * + * @return void + */ + public function testPrepareImageGenerationToolWithSize(): void + { + $config = new ModelConfig(); + $config->setOutputMediaAspectRatio('16:9'); + $model = $this->createModel($config); + + $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + + $this->assertEquals('1792x1024', $tool['size']); + } + + /** + * Tests prepareImageGenerationTool() with output format. + * + * @return void + */ + public function testPrepareImageGenerationToolWithOutputFormat(): void + { + $config = new ModelConfig(); + $config->setOutputMimeType('image/webp'); + $model = $this->createModel($config); + + $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + + $this->assertEquals('webp', $tool['output_format']); + } + + /** + * 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 aspect ratios. + * + * @return void + */ + public function testPrepareSizeParamWithAspectRatios(): void + { + $model = $this->createModel(); + + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1792x1024', $model->exposePrepareSize(null, '16:9')); + $this->assertEquals('1024x1792', $model->exposePrepareSize(null, '9:16')); + } + + /** + * Tests prepareSizeParam() with orientations. + * + * @return void + */ + public function testPrepareSizeParamWithOrientations(): void + { + $model = $this->createModel(); + + $this->assertEquals('1792x1024', $model->exposePrepareSize(MediaOrientationEnum::landscape(), null)); + $this->assertEquals('1024x1792', $model->exposePrepareSize(MediaOrientationEnum::portrait(), null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize(MediaOrientationEnum::square(), null)); + } + + /** + * Tests prepareSizeParam() defaults to square. + * + * @return void + */ + public function testPrepareSizeParamDefaultsToSquare(): void + { + $model = $this->createModel(); + + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, null)); + } + + /** + * Tests parseImageGenerationCallToCandidate() method. + * + * @return void + */ + public function testParseImageGenerationCallToCandidate(): void + { + $model = $this->createModel(); + + $candidate = $model->exposeParseImageGenerationCallToCandidate([ + 'type' => 'image_generation_call', + 'result' => 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 parseImageGenerationCallToCandidate() with custom MIME type. + * + * @return void + */ + public function testParseImageGenerationCallToCandidateWithCustomMimeType(): void + { + $config = new ModelConfig(); + $config->setOutputMimeType('image/jpeg'); + $model = $this->createModel($config); + + $candidate = $model->exposeParseImageGenerationCallToCandidate([ + 'type' => 'image_generation_call', + 'result' => self::VALID_BASE64_IMAGE, + ], 0); + + $file = $candidate->getMessage()->getParts()[0]->getFile(); + $this->assertNotNull($file); + $this->assertEquals('image/jpeg', $file->getMimeType()); + } + + /** + * Tests parseOutputItemToCandidate() skips non-image output. + * + * @return void + */ + public function testParseOutputItemToCandidateSkipsNonImageOutput(): void + { + $model = $this->createModel(); + + $result = $model->exposeParseOutputItemToCandidate([ + 'type' => 'message', + 'role' => 'assistant', + 'content' => [], + ], 0); + + $this->assertNull($result); + } +} diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php new file mode 100644 index 00000000..026348a2 --- /dev/null +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -0,0 +1,668 @@ +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 streamGenerateTextResult() method throws RuntimeException. + * + * @return void + */ + public function testStreamGenerateTextResultThrowsException(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Streaming is not yet implemented for OpenAI Responses API.'); + + $generator = $model->streamGenerateTextResult($prompt); + $generator->current(); + } + + /** + * 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->assertEquals('message', $params['input'][0]['type']); + $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 code interpreter via customOptions. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithCodeInterpreter(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Run some code')])]; + $config = new ModelConfig(); + $config->setCustomOptions(['code_interpreter' => true]); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('tools', $params); + $toolTypes = array_column($params['tools'], 'type'); + $this->assertContains('code_interpreter', $toolTypes); + } + + /** + * Tests prepareGenerateTextParams() with image generation via customOptions. + * + * @return void + */ + public function testPrepareGenerateTextParamsWithImageGeneration(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate an image')])]; + $config = new ModelConfig(); + $config->setCustomOptions(['image_generation' => true]); + $model = $this->createModel($config); + + $params = $model->exposePrepareGenerateTextParams($prompt); + + $this->assertArrayHasKey('tools', $params); + $toolTypes = array_column($params['tools'], 'type'); + $this->assertContains('image_generation', $toolTypes); + } + + /** + * 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 getMessagePartData() with function response. + * + * @return void + */ + public function testGetMessagePartDataWithFunctionResponse(): void + { + $model = $this->createModel(); + $functionResponse = new FunctionResponse('call_123', 'get_weather', ['temperature' => 72]); + $part = new MessagePart($functionResponse); + + $data = $model->exposeGetMessagePartData($part); + + $this->assertEquals('function_call_output', $data['type']); + $this->assertEquals('call_123', $data['call_id']); + $this->assertEquals('{"temperature":72}', $data['output']); + } + + /** + * Tests prepareToolsParam() with all tool types. + * + * @return void + */ + public function testPrepareToolsParamWithAllTools(): void + { + $model = $this->createModel(); + $functionDeclaration = new FunctionDeclaration( + 'test_func', + 'A test function', + ['type' => 'object'] + ); + $webSearch = new WebSearch(); + + $tools = $model->exposePrepareToolsParam( + [$functionDeclaration], + $webSearch, + true, + true + ); + + $this->assertCount(4, $tools); + $toolTypes = array_column($tools, 'type'); + $this->assertContains('function', $toolTypes); + $this->assertContains('web_search', $toolTypes); + $this->assertContains('code_interpreter', $toolTypes); + $this->assertContains('image_generation', $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()); + } +} From d19795c63a19038e24d416dbe313b6a8664d191c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 30 Dec 2025 15:56:14 -0700 Subject: [PATCH 02/22] fix: corrects custom options --- .../OpenAi/OpenAiTextGenerationModel.php | 20 +++++++++++++++---- .../OpenAi/OpenAiTextGenerationModelTest.php | 15 +++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 01211313..bc3ff4b2 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -158,8 +158,17 @@ protected function prepareGenerateTextParams(array $prompt): array $customOptions = $config->getCustomOptions(); // Check for built-in tools via customOptions. - $codeInterpreter = isset($customOptions['code_interpreter']) && $customOptions['code_interpreter']; - $imageGeneration = isset($customOptions['image_generation']) && $customOptions['image_generation']; + $codeInterpreter = !empty($customOptions['codeInterpreter']); + $imageGeneration = !empty($customOptions['imageGeneration']); + + // TODO: Implement multimodal output support for image_generation tool. + // This requires parsing image_generation_call outputs and returning them as file parts. + if ($imageGeneration) { + throw new RuntimeException( + 'The imageGeneration option is not yet supported for text generation models. ' + . 'Use the ImageGenerationModelInterface instead.' + ); + } if (is_array($functionDeclarations) || $webSearch || $codeInterpreter || $imageGeneration) { $params['tools'] = $this->prepareToolsParam( @@ -176,7 +185,7 @@ protected function prepareGenerateTextParams(array $prompt): array * Skip the built-in tool options we've already processed. */ foreach ($customOptions as $key => $value) { - if ($key === 'code_interpreter' || $key === 'image_generation') { + if ($key === 'codeInterpreter' || $key === 'imageGeneration') { continue; } if (isset($params[$key])) { @@ -413,7 +422,10 @@ protected function prepareToolsParam( } if ($codeInterpreter) { - $tools[] = ['type' => 'code_interpreter']; + $tools[] = [ + 'type' => 'code_interpreter', + 'container' => ['type' => 'auto'], + ]; } if ($imageGeneration) { diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 026348a2..55911249 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -314,7 +314,7 @@ public function testPrepareGenerateTextParamsWithCodeInterpreter(): void { $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Run some code')])]; $config = new ModelConfig(); - $config->setCustomOptions(['code_interpreter' => true]); + $config->setCustomOptions(['codeInterpreter' => true]); $model = $this->createModel($config); $params = $model->exposePrepareGenerateTextParams($prompt); @@ -325,22 +325,21 @@ public function testPrepareGenerateTextParamsWithCodeInterpreter(): void } /** - * Tests prepareGenerateTextParams() with image generation via customOptions. + * Tests prepareGenerateTextParams() with image generation throws exception (not yet supported). * * @return void */ - public function testPrepareGenerateTextParamsWithImageGeneration(): void + public function testPrepareGenerateTextParamsWithImageGenerationThrowsException(): void { $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate an image')])]; $config = new ModelConfig(); - $config->setCustomOptions(['image_generation' => true]); + $config->setCustomOptions(['imageGeneration' => true]); $model = $this->createModel($config); - $params = $model->exposePrepareGenerateTextParams($prompt); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The imageGeneration option is not yet supported'); - $this->assertArrayHasKey('tools', $params); - $toolTypes = array_column($params['tools'], 'type'); - $this->assertContains('image_generation', $toolTypes); + $model->exposePrepareGenerateTextParams($prompt); } /** From e24f55fa02640ab99ed94b0893b2130ea5f7bc85 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 30 Dec 2025 15:56:30 -0700 Subject: [PATCH 03/22] feat: adds cli support for stin or file referencing --- cli.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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) { From 8a861191bb2fa1f587d370c58957cd7ca5f8717d Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 30 Dec 2025 16:00:24 -0700 Subject: [PATCH 04/22] fix: corrects always false return --- .../OpenAi/OpenAiTextGenerationModel.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index bc3ff4b2..788ce049 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -170,12 +170,12 @@ protected function prepareGenerateTextParams(array $prompt): array ); } - if (is_array($functionDeclarations) || $webSearch || $codeInterpreter || $imageGeneration) { + if (is_array($functionDeclarations) || $webSearch || $codeInterpreter) { $params['tools'] = $this->prepareToolsParam( $functionDeclarations, $webSearch, $codeInterpreter, - $imageGeneration + false // imageGeneration not yet supported ); } From 6f21cb67c6304e6f2f4003cd21e8d6e1a6cfb63a Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 30 Dec 2025 16:02:51 -0700 Subject: [PATCH 05/22] test: corrects incorrect namespace --- .../OpenAi/MockOpenAiImageGenerationModel.php | 2 +- .../OpenAi/MockOpenAiTextGenerationModel.php | 2 +- .../OpenAi/OpenAiImageGenerationModelTest.php | 2 +- .../OpenAi/OpenAiTextGenerationModelTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php index a36d8458..36e6269b 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\Unit\ProviderImplementations\OpenAi; +namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php index 0b24e0a8..8802e2fe 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\Unit\ProviderImplementations\OpenAi; +namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php index 1e414abf..05ff318b 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\Unit\ProviderImplementations\OpenAi; +namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Exception\InvalidArgumentException; diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 55911249..4b57636b 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\Unit\ProviderImplementations\OpenAi; +namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Exception\RuntimeException; From 00caa6e739bdce1fb46ecaedfa04b9f31dcaba47 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Mon, 5 Jan 2026 16:31:53 -0700 Subject: [PATCH 06/22] feat: switches to using Images API for image generation model --- .../OpenAi/OpenAiImageGenerationModel.php | 290 ++++++++++-------- .../OpenAi/MockOpenAiImageGenerationModel.php | 49 +-- .../OpenAi/OpenAiImageGenerationModelTest.php | 230 ++++++++------ 3 files changed, 310 insertions(+), 259 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 73153708..4e45c7a0 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -23,33 +23,21 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** - * Class for an OpenAI image generation model using the Responses API. + * Class for an OpenAI image generation model using the Images API. * - * This uses the Responses API with the built-in image_generation tool. + * 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 n.e.x.t * - * @phpstan-type ImageGenerationCallData array{ - * type: string, - * result?: 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 ImageData array{ + * b64_json?: string, + * url?: string, + * revised_prompt?: string * } * @phpstan-type ResponseData array{ - * id?: string, - * status?: string, - * output?: list, - * usage?: UsageData + * created?: int, + * data?: list * } */ class OpenAiImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface @@ -67,7 +55,7 @@ public function generateImageResult(array $prompt): GenerativeAiResult $request = new Request( HttpMethodEnum::POST(), - OpenAiProvider::url('responses'), + OpenAiProvider::url('images/generations'), ['Content-Type' => 'application/json'], $params, $this->getRequestOptions() @@ -95,16 +83,25 @@ protected function prepareGenerateImageParams(array $prompt): array $config = $this->getConfig(); $modelId = $this->metadata()->getId(); - // The Responses API with image_generation tool requires a model that supports it. - // We use a capable model like gpt-4o to process the request with the image_generation tool. $params = [ - 'model' => $this->getHostModelForImageGeneration($modelId), - 'input' => $this->preparePromptParam($prompt), - 'tools' => [ - $this->prepareImageGenerationTool($modelId), - ], + 'model' => $modelId, + 'prompt' => $this->preparePromptParam($prompt), ]; + // Add size configuration if available. + $outputMediaOrientation = $config->getOutputMediaOrientation(); + $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); + if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { + $params['size'] = $this->prepareSizeParam($modelId, $outputMediaOrientation, $outputMediaAspectRatio); + } + + // Add model-specific parameters. + if ($this->isGptImageModel($modelId)) { + $this->addGptImageModelParams($params, $config); + } else { + $this->addDalleModelParams($params); + } + /* * 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. @@ -126,66 +123,53 @@ protected function prepareGenerateImageParams(array $prompt): array } /** - * Gets the host model to use for image generation requests. - * - * The image_generation tool runs within a host model's context. For dedicated - * image generation models like gpt-image-1, we use a capable host model. + * Adds GPT image model specific parameters to the request. * * @since n.e.x.t * - * @param string $modelId The requested model ID. - * @return string The host model ID to use for the request. + * @param array $params The parameters array to modify. + * @param \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config The model configuration. */ - protected function getHostModelForImageGeneration(string $modelId): string + protected function addGptImageModelParams(array &$params, $config): void { - // If this is a gpt-image-* model, we need a host model to run the tool. - // Otherwise, the model itself can host the image_generation tool. - if (str_starts_with($modelId, 'gpt-image-')) { - return 'gpt-4o'; - } - return $modelId; - } - - /** - * Prepares the image_generation tool configuration. - * - * @since n.e.x.t - * - * @param string $modelId The model ID for image generation. - * @return array The tool configuration. - */ - protected function prepareImageGenerationTool(string $modelId): array - { - $config = $this->getConfig(); - $tool = ['type' => 'image_generation']; - - // If a specific image model is requested, include it in the tool config. - if (str_starts_with($modelId, 'gpt-image-')) { - $tool['model'] = $modelId; - } - - // Add size configuration if available. - $outputMediaOrientation = $config->getOutputMediaOrientation(); - $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); - if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { - $tool['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio); - } - // Add output format configuration if available. $outputMimeType = $config->getOutputMimeType(); if ($outputMimeType !== null) { - // Map MIME type to OpenAI format. $formatMap = [ 'image/png' => 'png', 'image/jpeg' => 'jpeg', 'image/webp' => 'webp', ]; if (isset($formatMap[$outputMimeType])) { - $tool['output_format'] = $formatMap[$outputMimeType]; + $params['output_format'] = $formatMap[$outputMimeType]; } } + } + + /** + * Adds DALL-E model specific parameters to the request. + * + * @since n.e.x.t + * + * @param array $params The parameters array to modify. + */ + protected function addDalleModelParams(array &$params): void + { + // DALL-E models need response_format set to b64_json to get base64 data. + $params['response_format'] = 'b64_json'; + } - return $tool; + /** + * 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-'); } /** @@ -228,24 +212,44 @@ protected function preparePromptParam(array $messages): string } /** - * Prepares the size parameter for the image generation tool. + * Prepares the size parameter for the image generation request. * * @since n.e.x.t * + * @param string $modelId The model ID. * @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 prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + protected function prepareSizeParam( + string $modelId, + ?MediaOrientationEnum $orientation, + ?string $aspectRatio + ): string { + if ($this->isGptImageModel($modelId)) { + return $this->prepareGptImageSizeParam($orientation, $aspectRatio); + } + + return $this->prepareDalleSizeParam($modelId, $orientation, $aspectRatio); + } + + /** + * 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', - '16:9' => '1792x1024', - '9:16' => '1024x1792', - '4:3' => '1024x768', - '3:4' => '768x1024', + '3:2' => '1536x1024', + '2:3' => '1024x1536', ]; if (isset($aspectRatioMap[$aspectRatio])) { return $aspectRatioMap[$aspectRatio]; @@ -255,11 +259,64 @@ protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string // Map orientation to size. if ($orientation !== null) { if ($orientation->isLandscape()) { - return '1792x1024'; + return '1536x1024'; } if ($orientation->isPortrait()) { - return '1024x1792'; + 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. @@ -279,49 +336,39 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera /** @var ResponseData $responseData */ $responseData = $response->getData(); - if (!isset($responseData['output']) || !$responseData['output']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'output'); + if (!isset($responseData['data']) || !$responseData['data']) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); } - if (!is_array($responseData['output']) || !array_is_list($responseData['output'])) { + if (!is_array($responseData['data']) || !array_is_list($responseData['data'])) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - 'output', + 'data', 'The value must be an indexed array.' ); } $candidates = []; - foreach ($responseData['output'] as $index => $outputItem) { - if (!is_array($outputItem) || array_is_list($outputItem)) { + foreach ($responseData['data'] as $index => $imageData) { + if (!is_array($imageData) || array_is_list($imageData)) { throw ResponseException::fromInvalidData( $this->providerMetadata()->getName(), - "output[{$index}]", + "data[{$index}]", 'The value must be an associative array.' ); } - $candidate = $this->parseOutputItemToCandidate($outputItem, $index); - if ($candidate !== null) { - $candidates[] = $candidate; - } + $candidates[] = $this->parseImageDataToCandidate($imageData, $index); } - $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + // The Images API doesn't return an ID, so we generate one from the created timestamp. + $id = isset($responseData['created']) ? 'img-' . $responseData['created'] : ''; - 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); - } + // The Images API doesn't return token usage. + $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']); + unset($additionalData['data'], $additionalData['created']); return new GenerativeAiResult( $id, @@ -334,47 +381,24 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera } /** - * 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. - * @return Candidate|null The parsed candidate, or null if the output item should be skipped. - */ - protected function parseOutputItemToCandidate(array $outputItem, int $index): ?Candidate - { - $type = $outputItem['type'] ?? ''; - - // Handle image_generation_call output type. - if ($type === 'image_generation_call') { - return $this->parseImageGenerationCallToCandidate($outputItem, $index); - } - - // Skip other output types. - return null; - } - - /** - * Parses an image_generation_call output item into a Candidate object. + * Parses image data from the API response into a Candidate object. * * @since n.e.x.t * - * @param ImageGenerationCallData $outputItem The output item data. - * @param int $index The index of the output item. + * @param ImageData $imageData The image data from the API response. + * @param int $index The index of the image in the data array. * @return Candidate The parsed candidate. */ - protected function parseImageGenerationCallToCandidate(array $outputItem, int $index): Candidate + protected function parseImageDataToCandidate(array $imageData, int $index): Candidate { - if (!isset($outputItem['result']) || !is_string($outputItem['result'])) { + if (!isset($imageData['b64_json']) || !is_string($imageData['b64_json'])) { throw ResponseException::fromMissingData( $this->providerMetadata()->getName(), - "output[{$index}].result" + "data[{$index}].b64_json" ); } - // The result is base64-encoded image data. - $base64Data = $outputItem['result']; + $base64Data = $imageData['b64_json']; // Determine MIME type from config or default to PNG. $config = $this->getConfig(); diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php index 36e6269b..1d899758 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php @@ -105,25 +105,14 @@ public function exposePrepareGenerateImageParams(array $prompt): array } /** - * Exposes getHostModelForImageGeneration for testing. + * Exposes isGptImageModel for testing. * * @param string $modelId - * @return string - */ - public function exposeGetHostModelForImageGeneration(string $modelId): string - { - return $this->getHostModelForImageGeneration($modelId); - } - - /** - * Exposes prepareImageGenerationTool for testing. - * - * @param string $modelId - * @return array + * @return bool */ - public function exposePrepareImageGenerationTool(string $modelId): array + public function exposeIsGptImageModel(string $modelId): bool { - return $this->prepareImageGenerationTool($modelId); + return $this->isGptImageModel($modelId); } /** @@ -140,36 +129,28 @@ public function exposePreparePromptParam(array $messages): string /** * Exposes prepareSizeParam for testing. * + * @param string $modelId * @param MediaOrientationEnum|null $orientation * @param string|null $aspectRatio * @return string */ - public function exposePrepareSize(?MediaOrientationEnum $orientation, ?string $aspectRatio): string - { - return $this->prepareSizeParam($orientation, $aspectRatio); - } - - /** - * Exposes parseOutputItemToCandidate for testing. - * - * @param array $outputItem - * @param int $index - * @return Candidate|null - */ - public function exposeParseOutputItemToCandidate(array $outputItem, int $index): ?Candidate - { - return $this->parseOutputItemToCandidate($outputItem, $index); + public function exposePrepareSize( + string $modelId, + ?MediaOrientationEnum $orientation, + ?string $aspectRatio + ): string { + return $this->prepareSizeParam($modelId, $orientation, $aspectRatio); } /** - * Exposes parseImageGenerationCallToCandidate for testing. + * Exposes parseImageDataToCandidate for testing. * - * @param array $outputItem + * @param array $imageData * @param int $index * @return Candidate */ - public function exposeParseImageGenerationCallToCandidate(array $outputItem, int $index): Candidate + public function exposeParseImageDataToCandidate(array $imageData, int $index): Candidate { - return $this->parseImageGenerationCallToCandidate($outputItem, $index); + return $this->parseImageDataToCandidate($imageData, $index); } } diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php index 05ff318b..ff09bf36 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php @@ -61,10 +61,16 @@ protected function setUp(): void * Creates a mock instance of OpenAiImageGenerationModel. * * @param ModelConfig|null $modelConfig + * @param string $modelId * @return MockOpenAiImageGenerationModel */ - private function createModel(?ModelConfig $modelConfig = null): 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, @@ -95,19 +101,12 @@ public function testGenerateImageResultSuccess(): void 200, [], json_encode([ - 'id' => 'resp_img_123', - 'status' => 'completed', - 'output' => [ + 'created' => 1234567890, + 'data' => [ [ - 'type' => 'image_generation_call', - 'result' => self::VALID_BASE64_IMAGE, + 'b64_json' => self::VALID_BASE64_IMAGE, ], ], - 'usage' => [ - 'input_tokens' => 50, - 'output_tokens' => 1000, - 'total_tokens' => 1050, - ], ]) ); @@ -125,7 +124,7 @@ public function testGenerateImageResultSuccess(): void $result = $model->generateImageResult($prompt); $this->assertInstanceOf(GenerativeAiResult::class, $result); - $this->assertEquals('resp_img_123', $result->getId()); + $this->assertEquals('img-1234567890', $result->getId()); $this->assertCount(1, $result->getCandidates()); $candidate = $result->getCandidates()[0]; $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); @@ -166,146 +165,157 @@ public function testGenerateImageResultApiFailure(): void } /** - * Tests getHostModelForImageGeneration() method. + * Tests isGptImageModel() method. * * @return void */ - public function testGetHostModelForImageGeneration(): void + public function testIsGptImageModel(): void { $model = $this->createModel(); - // For gpt-image-* models, should return gpt-4o as host. - $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-image-1')); - $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-image-1-mini')); + // 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')); - // For other models, should return the model itself. - $this->assertEquals('gpt-4o', $model->exposeGetHostModelForImageGeneration('gpt-4o')); - $this->assertEquals('gpt-5', $model->exposeGetHostModelForImageGeneration('gpt-5')); + // Other models should return false. + $this->assertFalse($model->exposeIsGptImageModel('gpt-4o')); } /** - * Tests prepareImageGenerationTool() with gpt-image model. + * Tests preparePromptParam() with valid single message. * * @return void */ - public function testPrepareImageGenerationToolWithGptImageModel(): void + public function testPreparePromptParamWithValidMessage(): void { $model = $this->createModel(); + $messages = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + $prompt = $model->exposePreparePromptParam($messages); - $this->assertEquals('image_generation', $tool['type']); - $this->assertEquals('gpt-image-1', $tool['model']); + $this->assertEquals('Generate a cat', $prompt); } /** - * Tests prepareImageGenerationTool() with size configuration. + * Tests preparePromptParam() with multiple messages throws exception. * * @return void */ - public function testPrepareImageGenerationToolWithSize(): void + public function testPreparePromptParamWithMultipleMessagesThrowsException(): void { - $config = new ModelConfig(); - $config->setOutputMediaAspectRatio('16:9'); - $model = $this->createModel($config); + $model = $this->createModel(); + $messages = [ + new Message(MessageRoleEnum::user(), [new MessagePart('Message 1')]), + new Message(MessageRoleEnum::user(), [new MessagePart('Message 2')]), + ]; - $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API requires a single user message as prompt.'); - $this->assertEquals('1792x1024', $tool['size']); + $model->exposePreparePromptParam($messages); } /** - * Tests prepareImageGenerationTool() with output format. + * Tests preparePromptParam() with non-user message throws exception. * * @return void */ - public function testPrepareImageGenerationToolWithOutputFormat(): void + public function testPreparePromptParamWithNonUserMessageThrowsException(): void { - $config = new ModelConfig(); - $config->setOutputMimeType('image/webp'); - $model = $this->createModel($config); + $model = $this->createModel(); + $messages = [new Message(MessageRoleEnum::model(), [new MessagePart('Response')])]; - $tool = $model->exposePrepareImageGenerationTool('gpt-image-1'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API requires a user message as prompt.'); - $this->assertEquals('webp', $tool['output_format']); + $model->exposePreparePromptParam($messages); } /** - * Tests preparePromptParam() with valid single message. + * Tests prepareSizeParam() with GPT image model aspect ratios. * * @return void */ - public function testPreparePromptParamWithValidMessage(): void + public function testPrepareSizeParamWithGptImageModelAspectRatios(): void { $model = $this->createModel(); - $messages = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate a cat')])]; - - $prompt = $model->exposePreparePromptParam($messages); - $this->assertEquals('Generate a cat', $prompt); + $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', null, '1:1')); + $this->assertEquals('1536x1024', $model->exposePrepareSize('gpt-image-1', null, '3:2')); + $this->assertEquals('1024x1536', $model->exposePrepareSize('gpt-image-1', null, '2:3')); } /** - * Tests preparePromptParam() with multiple messages throws exception. + * Tests prepareSizeParam() with GPT image model orientations. * * @return void */ - public function testPreparePromptParamWithMultipleMessagesThrowsException(): void + public function testPrepareSizeParamWithGptImageModelOrientations(): 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.'); + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + $square = MediaOrientationEnum::square(); - $model->exposePreparePromptParam($messages); + $this->assertEquals('1536x1024', $model->exposePrepareSize('gpt-image-1', $landscape, null)); + $this->assertEquals('1024x1536', $model->exposePrepareSize('gpt-image-1', $portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', $square, null)); } /** - * Tests preparePromptParam() with non-user message throws exception. + * Tests prepareSizeParam() with DALL-E 3 aspect ratios. * * @return void */ - public function testPreparePromptParamWithNonUserMessageThrowsException(): void + public function testPrepareSizeParamWithDalle3AspectRatios(): 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); + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', null, '1:1')); + $this->assertEquals('1792x1024', $model->exposePrepareSize('dall-e-3', null, '7:4')); + $this->assertEquals('1024x1792', $model->exposePrepareSize('dall-e-3', null, '4:7')); } /** - * Tests prepareSizeParam() with aspect ratios. + * Tests prepareSizeParam() with DALL-E 3 orientations. * * @return void */ - public function testPrepareSizeParamWithAspectRatios(): void + public function testPrepareSizeParamWithDalle3Orientations(): void { $model = $this->createModel(); - $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); - $this->assertEquals('1792x1024', $model->exposePrepareSize(null, '16:9')); - $this->assertEquals('1024x1792', $model->exposePrepareSize(null, '9:16')); + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + $square = MediaOrientationEnum::square(); + + $this->assertEquals('1792x1024', $model->exposePrepareSize('dall-e-3', $landscape, null)); + $this->assertEquals('1024x1792', $model->exposePrepareSize('dall-e-3', $portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', $square, null)); } /** - * Tests prepareSizeParam() with orientations. + * Tests prepareSizeParam() with DALL-E 2 (only supports square). * * @return void */ - public function testPrepareSizeParamWithOrientations(): void + public function testPrepareSizeParamWithDalle2(): void { $model = $this->createModel(); - $this->assertEquals('1792x1024', $model->exposePrepareSize(MediaOrientationEnum::landscape(), null)); - $this->assertEquals('1024x1792', $model->exposePrepareSize(MediaOrientationEnum::portrait(), null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize(MediaOrientationEnum::square(), null)); + $landscape = MediaOrientationEnum::landscape(); + $portrait = MediaOrientationEnum::portrait(); + + // DALL-E 2 only supports square images. + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-2', null, '1:1')); + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-2', $landscape, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-2', $portrait, null)); } /** @@ -317,21 +327,21 @@ public function testPrepareSizeParamDefaultsToSquare(): void { $model = $this->createModel(); - $this->assertEquals('1024x1024', $model->exposePrepareSize(null, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', null, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', null, null)); } /** - * Tests parseImageGenerationCallToCandidate() method. + * Tests parseImageDataToCandidate() method. * * @return void */ - public function testParseImageGenerationCallToCandidate(): void + public function testParseImageDataToCandidate(): void { $model = $this->createModel(); - $candidate = $model->exposeParseImageGenerationCallToCandidate([ - 'type' => 'image_generation_call', - 'result' => self::VALID_BASE64_IMAGE, + $candidate = $model->exposeParseImageDataToCandidate([ + 'b64_json' => self::VALID_BASE64_IMAGE, ], 0); $this->assertNotNull($candidate); @@ -346,19 +356,18 @@ public function testParseImageGenerationCallToCandidate(): void } /** - * Tests parseImageGenerationCallToCandidate() with custom MIME type. + * Tests parseImageDataToCandidate() with custom MIME type. * * @return void */ - public function testParseImageGenerationCallToCandidateWithCustomMimeType(): void + public function testParseImageDataToCandidateWithCustomMimeType(): void { $config = new ModelConfig(); $config->setOutputMimeType('image/jpeg'); $model = $this->createModel($config); - $candidate = $model->exposeParseImageGenerationCallToCandidate([ - 'type' => 'image_generation_call', - 'result' => self::VALID_BASE64_IMAGE, + $candidate = $model->exposeParseImageDataToCandidate([ + 'b64_json' => self::VALID_BASE64_IMAGE, ], 0); $file = $candidate->getMessage()->getParts()[0]->getFile(); @@ -367,20 +376,57 @@ public function testParseImageGenerationCallToCandidateWithCustomMimeType(): voi } /** - * Tests parseOutputItemToCandidate() skips non-image output. + * Tests prepareGenerateImageParams() for GPT image model. * * @return void */ - public function testParseOutputItemToCandidateSkipsNonImageOutput(): void + public function testPrepareGenerateImageParamsForGptImageModel(): void { - $model = $this->createModel(); + $config = new ModelConfig(); + $config->setOutputMimeType('image/webp'); + $model = $this->createModel($config, 'gpt-image-1'); - $result = $model->exposeParseOutputItemToCandidate([ - 'type' => 'message', - 'role' => 'assistant', - 'content' => [], - ], 0); + $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->assertNull($result); + $this->assertEquals('1536x1024', $params['size']); } } From aac8cbe2633a4f49c89e388b55b931c49a5e4f55 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Mon, 5 Jan 2026 16:51:29 -0700 Subject: [PATCH 07/22] refactor: removes image generation from text model --- .../OpenAi/OpenAiTextGenerationModel.php | 39 +++++++++---------- .../OpenAi/MockOpenAiTextGenerationModel.php | 6 +-- .../OpenAi/OpenAiTextGenerationModelTest.php | 18 ++++----- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 788ce049..8a44c842 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -157,35 +157,38 @@ protected function prepareGenerateTextParams(array $prompt): array $webSearch = $config->getWebSearch(); $customOptions = $config->getCustomOptions(); + /* + * Handle previous_response_id for conversation state. + * + * This allows chaining responses together for multi-turn conversations without + * resending the entire conversation history. Pass the response ID from a previous + * call to continue the conversation. + * + * @see https://platform.openai.com/docs/guides/conversation-state + */ + if (!empty($customOptions['previous_response_id'])) { + $params['previous_response_id'] = $customOptions['previous_response_id']; + } + // Check for built-in tools via customOptions. $codeInterpreter = !empty($customOptions['codeInterpreter']); - $imageGeneration = !empty($customOptions['imageGeneration']); - - // TODO: Implement multimodal output support for image_generation tool. - // This requires parsing image_generation_call outputs and returning them as file parts. - if ($imageGeneration) { - throw new RuntimeException( - 'The imageGeneration option is not yet supported for text generation models. ' - . 'Use the ImageGenerationModelInterface instead.' - ); - } if (is_array($functionDeclarations) || $webSearch || $codeInterpreter) { $params['tools'] = $this->prepareToolsParam( $functionDeclarations, $webSearch, - $codeInterpreter, - false // imageGeneration not yet supported + $codeInterpreter ); } /* * 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. - * Skip the built-in tool options we've already processed. + * Skip options we've already processed explicitly. */ + $processedCustomOptions = ['codeInterpreter', 'previous_response_id']; foreach ($customOptions as $key => $value) { - if ($key === 'codeInterpreter' || $key === 'imageGeneration') { + if (in_array($key, $processedCustomOptions, true)) { continue; } if (isset($params[$key])) { @@ -392,14 +395,12 @@ protected function getMessagePartData(MessagePart $part): ?array * @param list|null $functionDeclarations The function declarations, or null if none. * @param WebSearch|null $webSearch The web search config, or null if none. * @param bool $codeInterpreter Whether to include the code interpreter tool. - * @param bool $imageGeneration Whether to include the image generation tool. * @return list> The prepared tools parameter. */ protected function prepareToolsParam( ?array $functionDeclarations, ?WebSearch $webSearch, - bool $codeInterpreter = false, - bool $imageGeneration = false + bool $codeInterpreter = false ): array { $tools = []; @@ -428,10 +429,6 @@ protected function prepareToolsParam( ]; } - if ($imageGeneration) { - $tools[] = ['type' => 'image_generation']; - } - return $tools; } diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php index 8802e2fe..f64cf119 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -158,16 +158,14 @@ public function exposeGetMessagePartData(MessagePart $part): ?array * @param list|null $functionDeclarations * @param WebSearch|null $webSearch * @param bool $codeInterpreter - * @param bool $imageGeneration * @return list> */ public function exposePrepareToolsParam( ?array $functionDeclarations, ?WebSearch $webSearch, - bool $codeInterpreter = false, - bool $imageGeneration = false + bool $codeInterpreter = false ): array { - return $this->prepareToolsParam($functionDeclarations, $webSearch, $codeInterpreter, $imageGeneration); + return $this->prepareToolsParam($functionDeclarations, $webSearch, $codeInterpreter); } /** diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 4b57636b..cfb239d8 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -325,21 +325,21 @@ public function testPrepareGenerateTextParamsWithCodeInterpreter(): void } /** - * Tests prepareGenerateTextParams() with image generation throws exception (not yet supported). + * Tests prepareGenerateTextParams() with previous_response_id for conversation state. * * @return void */ - public function testPrepareGenerateTextParamsWithImageGenerationThrowsException(): void + public function testPrepareGenerateTextParamsWithPreviousResponseId(): void { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Generate an image')])]; + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Continue the conversation')])]; $config = new ModelConfig(); - $config->setCustomOptions(['imageGeneration' => true]); + $config->setCustomOptions(['previous_response_id' => 'resp_abc123']); $model = $this->createModel($config); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('The imageGeneration option is not yet supported'); + $params = $model->exposePrepareGenerateTextParams($prompt); - $model->exposePrepareGenerateTextParams($prompt); + $this->assertArrayHasKey('previous_response_id', $params); + $this->assertEquals('resp_abc123', $params['previous_response_id']); } /** @@ -470,16 +470,14 @@ public function testPrepareToolsParamWithAllTools(): void $tools = $model->exposePrepareToolsParam( [$functionDeclaration], $webSearch, - true, true ); - $this->assertCount(4, $tools); + $this->assertCount(3, $tools); $toolTypes = array_column($tools, 'type'); $this->assertContains('function', $toolTypes); $this->assertContains('web_search', $toolTypes); $this->assertContains('code_interpreter', $toolTypes); - $this->assertContains('image_generation', $toolTypes); } /** From 437d0d978cae73333bfc1c7ccce0337790514873 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 7 Jan 2026 12:45:07 -0700 Subject: [PATCH 08/22] feat: adds support for document inputs --- .../OpenAi/OpenAiModelMetadataDirectory.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index e0022529..a4c878b3 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -89,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()]]), @@ -100,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( From 32291e8ab15bf0d180b020e2092621782fa70a42 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 8 Jan 2026 16:15:48 -0700 Subject: [PATCH 09/22] refactor: uses OpenAi compatible image model class --- .../OpenAi/OpenAiImageGenerationModel.php | 320 +++++------------- .../OpenAi/MockOpenAiImageGenerationModel.php | 26 +- .../OpenAi/OpenAiImageGenerationModelTest.php | 65 ++-- 3 files changed, 124 insertions(+), 287 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 4e45c7a0..b6c8b447 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -4,23 +4,14 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; -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\Http\Exception\ResponseException; -use WordPress\AiClient\Providers\Http\Util\ResponseUtil; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Results\DTO\Candidate; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; -use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** * Class for an OpenAI image generation model using the Images API. @@ -30,207 +21,138 @@ * * @since n.e.x.t * - * @phpstan-type ImageData array{ - * b64_json?: string, - * url?: string, - * revised_prompt?: string - * } - * @phpstan-type ResponseData array{ + * @phpstan-type ImageResponseData array{ * created?: int, - * data?: list + * data?: list * } */ -class OpenAiImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface +class OpenAiImageGenerationModel extends AbstractOpenAiCompatibleImageGenerationModel { /** * {@inheritDoc} * * @since n.e.x.t */ - public function generateImageResult(array $prompt): GenerativeAiResult - { - $httpTransporter = $this->getHttpTransporter(); - - $params = $this->prepareGenerateImageParams($prompt); - - $request = new Request( - HttpMethodEnum::POST(), - OpenAiProvider::url('images/generations'), - ['Content-Type' => 'application/json'], - $params, + protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request { + return new Request( + $method, + OpenAiProvider::url($path), + $headers, + $data, $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. + * {@inheritDoc} * * @since n.e.x.t - * - * @param list $prompt The prompt to generate an image for. Should be a single user message. - * @return array The parameters for the API request. */ protected function prepareGenerateImageParams(array $prompt): array { - $config = $this->getConfig(); + $params = parent::prepareGenerateImageParams($prompt); $modelId = $this->metadata()->getId(); - $params = [ - 'model' => $modelId, - 'prompt' => $this->preparePromptParam($prompt), - ]; - - // Add size configuration if available. - $outputMediaOrientation = $config->getOutputMediaOrientation(); - $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); - if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { - $params['size'] = $this->prepareSizeParam($modelId, $outputMediaOrientation, $outputMediaAspectRatio); - } - - // Add model-specific parameters. + // GPT image models use output_format, DALL-E uses response_format. if ($this->isGptImageModel($modelId)) { - $this->addGptImageModelParams($params, $config); - } else { - $this->addDalleModelParams($params); - } - - /* - * 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. - */ - $customOptions = $config->getCustomOptions(); - 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; + // For GPT image models, convert response_format to the appropriate format. + // The parent sets response_format, but GPT models don't need it. + unset($params['response_format']); } return $params; } /** - * Adds GPT image model specific parameters to the request. + * {@inheritDoc} * * @since n.e.x.t - * - * @param array $params The parameters array to modify. - * @param \WordPress\AiClient\Providers\Models\DTO\ModelConfig $config The model configuration. */ - protected function addGptImageModelParams(array &$params, $config): void + protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string { - // Add output format configuration if available. - $outputMimeType = $config->getOutputMimeType(); - if ($outputMimeType !== null) { - $formatMap = [ - 'image/png' => 'png', - 'image/jpeg' => 'jpeg', - 'image/webp' => 'webp', - ]; - if (isset($formatMap[$outputMimeType])) { - $params['output_format'] = $formatMap[$outputMimeType]; - } + $modelId = $this->metadata()->getId(); + + if ($this->isGptImageModel($modelId)) { + return $this->prepareGptImageSizeParam($orientation, $aspectRatio); } - } - /** - * Adds DALL-E model specific parameters to the request. - * - * @since n.e.x.t - * - * @param array $params The parameters array to modify. - */ - protected function addDalleModelParams(array &$params): void - { - // DALL-E models need response_format set to b64_json to get base64 data. - $params['response_format'] = 'b64_json'; + return $this->prepareDalleSizeParam($modelId, $orientation, $aspectRatio); } /** - * Checks if the given model ID is a GPT image model. + * {@inheritDoc} * - * @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 prompt parameter for the API request. + * Overrides the parent to handle OpenAI's `created` timestamp instead of `id`. * * @since n.e.x.t - * - * @param list $messages The messages to prepare. Should be a single user message. - * @return string The prepared prompt string. */ - protected function preparePromptParam(array $messages): string - { - if (count($messages) !== 1) { - throw new InvalidArgumentException( - 'The API requires a single user message as prompt.' - ); + protected function parseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'image/png' + ): GenerativeAiResult { + /** @var ImageResponseData $responseData */ + $responseData = $response->getData(); + + if (!isset($responseData['data']) || !$responseData['data']) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); } - $message = $messages[0]; - if (!$message->getRole()->isUser()) { - throw new InvalidArgumentException( - 'The API requires a user message as prompt.' + if (!is_array($responseData['data']) || !array_is_list($responseData['data'])) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + 'data', + 'The value must be an indexed array.' ); } - $text = null; - foreach ($message->getParts() as $part) { - $text = $part->getText(); - if ($text !== null) { - break; + $candidates = []; + foreach ($responseData['data'] as $index => $choiceData) { + if (!is_array($choiceData) || array_is_list($choiceData)) { + throw ResponseException::fromInvalidData( + $this->providerMetadata()->getName(), + "data[{$index}]", + 'The value must be an associative array.' + ); } - } - if ($text === null) { - throw new InvalidArgumentException( - 'The API requires a text message part as prompt.' - ); + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); } - return $text; + // The Images API returns `created` timestamp instead of `id`. + $id = isset($responseData['created']) ? 'img-' . $responseData['created'] : ''; + + // The Images API doesn't return token usage. + $tokenUsage = new TokenUsage(0, 0, 0); + + // Use any other data from the response as provider-specific response metadata. + $additionalData = $responseData; + unset($additionalData['data'], $additionalData['created']); + + return new GenerativeAiResult( + $id, + $candidates, + $tokenUsage, + $this->providerMetadata(), + $this->metadata(), + $additionalData + ); } /** - * Prepares the size parameter for the image generation request. + * Checks if the given model ID is a GPT image model. * * @since n.e.x.t * - * @param string $modelId The model ID. - * @param MediaOrientationEnum|null $orientation The desired media orientation. - * @param string|null $aspectRatio The desired media aspect ratio. - * @return string The size parameter value. + * @param string $modelId The model ID to check. + * @return bool True if it's a GPT image model, false otherwise. */ - protected function prepareSizeParam( - string $modelId, - ?MediaOrientationEnum $orientation, - ?string $aspectRatio - ): string { - if ($this->isGptImageModel($modelId)) { - return $this->prepareGptImageSizeParam($orientation, $aspectRatio); - } - - return $this->prepareDalleSizeParam($modelId, $orientation, $aspectRatio); + protected function isGptImageModel(string $modelId): bool + { + return str_starts_with($modelId, 'gpt-image-'); } /** @@ -322,92 +244,4 @@ protected function prepareDalleSizeParam( // Default to square. return '1024x1024'; } - - /** - * 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['data']) || !$responseData['data']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); - } - if (!is_array($responseData['data']) || !array_is_list($responseData['data'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'data', - 'The value must be an indexed array.' - ); - } - - $candidates = []; - foreach ($responseData['data'] as $index => $imageData) { - if (!is_array($imageData) || array_is_list($imageData)) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "data[{$index}]", - 'The value must be an associative array.' - ); - } - - $candidates[] = $this->parseImageDataToCandidate($imageData, $index); - } - - // The Images API doesn't return an ID, so we generate one from the created timestamp. - $id = isset($responseData['created']) ? 'img-' . $responseData['created'] : ''; - - // The Images API doesn't return token usage. - $tokenUsage = new TokenUsage(0, 0, 0); - - // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset($additionalData['data'], $additionalData['created']); - - return new GenerativeAiResult( - $id, - $candidates, - $tokenUsage, - $this->providerMetadata(), - $this->metadata(), - $additionalData - ); - } - - /** - * Parses image data from the API response into a Candidate object. - * - * @since n.e.x.t - * - * @param ImageData $imageData The image data from the API response. - * @param int $index The index of the image in the data array. - * @return Candidate The parsed candidate. - */ - protected function parseImageDataToCandidate(array $imageData, int $index): Candidate - { - if (!isset($imageData['b64_json']) || !is_string($imageData['b64_json'])) { - throw ResponseException::fromMissingData( - $this->providerMetadata()->getName(), - "data[{$index}].b64_json" - ); - } - - $base64Data = $imageData['b64_json']; - - // Determine MIME type from config or default to PNG. - $config = $this->getConfig(); - $mimeType = $config->getOutputMimeType() ?? 'image/png'; - - $imageFile = new File($base64Data, $mimeType); - $parts = [new MessagePart($imageFile)]; - $message = new Message(MessageRoleEnum::model(), $parts); - - return new Candidate($message, FinishReasonEnum::stop()); - } } diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php index 1d899758..b72e974c 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiImageGenerationModel.php @@ -83,12 +83,14 @@ public function setMockGenerativeAiResult(GenerativeAiResult $result): void /** * {@inheritDoc} */ - public function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult - { + protected function parseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'image/png' + ): GenerativeAiResult { if ($this->mockGenerativeAiResult) { return $this->mockGenerativeAiResult; } - return parent::parseResponseToGenerativeAiResult($response); + return parent::parseResponseToGenerativeAiResult($response, $expectedMimeType); } // Expose protected methods for testing. @@ -129,28 +131,30 @@ public function exposePreparePromptParam(array $messages): string /** * Exposes prepareSizeParam for testing. * - * @param string $modelId * @param MediaOrientationEnum|null $orientation * @param string|null $aspectRatio * @return string */ public function exposePrepareSize( - string $modelId, ?MediaOrientationEnum $orientation, ?string $aspectRatio ): string { - return $this->prepareSizeParam($modelId, $orientation, $aspectRatio); + return $this->prepareSizeParam($orientation, $aspectRatio); } /** - * Exposes parseImageDataToCandidate for testing. + * Exposes parseResponseChoiceToCandidate for testing. * - * @param array $imageData + * @param array $choiceData * @param int $index + * @param string $expectedMimeType * @return Candidate */ - public function exposeParseImageDataToCandidate(array $imageData, int $index): Candidate - { - return $this->parseImageDataToCandidate($imageData, $index); + 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/OpenAiImageGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php index ff09bf36..1b5b6bc9 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiImageGenerationModelTest.php @@ -243,11 +243,11 @@ public function testPreparePromptParamWithNonUserMessageThrowsException(): void */ public function testPrepareSizeParamWithGptImageModelAspectRatios(): void { - $model = $this->createModel(); + $model = $this->createModel(null, 'gpt-image-1'); - $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', null, '1:1')); - $this->assertEquals('1536x1024', $model->exposePrepareSize('gpt-image-1', null, '3:2')); - $this->assertEquals('1024x1536', $model->exposePrepareSize('gpt-image-1', null, '2:3')); + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1536x1024', $model->exposePrepareSize(null, '3:2')); + $this->assertEquals('1024x1536', $model->exposePrepareSize(null, '2:3')); } /** @@ -257,15 +257,15 @@ public function testPrepareSizeParamWithGptImageModelAspectRatios(): void */ public function testPrepareSizeParamWithGptImageModelOrientations(): void { - $model = $this->createModel(); + $model = $this->createModel(null, 'gpt-image-1'); $landscape = MediaOrientationEnum::landscape(); $portrait = MediaOrientationEnum::portrait(); $square = MediaOrientationEnum::square(); - $this->assertEquals('1536x1024', $model->exposePrepareSize('gpt-image-1', $landscape, null)); - $this->assertEquals('1024x1536', $model->exposePrepareSize('gpt-image-1', $portrait, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', $square, null)); + $this->assertEquals('1536x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1536', $model->exposePrepareSize($portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); } /** @@ -275,11 +275,11 @@ public function testPrepareSizeParamWithGptImageModelOrientations(): void */ public function testPrepareSizeParamWithDalle3AspectRatios(): void { - $model = $this->createModel(); + $model = $this->createModel(null, 'dall-e-3'); - $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', null, '1:1')); - $this->assertEquals('1792x1024', $model->exposePrepareSize('dall-e-3', null, '7:4')); - $this->assertEquals('1024x1792', $model->exposePrepareSize('dall-e-3', null, '4:7')); + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1792x1024', $model->exposePrepareSize(null, '7:4')); + $this->assertEquals('1024x1792', $model->exposePrepareSize(null, '4:7')); } /** @@ -289,15 +289,15 @@ public function testPrepareSizeParamWithDalle3AspectRatios(): void */ public function testPrepareSizeParamWithDalle3Orientations(): void { - $model = $this->createModel(); + $model = $this->createModel(null, 'dall-e-3'); $landscape = MediaOrientationEnum::landscape(); $portrait = MediaOrientationEnum::portrait(); $square = MediaOrientationEnum::square(); - $this->assertEquals('1792x1024', $model->exposePrepareSize('dall-e-3', $landscape, null)); - $this->assertEquals('1024x1792', $model->exposePrepareSize('dall-e-3', $portrait, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', $square, null)); + $this->assertEquals('1792x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1792', $model->exposePrepareSize($portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($square, null)); } /** @@ -307,15 +307,15 @@ public function testPrepareSizeParamWithDalle3Orientations(): void */ public function testPrepareSizeParamWithDalle2(): void { - $model = $this->createModel(); + $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('dall-e-2', null, '1:1')); - $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-2', $landscape, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-2', $portrait, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize(null, '1:1')); + $this->assertEquals('1024x1024', $model->exposePrepareSize($landscape, null)); + $this->assertEquals('1024x1024', $model->exposePrepareSize($portrait, null)); } /** @@ -325,22 +325,23 @@ public function testPrepareSizeParamWithDalle2(): void */ public function testPrepareSizeParamDefaultsToSquare(): void { - $model = $this->createModel(); + $gptModel = $this->createModel(null, 'gpt-image-1'); + $dalleModel = $this->createModel(null, 'dall-e-3'); - $this->assertEquals('1024x1024', $model->exposePrepareSize('gpt-image-1', null, null)); - $this->assertEquals('1024x1024', $model->exposePrepareSize('dall-e-3', null, null)); + $this->assertEquals('1024x1024', $gptModel->exposePrepareSize(null, null)); + $this->assertEquals('1024x1024', $dalleModel->exposePrepareSize(null, null)); } /** - * Tests parseImageDataToCandidate() method. + * Tests parseResponseChoiceToCandidate() method. * * @return void */ - public function testParseImageDataToCandidate(): void + public function testParseResponseChoiceToCandidate(): void { $model = $this->createModel(); - $candidate = $model->exposeParseImageDataToCandidate([ + $candidate = $model->exposeParseResponseChoiceToCandidate([ 'b64_json' => self::VALID_BASE64_IMAGE, ], 0); @@ -356,19 +357,17 @@ public function testParseImageDataToCandidate(): void } /** - * Tests parseImageDataToCandidate() with custom MIME type. + * Tests parseResponseChoiceToCandidate() with custom MIME type. * * @return void */ - public function testParseImageDataToCandidateWithCustomMimeType(): void + public function testParseResponseChoiceToCandidateWithCustomMimeType(): void { - $config = new ModelConfig(); - $config->setOutputMimeType('image/jpeg'); - $model = $this->createModel($config); + $model = $this->createModel(); - $candidate = $model->exposeParseImageDataToCandidate([ + $candidate = $model->exposeParseResponseChoiceToCandidate([ 'b64_json' => self::VALID_BASE64_IMAGE, - ], 0); + ], 0, 'image/jpeg'); $file = $candidate->getMessage()->getParts()[0]->getFile(); $this->assertNotNull($file); From 932f26dfbe8340ab37e624bbd326cd84ee640f88 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 8 Jan 2026 16:21:16 -0700 Subject: [PATCH 10/22] refactor: simplifies redundancies --- .../OpenAi/OpenAiTextGenerationModel.php | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 8a44c842..7fb9c468 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -157,19 +157,6 @@ protected function prepareGenerateTextParams(array $prompt): array $webSearch = $config->getWebSearch(); $customOptions = $config->getCustomOptions(); - /* - * Handle previous_response_id for conversation state. - * - * This allows chaining responses together for multi-turn conversations without - * resending the entire conversation history. Pass the response ID from a previous - * call to continue the conversation. - * - * @see https://platform.openai.com/docs/guides/conversation-state - */ - if (!empty($customOptions['previous_response_id'])) { - $params['previous_response_id'] = $customOptions['previous_response_id']; - } - // Check for built-in tools via customOptions. $codeInterpreter = !empty($customOptions['codeInterpreter']); @@ -186,7 +173,7 @@ protected function prepareGenerateTextParams(array $prompt): array * This allows developers to pass other options that may be more niche or not yet supported by the SDK. * Skip options we've already processed explicitly. */ - $processedCustomOptions = ['codeInterpreter', 'previous_response_id']; + $processedCustomOptions = ['codeInterpreter']; foreach ($customOptions as $key => $value) { if (in_array($key, $processedCustomOptions, true)) { continue; @@ -332,25 +319,24 @@ protected function getMessagePartData(MessagePart $part): ?array ]; } // Else, it is an inline file. - $fileBase64Data = $file->getBase64Data(); - if (!$fileBase64Data) { + $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.' ); } - $mimeType = $file->getMimeType(); if ($file->isImage()) { return [ 'type' => 'input_image', - 'image_url' => "data:{$mimeType};base64,{$fileBase64Data}", + 'image_url' => $dataUri, ]; } // For other file types (like PDF), use input_file. return [ 'type' => 'input_file', 'filename' => 'file', - 'file_data' => "data:{$mimeType};base64,{$fileBase64Data}", + 'file_data' => $dataUri, ]; } if ($type->isFunctionCall()) { From bf04bc68c9da028e3bbadf8c899dccb7abfd0c4b Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 8 Jan 2026 22:29:06 -0700 Subject: [PATCH 11/22] fix: corrects function calling in input array --- .../OpenAi/OpenAiTextGenerationModel.php | 100 ++++++++++-------- .../OpenAi/OpenAiTextGenerationModelTest.php | 53 +++++++++- 2 files changed, 107 insertions(+), 46 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 7fb9c468..2b33b4c7 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -218,31 +218,67 @@ protected function prepareInputParam(array $messages): array * @since n.e.x.t * * @param Message $message The message to convert. - * @return array|null The input item, or null if the message should be skipped. + * @return array|null The input item, or null if the message is empty. + * @throws InvalidArgumentException If a function call/response message contains multiple parts. */ protected function getMessageInputItem(Message $message): ?array { $parts = $message->getParts(); - $content = []; - $functionOutputs = []; + if (empty($parts)) { + return null; + } + + $content = []; foreach ($parts as $part) { - $partData = $this->getMessagePartData($part); - if ($partData !== null) { - // Function call outputs are handled separately. - if (isset($partData['type']) && $partData['type'] === 'function_call_output') { - $functionOutputs[] = $partData; - } else { - $content[] = $partData; + $type = $part->getType(); + + // Function call message → return as function_call item. + if ($type->isFunctionCall()) { + if (count($parts) > 1) { + throw new InvalidArgumentException( + 'A function call message must contain only one part.' + ); + } + $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 there are function outputs, return them as separate items (they're top-level in the input array). - if (!empty($functionOutputs)) { - // Function outputs are returned directly, not wrapped in a message. - // For now, we only return the first one (the caller should handle multiple). - return $functionOutputs[0]; + // Function response message → return as function_call_output item. + if ($type->isFunctionResponse()) { + if (count($parts) > 1) { + throw new InvalidArgumentException( + 'A function response message must contain only one part.' + ); + } + $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()), + ]; + } + + // Regular content part. + $partData = $this->getMessagePartData($part); + if ($partData !== null) { + $content[] = $partData; + } } if (empty($content)) { @@ -250,7 +286,6 @@ protected function getMessageInputItem(Message $message): ?array } return [ - 'type' => 'message', 'role' => $this->getMessageRoleString($message->getRole()), 'content' => $content, ]; @@ -339,31 +374,12 @@ protected function getMessagePartData(MessagePart $part): ?array 'file_data' => $dataUri, ]; } - if ($type->isFunctionCall()) { - // Function calls in input are typically from assistant messages in conversation history. - // The Responses API handles this differently - we include them as part of the message. - $functionCall = $part->getFunctionCall(); - if (!$functionCall) { - throw new RuntimeException( - 'The function_call typed message part must contain a function call.' - ); - } - // Skip function calls in input - they're part of the conversation flow. - return null; - } - if ($type->isFunctionResponse()) { - $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 [ - 'type' => 'function_call_output', - 'call_id' => $functionResponse->getId(), - 'output' => json_encode($functionResponse->getResponse()), - ]; + // Function calls and responses are handled at the message level in getMessageInputItem(). + // They should not appear as parts mixed with other content types. + if ($type->isFunctionCall() || $type->isFunctionResponse()) { + throw new InvalidArgumentException( + 'Function calls and responses must be in their own message, not mixed with other content.' + ); } throw new InvalidArgumentException( sprintf( diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index cfb239d8..635712e9 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; @@ -19,6 +20,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -195,7 +197,7 @@ public function testPrepareGenerateTextParamsBasicText(): void $this->assertEquals('gpt-4o', $params['model']); $this->assertArrayHasKey('input', $params); $this->assertCount(1, $params['input']); - $this->assertEquals('message', $params['input'][0]['type']); + $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']); @@ -435,23 +437,66 @@ public function testGetMessagePartDataWithInlineImage(): void } /** - * Tests getMessagePartData() with function response. + * Tests getMessageInputItem() with function response message. * * @return void */ - public function testGetMessagePartDataWithFunctionResponse(): 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->exposeGetMessagePartData($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 getMessageInputItem() throws exception for mixed function call message. + * + * @return void + */ + public function testGetMessageInputItemThrowsForMixedFunctionCallMessage(): void + { + $model = $this->createModel(); + $functionCall = new FunctionCall('call_456', 'search', ['query' => 'test']); + $message = new Message(MessageRoleEnum::model(), [ + new MessagePart('Some text'), + new MessagePart($functionCall), + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A function call message must contain only one part.'); + + $model->exposeGetMessageInputItem($message); + } + /** * Tests prepareToolsParam() with all tool types. * From 037e8734c5144a96b1a1c38de7050eadf8a34584 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Mon, 12 Jan 2026 17:38:19 -0700 Subject: [PATCH 12/22] refactor: cleans up function call/response handling --- src/Messages/DTO/Message.php | 16 +++- .../OpenAi/OpenAiTextGenerationModel.php | 93 +++++++------------ tests/unit/Builders/MessageBuilderTest.php | 9 +- tests/unit/Builders/PromptBuilderTest.php | 7 +- tests/unit/Messages/DTO/MessageTest.php | 51 +++++++--- .../OpenAi/MockOpenAiTextGenerationModel.php | 4 +- .../OpenAi/OpenAiTextGenerationModelTest.php | 21 ----- 7 files changed, 94 insertions(+), 107 deletions(-) diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 6f162eb4..073bd37a 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -106,18 +106,30 @@ public function withPart(MessagePart $part): Message */ private function validateParts(): void { + $partCount = count($this->parts); + 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.' ); } + + // Function responses must be the only part in a message. + // (Function calls from model can be combined with text.) + if ($type->isFunctionResponse() && $partCount > 1) { + throw new InvalidArgumentException( + 'Function response parts must be the only part in a message.' + ); + } } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 2b33b4c7..c66f7fe3 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -219,7 +219,6 @@ protected function prepareInputParam(array $messages): array * * @param Message $message The message to convert. * @return array|null The input item, or null if the message is empty. - * @throws InvalidArgumentException If a function call/response message contains multiple parts. */ protected function getMessageInputItem(Message $message): ?array { @@ -231,58 +230,16 @@ protected function getMessageInputItem(Message $message): ?array $content = []; foreach ($parts as $part) { - $type = $part->getType(); - - // Function call message → return as function_call item. - if ($type->isFunctionCall()) { - if (count($parts) > 1) { - throw new InvalidArgumentException( - 'A function call message must contain only one part.' - ); - } - $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()), - ]; - } - - // Function response message → return as function_call_output item. - if ($type->isFunctionResponse()) { - if (count($parts) > 1) { - throw new InvalidArgumentException( - 'A function response message must contain only one part.' - ); - } - $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()), - ]; - } - - // Regular content part. $partData = $this->getMessagePartData($part); - if ($partData !== null) { - $content[] = $partData; + + // Function calls and responses are top-level items, not wrapped in a message. + // Message::validateParts() ensures these are the only part in a message. + $partType = $partData['type'] ?? ''; + if ($partType === 'function_call' || $partType === 'function_call_output') { + return $partData; } - } - if (empty($content)) { - return null; + $content[] = $partData; } return [ @@ -313,10 +270,10 @@ protected function getMessageRoleString(MessageRoleEnum $role): string * @since n.e.x.t * * @param MessagePart $part The message part to get the data for. - * @return ?array The data for the message part, or null if not applicable. + * @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 + protected function getMessagePartData(MessagePart $part): array { $type = $part->getType(); if ($type->isText()) { @@ -374,12 +331,32 @@ protected function getMessagePartData(MessagePart $part): ?array 'file_data' => $dataUri, ]; } - // Function calls and responses are handled at the message level in getMessageInputItem(). - // They should not appear as parts mixed with other content types. - if ($type->isFunctionCall() || $type->isFunctionResponse()) { - throw new InvalidArgumentException( - 'Function calls and responses must be in their own message, not mixed with other content.' - ); + 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( 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 baba23d6..172b483a 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -2514,14 +2514,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')]) @@ -2543,13 +2541,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..65d14af7 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -103,35 +104,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.'), @@ -145,6 +139,33 @@ public function testComplexMessageWithAllPartTypes(): void $this->assertInstanceOf(FunctionCall::class, $modelMessage->getParts()[1]->getFunctionCall()); } + /** + * Tests that function response must be the only part in a message. + * + * @return void + */ + public function testFunctionResponseMustBeSinglePart(): void + { + // Valid: function response as the only part + $validMessage = new Message( + MessageRoleEnum::user(), + [new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data']))] + ); + $this->assertCount(1, $validMessage->getParts()); + + // Invalid: function response mixed with other content + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Function response parts must be the only part in a message.'); + + new Message( + MessageRoleEnum::user(), + [ + new MessagePart('Some text'), + new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data'])), + ] + ); + } + /** * Tests JSON schema. * diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php index f64cf119..16c1ca8b 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -145,9 +145,9 @@ public function exposeGetMessageRoleString(MessageRoleEnum $role): string * Exposes getMessagePartData for testing. * * @param MessagePart $part - * @return array|null + * @return array */ - public function exposeGetMessagePartData(MessagePart $part): ?array + public function exposeGetMessagePartData(MessagePart $part): array { return $this->getMessagePartData($part); } diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 635712e9..a691e733 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; @@ -477,26 +476,6 @@ public function testGetMessageInputItemWithFunctionCall(): void $this->assertEquals('{"query":"test"}', $data['arguments']); } - /** - * Tests getMessageInputItem() throws exception for mixed function call message. - * - * @return void - */ - public function testGetMessageInputItemThrowsForMixedFunctionCallMessage(): void - { - $model = $this->createModel(); - $functionCall = new FunctionCall('call_456', 'search', ['query' => 'test']); - $message = new Message(MessageRoleEnum::model(), [ - new MessagePart('Some text'), - new MessagePart($functionCall), - ]); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('A function call message must contain only one part.'); - - $model->exposeGetMessageInputItem($message); - } - /** * Tests prepareToolsParam() with all tool types. * From 97a5d6a1cb6a9a90c37eb824e1aacee5baf979ba Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 13 Jan 2026 19:49:04 -0700 Subject: [PATCH 13/22] refactor: uses parent method --- .../OpenAi/OpenAiImageGenerationModel.php | 64 ++----------------- ...ctOpenAiCompatibleImageGenerationModel.php | 17 ++++- 2 files changed, 21 insertions(+), 60 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index b6c8b447..03442e0b 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -6,12 +6,8 @@ use WordPress\AiClient\Files\Enums\MediaOrientationEnum; 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\Http\Exception\ResponseException; use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; -use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Results\DTO\TokenUsage; /** * Class for an OpenAI image generation model using the Images API. @@ -20,11 +16,6 @@ * (gpt-image-1, etc.) and DALL-E models (dall-e-2, dall-e-3). * * @since n.e.x.t - * - * @phpstan-type ImageResponseData array{ - * created?: int, - * data?: list - * } */ class OpenAiImageGenerationModel extends AbstractOpenAiCompatibleImageGenerationModel { @@ -87,59 +78,14 @@ protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string /** * {@inheritDoc} * - * Overrides the parent to handle OpenAI's `created` timestamp instead of `id`. - * * @since n.e.x.t */ - protected function parseResponseToGenerativeAiResult( - Response $response, - string $expectedMimeType = 'image/png' - ): GenerativeAiResult { - /** @var ImageResponseData $responseData */ - $responseData = $response->getData(); - - if (!isset($responseData['data']) || !$responseData['data']) { - throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data'); - } - if (!is_array($responseData['data']) || !array_is_list($responseData['data'])) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - 'data', - 'The value must be an indexed array.' - ); - } - - $candidates = []; - foreach ($responseData['data'] as $index => $choiceData) { - if (!is_array($choiceData) || array_is_list($choiceData)) { - throw ResponseException::fromInvalidData( - $this->providerMetadata()->getName(), - "data[{$index}]", - 'The value must be an associative array.' - ); - } - - $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType); - } - + protected function getResultId(array $responseData): string + { // The Images API returns `created` timestamp instead of `id`. - $id = isset($responseData['created']) ? 'img-' . $responseData['created'] : ''; - - // The Images API doesn't return token usage. - $tokenUsage = new TokenUsage(0, 0, 0); - - // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset($additionalData['data'], $additionalData['created']); - - return new GenerativeAiResult( - $id, - $candidates, - $tokenUsage, - $this->providerMetadata(), - $this->metadata(), - $additionalData - ); + return isset($responseData['created']) && is_int($responseData['created']) + ? 'img-' . $responseData['created'] + : ''; } /** diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index e8d926a3..b7f95daa 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -305,7 +305,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 +367,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'] + : ''; + } } From dfb63a7f2f3da2d72a64d93921f8271ace5b665b Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 13 Jan 2026 19:56:41 -0700 Subject: [PATCH 14/22] chore: corrects `@since` tags --- .../OpenAi/OpenAiImageGenerationModel.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 03442e0b..1c0db859 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -15,14 +15,14 @@ * 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 n.e.x.t + * @since 0.1.0 */ class OpenAiImageGenerationModel extends AbstractOpenAiCompatibleImageGenerationModel { /** * {@inheritDoc} * - * @since n.e.x.t + * @since 0.1.0 */ protected function createRequest( HttpMethodEnum $method, @@ -42,7 +42,7 @@ protected function createRequest( /** * {@inheritDoc} * - * @since n.e.x.t + * @since 0.1.0 */ protected function prepareGenerateImageParams(array $prompt): array { From da89e4dd6ab89be87f9d5efa51fa002ca6ed9133 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 14 Jan 2026 15:37:10 -0700 Subject: [PATCH 15/22] fix: adds unsetting output format back for DALL-E models --- .../OpenAi/OpenAiImageGenerationModel.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 1c0db859..c5cf0a1c 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -47,13 +47,15 @@ protected function createRequest( protected function prepareGenerateImageParams(array $prompt): array { $params = parent::prepareGenerateImageParams($prompt); - $modelId = $this->metadata()->getId(); - // GPT image models use output_format, DALL-E uses response_format. - if ($this->isGptImageModel($modelId)) { - // For GPT image models, convert response_format to the appropriate format. - // The parent sets response_format, but GPT models don't need it. + /* + * 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 ($this->isGptImageModel($this->metadata()->getId())) { unset($params['response_format']); + } else { + unset($params['output_format']); } return $params; From 603b98b42630da4ecd3eeb28ac3308557fa139eb Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 14 Jan 2026 15:42:40 -0700 Subject: [PATCH 16/22] refactor: removes code interpretation for now --- .../OpenAi/OpenAiTextGenerationModel.php | 24 ++------------- .../OpenAi/MockOpenAiTextGenerationModel.php | 6 ++-- .../OpenAi/OpenAiTextGenerationModelTest.php | 29 +++---------------- 3 files changed, 9 insertions(+), 50 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index c66f7fe3..3a8f7a82 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -157,27 +157,18 @@ protected function prepareGenerateTextParams(array $prompt): array $webSearch = $config->getWebSearch(); $customOptions = $config->getCustomOptions(); - // Check for built-in tools via customOptions. - $codeInterpreter = !empty($customOptions['codeInterpreter']); - - if (is_array($functionDeclarations) || $webSearch || $codeInterpreter) { + if (is_array($functionDeclarations) || $webSearch) { $params['tools'] = $this->prepareToolsParam( $functionDeclarations, - $webSearch, - $codeInterpreter + $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. - * Skip options we've already processed explicitly. */ - $processedCustomOptions = ['codeInterpreter']; foreach ($customOptions as $key => $value) { - if (in_array($key, $processedCustomOptions, true)) { - continue; - } if (isset($params[$key])) { throw new InvalidArgumentException( sprintf( @@ -373,13 +364,11 @@ protected function getMessagePartData(MessagePart $part): array * * @param list|null $functionDeclarations The function declarations, or null if none. * @param WebSearch|null $webSearch The web search config, or null if none. - * @param bool $codeInterpreter Whether to include the code interpreter tool. * @return list> The prepared tools parameter. */ protected function prepareToolsParam( ?array $functionDeclarations, - ?WebSearch $webSearch, - bool $codeInterpreter = false + ?WebSearch $webSearch ): array { $tools = []; @@ -401,13 +390,6 @@ protected function prepareToolsParam( $tools[] = $webSearchTool; } - if ($codeInterpreter) { - $tools[] = [ - 'type' => 'code_interpreter', - 'container' => ['type' => 'auto'], - ]; - } - return $tools; } diff --git a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php index 16c1ca8b..e4dfd563 100644 --- a/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php +++ b/tests/unit/ProviderImplementations/OpenAi/MockOpenAiTextGenerationModel.php @@ -157,15 +157,13 @@ public function exposeGetMessagePartData(MessagePart $part): array * * @param list|null $functionDeclarations * @param WebSearch|null $webSearch - * @param bool $codeInterpreter * @return list> */ public function exposePrepareToolsParam( ?array $functionDeclarations, - ?WebSearch $webSearch, - bool $codeInterpreter = false + ?WebSearch $webSearch ): array { - return $this->prepareToolsParam($functionDeclarations, $webSearch, $codeInterpreter); + return $this->prepareToolsParam($functionDeclarations, $webSearch); } /** diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index a691e733..d9e30d02 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -306,25 +306,6 @@ public function testPrepareGenerateTextParamsWithWebSearch(): void $this->assertEquals('web_search', $params['tools'][0]['type']); } - /** - * Tests prepareGenerateTextParams() with code interpreter via customOptions. - * - * @return void - */ - public function testPrepareGenerateTextParamsWithCodeInterpreter(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Run some code')])]; - $config = new ModelConfig(); - $config->setCustomOptions(['codeInterpreter' => true]); - $model = $this->createModel($config); - - $params = $model->exposePrepareGenerateTextParams($prompt); - - $this->assertArrayHasKey('tools', $params); - $toolTypes = array_column($params['tools'], 'type'); - $this->assertContains('code_interpreter', $toolTypes); - } - /** * Tests prepareGenerateTextParams() with previous_response_id for conversation state. * @@ -477,11 +458,11 @@ public function testGetMessageInputItemWithFunctionCall(): void } /** - * Tests prepareToolsParam() with all tool types. + * Tests prepareToolsParam() with function declarations and web search. * * @return void */ - public function testPrepareToolsParamWithAllTools(): void + public function testPrepareToolsParamWithFunctionAndWebSearch(): void { $model = $this->createModel(); $functionDeclaration = new FunctionDeclaration( @@ -493,15 +474,13 @@ public function testPrepareToolsParamWithAllTools(): void $tools = $model->exposePrepareToolsParam( [$functionDeclaration], - $webSearch, - true + $webSearch ); - $this->assertCount(3, $tools); + $this->assertCount(2, $tools); $toolTypes = array_column($tools, 'type'); $this->assertContains('function', $toolTypes); $this->assertContains('web_search', $toolTypes); - $this->assertContains('code_interpreter', $toolTypes); } /** From 8a563be26c492abc27f7dd4d8c083b41ef1b1b9c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 14 Jan 2026 15:57:03 -0700 Subject: [PATCH 17/22] refactor: moves function call/response part validation to OpenAI --- src/Messages/DTO/Message.php | 10 ---- .../OpenAi/OpenAiTextGenerationModel.php | 44 ++++++++++++++++- tests/unit/Messages/DTO/MessageTest.php | 27 ---------- .../OpenAi/OpenAiTextGenerationModelTest.php | 49 +++++++++++++++++++ 4 files changed, 92 insertions(+), 38 deletions(-) diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 073bd37a..d5a7f5f1 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -106,8 +106,6 @@ public function withPart(MessagePart $part): Message */ private function validateParts(): void { - $partCount = count($this->parts); - foreach ($this->parts as $part) { $type = $part->getType(); @@ -122,14 +120,6 @@ private function validateParts(): void 'Model messages cannot contain function responses.' ); } - - // Function responses must be the only part in a message. - // (Function calls from model can be combined with text.) - if ($type->isFunctionResponse() && $partCount > 1) { - throw new InvalidArgumentException( - 'Function response parts must be the only part in a message.' - ); - } } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 3a8f7a82..57fca353 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -193,6 +193,8 @@ protected function prepareGenerateTextParams(array $prompt): array */ protected function prepareInputParam(array $messages): array { + $this->validateMessages($messages); + $input = []; foreach ($messages as $message) { $inputItem = $this->getMessageInputItem($message); @@ -203,6 +205,46 @@ protected function prepareInputParam(array $messages): array 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. * @@ -224,7 +266,7 @@ protected function getMessageInputItem(Message $message): ?array $partData = $this->getMessagePartData($part); // Function calls and responses are top-level items, not wrapped in a message. - // Message::validateParts() ensures these are the only part 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; diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index 65d14af7..b792d682 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -139,33 +139,6 @@ public function testComplexMessageWithMultipleContentTypes(): void $this->assertInstanceOf(FunctionCall::class, $modelMessage->getParts()[1]->getFunctionCall()); } - /** - * Tests that function response must be the only part in a message. - * - * @return void - */ - public function testFunctionResponseMustBeSinglePart(): void - { - // Valid: function response as the only part - $validMessage = new Message( - MessageRoleEnum::user(), - [new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data']))] - ); - $this->assertCount(1, $validMessage->getParts()); - - // Invalid: function response mixed with other content - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Function response parts must be the only part in a message.'); - - new Message( - MessageRoleEnum::user(), - [ - new MessagePart('Some text'), - new MessagePart(new FunctionResponse('func_123', 'search', ['result' => 'data'])), - ] - ); - } - /** * Tests JSON schema. * diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index d9e30d02..7bb0adf5 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\ProviderImplementations\OpenAi; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; @@ -457,6 +458,54 @@ public function testGetMessageInputItemWithFunctionCall(): void $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. * From 725b8682378f7a6b9b7d6c9b6235827a59a96caa Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 14 Jan 2026 16:05:40 -0700 Subject: [PATCH 18/22] test: fixes linting --- tests/unit/Messages/DTO/MessageTest.php | 1 - .../OpenAi/OpenAiTextGenerationModelTest.php | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index b792d682..83edfe86 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; -use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 7bb0adf5..7c16fe8b 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -477,7 +477,9 @@ public function testValidateMessagesRejectsFunctionResponseMixedWithText(): void ]; $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Function response parts must be the only part in a message for the OpenAI Responses API.'); + $this->expectExceptionMessage( + 'Function response parts must be the only part in a message for the OpenAI Responses API.' + ); $model->exposePrepareInputParam($messages); } @@ -501,7 +503,9 @@ public function testValidateMessagesRejectsFunctionCallMixedWithText(): void ]; $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Function call parts must be the only part in a message for the OpenAI Responses API.'); + $this->expectExceptionMessage( + 'Function call parts must be the only part in a message for the OpenAI Responses API.' + ); $model->exposePrepareInputParam($messages); } From 25fbc4e539e66388a36735c96993dc72f5629070 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 16 Jan 2026 10:24:53 -0700 Subject: [PATCH 19/22] refactor: uses $params instead of bypassing data Co-authored-by: Felix Arntz --- .../OpenAi/OpenAiImageGenerationModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index c5cf0a1c..6ab3b13d 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -52,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 ($this->isGptImageModel($this->metadata()->getId())) { + if ($this->isGptImageModel($params['model'])) { unset($params['response_format']); } else { unset($params['output_format']); From 18aa79ba86f703857d8f02c4797af56cb1995b41 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 16 Jan 2026 10:30:07 -0700 Subject: [PATCH 20/22] refactor: improves image generation parms typing --- .../AbstractOpenAiCompatibleImageGenerationModel.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index b7f95daa..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; } From d7b76298c71d68adad9f78e7e7ef46a6d76a2e2f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 16 Jan 2026 10:58:44 -0700 Subject: [PATCH 21/22] refactor: removes stream method as part of broader removal --- .../OpenAi/OpenAiTextGenerationModel.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php index 57fca353..e714acae 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextGenerationModel.php @@ -4,7 +4,6 @@ namespace WordPress\AiClient\ProviderImplementations\OpenAi; -use Generator; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Messages\DTO\Message; @@ -87,19 +86,6 @@ final public function generateTextResult(array $prompt): GenerativeAiResult return $this->parseResponseToGenerativeAiResult($response); } - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - final public function streamGenerateTextResult(array $prompt): Generator - { - // TODO: Implement streaming support. - throw new RuntimeException( - 'Streaming is not yet implemented for OpenAI Responses API.' - ); - } - /** * Prepares the given prompt and the model configuration into parameters for the API request. * From 70a097d96699965a4fe1ffeba5da7567189134f8 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 16 Jan 2026 11:00:24 -0700 Subject: [PATCH 22/22] test: removes streaming test --- .../OpenAi/OpenAiTextGenerationModelTest.php | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php index 7c16fe8b..12a679e3 100644 --- a/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php +++ b/tests/unit/ProviderImplementations/OpenAi/OpenAiTextGenerationModelTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Common\Exception\InvalidArgumentException; -use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -164,23 +163,6 @@ public function testGenerateTextResultApiFailure(): void $model->generateTextResult($prompt); } - /** - * Tests streamGenerateTextResult() method throws RuntimeException. - * - * @return void - */ - public function testStreamGenerateTextResultThrowsException(): void - { - $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; - $model = $this->createModel(); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Streaming is not yet implemented for OpenAI Responses API.'); - - $generator = $model->streamGenerateTextResult($prompt); - $generator->current(); - } - /** * Tests prepareGenerateTextParams() with basic text prompt. *