diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php new file mode 100644 index 00000000..83721788 --- /dev/null +++ b/src/Builders/PromptBuilder.php @@ -0,0 +1,1171 @@ +|list|null + */ +class PromptBuilder +{ + /** + * @var ProviderRegistry The provider registry for finding suitable models. + */ + private ProviderRegistry $registry; + + /** + * @var list The messages in the conversation. + */ + protected array $messages = []; + + /** + * @var ModelInterface|null The model to use for generation. + */ + protected ?ModelInterface $model = null; + + /** + * @var ModelConfig The model configuration. + */ + protected ModelConfig $modelConfig; + + // phpcs:disable Generic.Files.LineLength.TooLong + /** + * Constructor. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param Prompt $prompt + * Optional initial prompt content. + */ + // phpcs:enable Generic.Files.LineLength.TooLong + public function __construct(ProviderRegistry $registry, $prompt = null) + { + $this->registry = $registry; + $this->modelConfig = new ModelConfig(); + + if ($prompt === null) { + return; + } + + // Check if it's a list of Messages - set as messages + if ($this->isMessagesList($prompt)) { + $this->messages = $prompt; + return; + } + + // Parse it as a user message + $userMessage = $this->parseMessage($prompt, MessageRoleEnum::user()); + $this->messages[] = $userMessage; + } + + /** + * Adds text to the current message. + * + * @since n.e.x.t + * + * @param string $text The text to add. + * @return self + */ + public function withText(string $text): self + { + $part = new MessagePart($text); + $this->appendPartToMessages($part); + return $this; + } + + /** + * Adds a file to the current message. + * + * Accepts: + * - File object + * - URL string (remote file) + * - Base64-encoded data string + * - Data URI string (data:mime/type;base64,data) + * - Local file path string + * + * @since n.e.x.t + * + * @param string|File $file The file (File object or string representation). + * @param string|null $mimeType The MIME type (optional, ignored if File object provided). + * @return self + * @throws InvalidArgumentException If the file is invalid or MIME type cannot be determined. + */ + public function withFile($file, ?string $mimeType = null): self + { + $file = $file instanceof File ? $file : new File($file, $mimeType); + $part = new MessagePart($file); + $this->appendPartToMessages($part); + return $this; + } + + + /** + * Adds a function response to the current message. + * + * @since n.e.x.t + * + * @param FunctionResponse $functionResponse The function response. + * @return self + */ + public function withFunctionResponse(FunctionResponse $functionResponse): self + { + $part = new MessagePart($functionResponse); + $this->appendPartToMessages($part); + return $this; + } + + /** + * Adds message parts to the current message. + * + * @since n.e.x.t + * + * @param MessagePart ...$parts The message parts to add. + * @return self + */ + public function withMessageParts(MessagePart ...$parts): self + { + foreach ($parts as $part) { + $this->appendPartToMessages($part); + } + return $this; + } + + /** + * Adds conversation history messages. + * + * Historical messages are prepended to the beginning of the message list, + * before the current message being built. + * + * @since n.e.x.t + * + * @param Message ...$messages The messages to add to history. + * @return self + */ + public function withHistory(Message ...$messages): self + { + // Prepend the history messages to the beginning of the messages array + $this->messages = array_merge($messages, $this->messages); + + return $this; + } + + /** + * Sets the model to use for generation. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + return $this; + } + + /** + * Sets the system instruction. + * + * System instructions are stored in the model configuration and guide + * the AI model's behavior throughout the conversation. + * + * @since n.e.x.t + * + * @param string $systemInstruction The system instruction text. + * @return self + */ + public function usingSystemInstruction(string $systemInstruction): self + { + $this->modelConfig->setSystemInstruction($systemInstruction); + return $this; + } + + /** + * Sets the maximum number of tokens to generate. + * + * @since n.e.x.t + * + * @param int $maxTokens The maximum number of tokens. + * @return self + */ + public function usingMaxTokens(int $maxTokens): self + { + $this->modelConfig->setMaxTokens($maxTokens); + return $this; + } + + /** + * Sets the temperature for generation. + * + * @since n.e.x.t + * + * @param float $temperature The temperature value. + * @return self + */ + public function usingTemperature(float $temperature): self + { + $this->modelConfig->setTemperature($temperature); + return $this; + } + + /** + * Sets the top-p value for generation. + * + * @since n.e.x.t + * + * @param float $topP The top-p value. + * @return self + */ + public function usingTopP(float $topP): self + { + $this->modelConfig->setTopP($topP); + return $this; + } + + /** + * Sets the top-k value for generation. + * + * @since n.e.x.t + * + * @param int $topK The top-k value. + * @return self + */ + public function usingTopK(int $topK): self + { + $this->modelConfig->setTopK($topK); + return $this; + } + + /** + * Sets stop sequences for generation. + * + * @since n.e.x.t + * + * @param string ...$stopSequences The stop sequences. + * @return self + */ + public function usingStopSequences(string ...$stopSequences): self + { + $this->modelConfig->setCustomOption('stopSequences', $stopSequences); + return $this; + } + + /** + * Sets the number of candidates to generate. + * + * @since n.e.x.t + * + * @param int $candidateCount The number of candidates. + * @return self + */ + public function usingCandidateCount(int $candidateCount): self + { + $this->modelConfig->setCandidateCount($candidateCount); + return $this; + } + + /** + * Sets the output MIME type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type. + * @return self + */ + public function asOutputMimeType(string $mimeType): self + { + $this->modelConfig->setOutputMimeType($mimeType); + return $this; + } + + /** + * Sets the output schema. + * + * @since n.e.x.t + * + * @param array $schema The output schema. + * @return self + */ + public function asOutputSchema(array $schema): self + { + $this->modelConfig->setOutputSchema($schema); + return $this; + } + + /** + * Sets the output modalities. + * + * @since n.e.x.t + * + * @param ModalityEnum ...$modalities The output modalities. + * @return self + */ + public function asOutputModalities(ModalityEnum ...$modalities): self + { + $this->modelConfig->setOutputModalities($modalities); + return $this; + } + + /** + * Configures the prompt for JSON response output. + * + * @since n.e.x.t + * + * @param array|null $schema Optional JSON schema. + * @return self + */ + public function asJsonResponse(?array $schema = null): self + { + $this->asOutputMimeType('application/json'); + if ($schema !== null) { + $this->asOutputSchema($schema); + } + return $this; + } + + /** + * Gets the inferred model requirements based on prompt features. + * + * @since n.e.x.t + * + * @param CapabilityEnum $capability The capability the model must support. + * @return ModelRequirements The inferred requirements. + */ + private function getModelRequirements(CapabilityEnum $capability): ModelRequirements + { + $capabilities = [$capability]; + $inputModalities = []; + + // Check if we have chat history (multiple messages) + if (count($this->messages) > 1) { + $capabilities[] = CapabilityEnum::chatHistory(); + } + + // Analyze all messages to determine required input modalities + $hasFunctionMessageParts = false; + foreach ($this->messages as $message) { + foreach ($message->getParts() as $part) { + // Check for text input + if ($part->getType()->isText()) { + $inputModalities[] = ModalityEnum::text(); + } + + // Check for file inputs + if ($part->getType()->isFile()) { + $file = $part->getFile(); + + if ($file !== null) { + if ($file->isImage()) { + $inputModalities[] = ModalityEnum::image(); + } elseif ($file->isAudio()) { + $inputModalities[] = ModalityEnum::audio(); + } elseif ($file->isVideo()) { + $inputModalities[] = ModalityEnum::video(); + } elseif ($file->isDocument() || $file->isText()) { + $inputModalities[] = ModalityEnum::document(); + } + } + } + + // Check for function calls/responses (these might require special capabilities) + if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) { + $hasFunctionMessageParts = true; + } + } + } + + // Build required options from ModelConfig + $requiredOptions = $this->modelConfig->toRequiredOptions(); + + if ($hasFunctionMessageParts) { + // Add function declarations option if we have function calls/responses + $requiredOptions = $this->includeInRequiredOptions( + $requiredOptions, + new RequiredOption(OptionEnum::functionDeclarations(), true) + ); + } + + // Add input modalities if we have any inputs + $requiredOptions = $this->includeInRequiredOptions( + $requiredOptions, + new RequiredOption(OptionEnum::inputModalities(), $inputModalities) + ); + + return new ModelRequirements( + $capabilities, + $requiredOptions + ); + } + + /** + * Infers the capability from configured output modalities. + * + * @since n.e.x.t + * + * @return CapabilityEnum The inferred capability. + * @throws RuntimeException If the output modality is not supported. + */ + private function inferCapabilityFromOutputModalities(): CapabilityEnum + { + // Get the configured output modalities + $outputModalities = $this->modelConfig->getOutputModalities(); + + // Default to text if no output modality is specified + if ($outputModalities === null || empty($outputModalities)) { + return CapabilityEnum::textGeneration(); + } + + // Multi-modal output (multiple modalities) defaults to text generation. This is temporary + // as a multi-modal interface will be implemented in the future. + if (count($outputModalities) > 1) { + return CapabilityEnum::textGeneration(); + } + + // Infer capability from single output modality + $outputModality = $outputModalities[0]; + + if ($outputModality->isText()) { + return CapabilityEnum::textGeneration(); + } elseif ($outputModality->isImage()) { + return CapabilityEnum::imageGeneration(); + } elseif ($outputModality->isAudio()) { + return CapabilityEnum::speechGeneration(); + } elseif ($outputModality->isVideo()) { + return CapabilityEnum::videoGeneration(); + } else { + // For unsupported modalities, provide a clear error message + throw new RuntimeException( + sprintf('Output modality "%s" is not yet supported.', $outputModality->value) + ); + } + } + + /** + * Checks if the current prompt is supported by the selected model. + * + * @since n.e.x.t + * + * @param CapabilityEnum|null $intendedCapability Optional capability to check support for. + * @return bool True if supported, false otherwise. + */ + private function isSupported(?CapabilityEnum $intendedCapability = null): bool + { + // If no intended capability provided, infer from output modalities + if ($intendedCapability === null) { + $intendedCapability = $this->inferCapabilityFromOutputModalities(); + } + + // Build requirements with the specified capability + $requirements = $this->getModelRequirements($intendedCapability); + + // If the model has been set, check if it meets the requirements + if ($this->model !== null) { + return $this->model->metadata()->meetsRequirements($requirements); + } + + try { + // Check if any models support these requirements + $models = $this->registry->findModelsMetadataForSupport($requirements); + return !empty($models); + } catch (InvalidArgumentException $e) { + // No models support the requirements + return false; + } + } + + /** + * Checks if the prompt is supported for text generation. + * + * @since n.e.x.t + * + * @return bool True if text generation is supported. + */ + public function isSupportedForTextGeneration(): bool + { + return $this->isSupported(CapabilityEnum::textGeneration()); + } + + /** + * Checks if the prompt is supported for image generation. + * + * @since n.e.x.t + * + * @return bool True if image generation is supported. + */ + public function isSupportedForImageGeneration(): bool + { + return $this->isSupported(CapabilityEnum::imageGeneration()); + } + + /** + * Checks if the prompt is supported for text to speech conversion. + * + * @since n.e.x.t + * + * @return bool True if text to speech conversion is supported. + */ + public function isSupportedForTextToSpeechConversion(): bool + { + return $this->isSupported(CapabilityEnum::textToSpeechConversion()); + } + + /** + * Checks if the prompt is supported for video generation. + * + * @since n.e.x.t + * + * @return bool True if video generation is supported. + */ + public function isSupportedForVideoGeneration(): bool + { + return $this->isSupported(CapabilityEnum::videoGeneration()); + } + + /** + * Checks if the prompt is supported for speech generation. + * + * @since n.e.x.t + * + * @return bool True if speech generation is supported. + */ + public function isSupportedForSpeechGeneration(): bool + { + return $this->isSupported(CapabilityEnum::speechGeneration()); + } + + /** + * Checks if the prompt is supported for music generation. + * + * @since n.e.x.t + * + * @return bool True if music generation is supported. + */ + public function isSupportedForMusicGeneration(): bool + { + return $this->isSupported(CapabilityEnum::musicGeneration()); + } + + /** + * Checks if the prompt is supported for embedding generation. + * + * @since n.e.x.t + * + * @return bool True if embedding generation is supported. + */ + public function isSupportedForEmbeddingGeneration(): bool + { + return $this->isSupported(CapabilityEnum::embeddingGeneration()); + } + + /** + * Generates a result from the prompt. + * + * This is the primary execution method that generates a result (containing + * potentially multiple candidates) based on the specified capability or + * the configured output modality. + * + * @since n.e.x.t + * + * @param CapabilityEnum|null $capability Optional capability to use for generation. + * If null, capability is inferred from output modality. + * @return GenerativeAiResult The generated result containing candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support the required capability. + */ + public function generateResult(?CapabilityEnum $capability = null): GenerativeAiResult + { + $this->validateMessages(); + + // If capability is not provided, infer it from output modalities + if ($capability === null) { + $capability = $this->inferCapabilityFromOutputModalities(); + } + + $model = $this->getConfiguredModel($capability); + + // Route to the appropriate generation method based on capability + if ($capability->isTextGeneration()) { + if (!$model instanceof TextGenerationModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support text generation.', + $model->metadata()->getId() + ) + ); + } + return $model->generateTextResult($this->messages); + } + + if ($capability->isImageGeneration()) { + if (!$model instanceof ImageGenerationModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support image generation.', + $model->metadata()->getId() + ) + ); + } + return $model->generateImageResult($this->messages); + } + + if ($capability->isTextToSpeechConversion()) { + if (!$model instanceof TextToSpeechConversionModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support text-to-speech conversion.', + $model->metadata()->getId() + ) + ); + } + return $model->convertTextToSpeechResult($this->messages); + } + + if ($capability->isSpeechGeneration()) { + if (!$model instanceof SpeechGenerationModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support speech generation.', + $model->metadata()->getId() + ) + ); + } + return $model->generateSpeechResult($this->messages); + } + + if ($capability->isVideoGeneration()) { + // Video generation is not yet implemented + throw new RuntimeException('Output modality "video" is not yet supported.'); + } + + // TODO: Add support for other capabilities when interfaces are available + throw new RuntimeException( + sprintf('Capability "%s" is not yet supported for generation.', $capability->value) + ); + } + + /** + * Generates a text result from the prompt. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The generated result containing text candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support text generation. + */ + public function generateTextResult(): GenerativeAiResult + { + // Include text in output modalities + $this->includeOutputModalities(ModalityEnum::text()); + + // Generate and return the result with text generation capability + return $this->generateResult(CapabilityEnum::textGeneration()); + } + + /** + * Generates an image result from the prompt. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The generated result containing image candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support image generation. + */ + public function generateImageResult(): GenerativeAiResult + { + // Include image in output modalities + $this->includeOutputModalities(ModalityEnum::image()); + + // Generate and return the result with image generation capability + return $this->generateResult(CapabilityEnum::imageGeneration()); + } + + /** + * Generates a speech result from the prompt. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The generated result containing speech audio candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support speech generation. + */ + public function generateSpeechResult(): GenerativeAiResult + { + // Include audio in output modalities + $this->includeOutputModalities(ModalityEnum::audio()); + + // Generate and return the result with speech generation capability + return $this->generateResult(CapabilityEnum::speechGeneration()); + } + + /** + * Converts text to speech and returns the result. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The generated result containing speech audio candidates. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model doesn't support text-to-speech conversion. + */ + public function convertTextToSpeechResult(): GenerativeAiResult + { + // Include audio in output modalities + $this->includeOutputModalities(ModalityEnum::audio()); + + // Generate and return the result with text-to-speech conversion capability + return $this->generateResult(CapabilityEnum::textToSpeechConversion()); + } + + /** + * Generates text from the prompt. + * + * @since n.e.x.t + * + * @return string The generated text. + * @throws InvalidArgumentException If the prompt or model validation fails. + */ + public function generateText(): string + { + return $this->generateTextResult()->toText(); + } + + /** + * Generates multiple text candidates from the prompt. + * + * @since n.e.x.t + * + * @param int|null $candidateCount The number of candidates to generate. + * @return list The generated texts. + * @throws InvalidArgumentException If the prompt or model validation fails. + */ + public function generateTexts(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + + // Generate text result + return $this->generateTextResult()->toTexts(); + } + + /** + * Generates an image from the prompt. + * + * @since n.e.x.t + * + * @return File The generated image file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no image is generated. + */ + public function generateImage(): File + { + return $this->generateImageResult()->toFile(); + } + + /** + * Generates multiple images from the prompt. + * + * @since n.e.x.t + * + * @param int|null $candidateCount The number of images to generate. + * @return list The generated image files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no images are generated. + */ + public function generateImages(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + + return $this->generateImageResult()->toFiles(); + } + + /** + * Converts text to speech. + * + * @since n.e.x.t + * + * @return File The generated speech audio file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function convertTextToSpeech(): File + { + return $this->convertTextToSpeechResult()->toFile(); + } + + /** + * Converts text to multiple speech outputs. + * + * @since n.e.x.t + * + * @param int|null $candidateCount The number of speech outputs to generate. + * @return list The generated speech audio files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function convertTextToSpeeches(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + + return $this->convertTextToSpeechResult()->toFiles(); + } + + /** + * Generates speech from the prompt. + * + * @since n.e.x.t + * + * @return File The generated speech audio file. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function generateSpeech(): File + { + return $this->generateSpeechResult()->toFile(); + } + + /** + * Generates multiple speech outputs from the prompt. + * + * @since n.e.x.t + * + * @param int|null $candidateCount The number of speech outputs to generate. + * @return list The generated speech audio files. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If no audio is generated. + */ + public function generateSpeeches(?int $candidateCount = null): array + { + if ($candidateCount !== null) { + $this->usingCandidateCount($candidateCount); + } + + return $this->generateSpeechResult()->toFiles(); + } + + /** + * Appends a MessagePart to the messages array. + * + * If the last message has a user role, the part is added to it. + * Otherwise, a new UserMessage is created with the part. + * + * @since n.e.x.t + * + * @param MessagePart $part The part to append. + * @return void + */ + protected function appendPartToMessages(MessagePart $part): void + { + $lastMessage = end($this->messages); + + if ($lastMessage instanceof Message && $lastMessage->getRole()->isUser()) { + // Replace the last message with a new one containing the appended part + array_pop($this->messages); + $this->messages[] = $lastMessage->withPart($part); + return; + } + + // Create new UserMessage with the part + $this->messages[] = new UserMessage([$part]); + } + + /** + * Gets the model to use for generation. + * + * If a model has been explicitly set, validates it meets requirements and returns it. + * Otherwise, finds a suitable model based on the prompt requirements. + * + * @since n.e.x.t + * + * @param CapabilityEnum $capability The capability the model will be using. + * @return ModelInterface The model to use. + * @throws InvalidArgumentException If no suitable model is found or set model doesn't meet requirements. + */ + private function getConfiguredModel(CapabilityEnum $capability): ModelInterface + { + $requirements = $this->getModelRequirements($capability); + + // If a model has been explicitly set, return it + if ($this->model !== null) { + $this->model->setConfig($this->modelConfig); + return $this->model; + } + + // Find a suitable model based on requirements + $modelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + + if (empty($modelsMetadata)) { + throw new InvalidArgumentException( + 'No models found that support the required capabilities and options for this prompt. ' . + 'Required capabilities: ' . implode(', ', array_map(function ($cap) { + return $cap->value; + }, $requirements->getRequiredCapabilities())) . + '. Required options: ' . implode(', ', array_map(function ($opt) { + return $opt->getName()->value . '=' . json_encode($opt->getValue()); + }, $requirements->getRequiredOptions())) + ); + } + + // Get the first available model from the first provider + $firstProviderModels = $modelsMetadata[0]; + $firstModelMetadata = $firstProviderModels->getModels()[0]; + + // Get the model instance from the provider + return $this->registry->getProviderModel( + $firstProviderModels->getProvider()->getId(), + $firstModelMetadata->getId(), + $this->modelConfig + ); + } + + /** + * Parses various input types into a Message with the given role. + * + * @since n.e.x.t + * + * @param mixed $input The input to parse. + * @param MessageRoleEnum $defaultRole The role for the message if not specified by input. + * @return Message The parsed message. + * @throws InvalidArgumentException If the input type is not supported or results in empty message. + */ + private function parseMessage($input, MessageRoleEnum $defaultRole): Message + { + // Handle Message input directly + if ($input instanceof Message) { + return $input; + } + + // Handle single MessagePart + if ($input instanceof MessagePart) { + return new Message($defaultRole, [$input]); + } + + // Handle string input + if (is_string($input)) { + if (trim($input) === '') { + throw new InvalidArgumentException('Cannot create a message from an empty string.'); + } + return new Message($defaultRole, [new MessagePart($input)]); + } + + // Handle array input + if (!is_array($input)) { + throw new InvalidArgumentException( + 'Input must be a string, MessagePart, MessagePartArrayShape, ' . + 'a list of string|MessagePart|MessagePartArrayShape, or a Message instance.' + ); + } + + // Handle MessageArrayShape input + if (Message::isArrayShape($input)) { + return Message::fromArray($input); + } + + // Check if it's a MessagePartArrayShape + if (MessagePart::isArrayShape($input)) { + return new Message($defaultRole, [MessagePart::fromArray($input)]); + } + + // It should be a list of string|MessagePart|MessagePartArrayShape + if (!array_is_list($input)) { + throw new InvalidArgumentException('Array input must be a list array.'); + } + + // Empty array check + if (empty($input)) { + throw new InvalidArgumentException('Cannot create a message from an empty array.'); + } + + $parts = []; + foreach ($input as $item) { + if (is_string($item)) { + $parts[] = new MessagePart($item); + } elseif ($item instanceof MessagePart) { + $parts[] = $item; + } elseif (is_array($item) && MessagePart::isArrayShape($item)) { + $parts[] = MessagePart::fromArray($item); + } else { + throw new InvalidArgumentException( + 'Array items must be strings, MessagePart instances, or MessagePartArrayShape.' + ); + } + } + + return new Message($defaultRole, $parts); + } + + /** + * Validates the messages array for prompt generation. + * + * Ensures that: + * - The first message is a user message + * - The last message is a user message + * - The last message has parts + * + * @since n.e.x.t + * + * @return void + * @throws InvalidArgumentException If validation fails. + */ + private function validateMessages(): void + { + if (empty($this->messages)) { + throw new InvalidArgumentException( + 'Cannot generate from an empty prompt. Add content using withText() or similar methods.' + ); + } + + $firstMessage = reset($this->messages); + if (!$firstMessage->getRole()->isUser()) { + throw new InvalidArgumentException( + 'The first message must be from a user role, not from ' . $firstMessage->getRole()->value + ); + } + + $lastMessage = end($this->messages); + if (!$lastMessage->getRole()->isUser()) { + throw new InvalidArgumentException( + 'The last message must be from a user role, not from ' . $lastMessage->getRole()->value + ); + } + + if (empty($lastMessage->getParts())) { + throw new InvalidArgumentException( + 'The last message must have content parts. Add content using withText() or similar methods.' + ); + } + } + + /** + * Checks if the value is a list of Message objects. + * + * @since n.e.x.t + * + * @param mixed $value The value to check. + * @return bool True if the value is a list of Message objects. + * + * @phpstan-assert-if-true list $value + */ + private function isMessagesList($value): bool + { + if (!is_array($value) || empty($value) || !array_is_list($value)) { + return false; + } + + // Check if all items are Messages + foreach ($value as $item) { + if (!($item instanceof Message)) { + return false; + } + } + + return true; + } + + /** + * Includes a required option in the list if not already present. + * + * Checks if a RequiredOption with the same name already exists in the list. + * If not, adds the new option. Returns the updated list. + * + * @since n.e.x.t + * + * @param list $options The existing list of required options. + * @param RequiredOption $option The option to potentially add. + * @return list The updated list of required options. + */ + private function includeInRequiredOptions(array $options, RequiredOption $option): array + { + // Check if an option with the same name already exists + foreach ($options as $existingOption) { + if ($existingOption->getName()->equals($option->getName())) { + // Option already exists, return unchanged list + return $options; + } + } + + // Add the new option + $options[] = $option; + return $options; + } + + /** + * Includes output modalities if not already present. + * + * Adds the given modalities to the output modalities list if they're not + * already included. If output modalities is null, initializes it with + * the given modalities. + * + * @since n.e.x.t + * + * @param ModalityEnum ...$modalities The modalities to include. + * @return void + */ + private function includeOutputModalities(ModalityEnum ...$modalities): void + { + $existing = $this->modelConfig->getOutputModalities(); + + // Initialize if null + if ($existing === null) { + $this->modelConfig->setOutputModalities($modalities); + return; + } + + // Build a set of existing modality values for O(1) lookup + $existingValues = []; + foreach ($existing as $existingModality) { + $existingValues[$existingModality->value] = true; + } + + // Add new modalities that don't exist + $toAdd = []; + foreach ($modalities as $modality) { + if (!isset($existingValues[$modality->value])) { + $toAdd[] = $modality; + } + } + + // Update if we have new modalities to add + if (!empty($toAdd)) { + $this->modelConfig->setOutputModalities(array_merge($existing, $toAdd)); + } + } +} diff --git a/src/Common/AbstractDataTransferObject.php b/src/Common/AbstractDataTransferObject.php index 302506dc..11baddd4 100644 --- a/src/Common/AbstractDataTransferObject.php +++ b/src/Common/AbstractDataTransferObject.php @@ -37,7 +37,7 @@ abstract class AbstractDataTransferObject implements * * @since n.e.x.t * - * @param TArrayShape $data The array data to validate. + * @param array $data The array data to validate. * @param string[] $requiredKeys The keys that must be present. * @throws InvalidArgumentException If any required key is missing. */ @@ -62,6 +62,22 @@ protected static function validateFromArrayData(array $data, array $requiredKeys } } + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function isArrayShape(array $array): bool + { + try { + /** @var TArrayShape $array */ + static::fromArray($array); + return true; + } catch (InvalidArgumentException $e) { + return false; + } + } + /** * Converts the object to a JSON-serializable format. * diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index c8d816c0..6b4a2cf4 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -138,7 +138,7 @@ final public static function from(string $value): self */ final public static function tryFrom(string $value): ?self { - $constants = self::getConstants(); + $constants = static::getConstants(); foreach ($constants as $name => $constantValue) { if ($constantValue === $value) { return self::getInstance($constantValue, $name); @@ -157,7 +157,7 @@ final public static function tryFrom(string $value): ?self final public static function cases(): array { $cases = []; - $constants = self::getConstants(); + $constants = static::getConstants(); foreach ($constants as $name => $value) { $cases[] = self::getInstance($value, $name); } @@ -203,7 +203,7 @@ final public function is(self $other): bool */ final public static function getValues(): array { - return array_values(self::getConstants()); + return array_values(static::getConstants()); } /** @@ -258,43 +258,60 @@ final protected static function getConstants(): array $className = static::class; if (!isset(self::$cache[$className])) { - $reflection = new ReflectionClass($className); - $constants = $reflection->getConstants(); - - // Validate all constants - $enumConstants = []; - foreach ($constants as $name => $value) { - // Check if constant name follows uppercase snake_case pattern - if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) { - throw new RuntimeException( - sprintf( - 'Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', - $name, - $className - ) - ); - } - - // Check if value is valid type - if (!is_string($value)) { - throw new RuntimeException( - sprintf( - 'Invalid enum value type for constant %s::%s. ' . - 'Only string values are allowed, %s given.', - $className, - $name, - gettype($value) - ) - ); - } - - $enumConstants[$name] = $value; + self::$cache[$className] = static::determineClassEnumerations($className); + } + + return self::$cache[$className]; + } + + /** + * Determines the class enumerations by reflecting on class constants. + * + * This method can be overridden by subclasses to customize how + * enumerations are determined (e.g., to add dynamic constants). + * + * @since n.e.x.t + * + * @param class-string $className The fully qualified class name. + * @return array Map of constant names to values. + * @throws RuntimeException If invalid constant found. + */ + protected static function determineClassEnumerations(string $className): array + { + $reflection = new ReflectionClass($className); + $constants = $reflection->getConstants(); + + // Validate all constants + $enumConstants = []; + foreach ($constants as $name => $value) { + // Check if constant name follows uppercase snake_case pattern + if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) { + throw new RuntimeException( + sprintf( + 'Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', + $name, + $className + ) + ); + } + + // Check if value is valid type + if (!is_string($value)) { + throw new RuntimeException( + sprintf( + 'Invalid enum value type for constant %s::%s. ' . + 'Only string values are allowed, %s given.', + $className, + $name, + gettype($value) + ) + ); } - self::$cache[$className] = $enumConstants; + $enumConstants[$name] = $value; } - return self::$cache[$className]; + return $enumConstants; } /** @@ -312,7 +329,7 @@ final public function __call(string $name, array $arguments): bool // Handle is* methods if (strpos($name, 'is') === 0) { $constantName = self::camelCaseToConstant(substr($name, 2)); - $constants = self::getConstants(); + $constants = static::getConstants(); if (isset($constants[$constantName])) { return $this->value === $constants[$constantName]; @@ -337,7 +354,7 @@ final public function __call(string $name, array $arguments): bool final public static function __callStatic(string $name, array $arguments): self { $constantName = self::camelCaseToConstant($name); - $constants = self::getConstants(); + $constants = static::getConstants(); if (isset($constants[$constantName])) { return self::getInstance($constants[$constantName], $constantName); diff --git a/src/Common/Contracts/WithArrayTransformationInterface.php b/src/Common/Contracts/WithArrayTransformationInterface.php index 257647f0..5d158e1b 100644 --- a/src/Common/Contracts/WithArrayTransformationInterface.php +++ b/src/Common/Contracts/WithArrayTransformationInterface.php @@ -31,4 +31,15 @@ public function toArray(): array; * @return self The created instance. */ public static function fromArray(array $array): self; + + /** + * Checks if the array is a valid shape for this object. + * + * @since n.e.x.t + * + * @param array $array The array to check. + * @return bool True if the array is a valid shape. + * @phpstan-assert-if-true TArrayShape $array + */ + public static function isArrayShape(array $array): bool; } diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 4a9ad809..ee731ff9 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -174,6 +174,30 @@ public function getFileType(): FileTypeEnum return $this->fileType; } + /** + * Checks if the file is an inline file. + * + * @since n.e.x.t + * + * @return bool True if the file is inline (base64/data URI). + */ + public function isInline(): bool + { + return $this->fileType->isInline(); + } + + /** + * Checks if the file is a remote file. + * + * @since n.e.x.t + * + * @return bool True if the file is remote (URL). + */ + public function isRemote(): bool + { + return $this->fileType->isRemote(); + } + /** * Gets the URL for remote files. * @@ -286,6 +310,18 @@ public function isText(): bool return $this->mimeType->isText(); } + /** + * Checks if the file is a document. + * + * @since n.e.x.t + * + * @return bool True if the file is a document. + */ + public function isDocument(): bool + { + return $this->mimeType->isDocument(); + } + /** * Checks if the file is a specific MIME type. * diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index d68b0b94..c07ca9e5 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Messages\DTO; +use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; @@ -45,11 +46,13 @@ class Message extends AbstractDataTransferObject * * @param MessageRoleEnum $role The role of the message sender. * @param MessagePart[] $parts The parts that make up this message. + * @throws InvalidArgumentException If parts contain invalid content for the role. */ public function __construct(MessageRoleEnum $role, array $parts) { $this->role = $role; $this->parts = $parts; + $this->validateParts(); } /** @@ -76,6 +79,48 @@ public function getParts(): array return $this->parts; } + /** + * Returns a new instance with the given part appended. + * + * @since n.e.x.t + * + * @param MessagePart $part The part to append. + * @return Message A new instance with the part appended. + * @throws InvalidArgumentException If the part is invalid for the role. + */ + public function withPart(MessagePart $part): Message + { + $newParts = $this->parts; + $newParts[] = $part; + + return new Message($this->role, $newParts); + } + + /** + * Validates that the message parts are appropriate for the message role. + * + * @since n.e.x.t + * + * @return void + * @throws InvalidArgumentException If validation fails. + */ + private function validateParts(): void + { + foreach ($this->parts as $part) { + if ($this->role->isUser() && $part->getType()->isFunctionCall()) { + throw new InvalidArgumentException( + 'User messages cannot contain function calls.' + ); + } + + if ($this->role->isModel() && $part->getType()->isFunctionResponse()) { + throw new InvalidArgumentException( + 'Model messages cannot contain function responses.' + ); + } + } + } + /** * {@inheritDoc} * diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php index cf67b79c..2b160cc3 100644 --- a/src/Messages/DTO/ModelMessage.php +++ b/src/Messages/DTO/ModelMessage.php @@ -12,6 +12,10 @@ * This is a convenience class that automatically sets the role to MODEL. * Model messages contain the AI's responses. * + * Important: Do not rely on `instanceof ModelMessage` to determine the message role. + * This is merely a helper class for construction. Always use `$message->getRole()` + * to check the role of a message. + * * @since n.e.x.t */ class ModelMessage extends Message diff --git a/src/Messages/DTO/UserMessage.php b/src/Messages/DTO/UserMessage.php index c0cb931f..84aded05 100644 --- a/src/Messages/DTO/UserMessage.php +++ b/src/Messages/DTO/UserMessage.php @@ -11,6 +11,10 @@ * * This is a convenience class that automatically sets the role to USER. * + * Important: Do not rely on `instanceof UserMessage` to determine the message role. + * This is merely a helper class for construction. Always use `$message->getRole()` + * to check the role of a message. + * * @since n.e.x.t */ class UserMessage extends Message diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index e3504232..9555be05 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -913,6 +914,139 @@ static function (FunctionDeclaration $function_declaration): array { return $data; } + /** + * Converts the model configuration to required options. + * + * @since n.e.x.t + * + * @return list The required options. + */ + public function toRequiredOptions(): array + { + $requiredOptions = []; + + // Map properties that have corresponding OptionEnum values + if ($this->outputModalities !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputModalities(), + $this->outputModalities + ); + } + + if ($this->systemInstruction !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::systemInstruction(), + $this->systemInstruction + ); + } + + if ($this->candidateCount !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::candidateCount(), + $this->candidateCount + ); + } + + if ($this->maxTokens !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::maxTokens(), + $this->maxTokens + ); + } + + if ($this->temperature !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::temperature(), + $this->temperature + ); + } + + if ($this->topP !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::topP(), + $this->topP + ); + } + + if ($this->topK !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::topK(), + $this->topK + ); + } + + if ($this->outputMimeType !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMimeType(), + $this->outputMimeType + ); + } + + if ($this->outputSchema !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputSchema(), + $this->outputSchema + ); + } + + // Handle properties without OptionEnum values as custom options + // These would need to be handled specially by providers + if ($this->stopSequences !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::stopSequences(), $this->stopSequences); + } + + if ($this->presencePenalty !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::presencePenalty(), $this->presencePenalty); + } + + if ($this->frequencyPenalty !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::frequencyPenalty(), $this->frequencyPenalty); + } + + if ($this->logprobs !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::logprobs(), $this->logprobs); + } + + if ($this->topLogprobs !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::topLogprobs(), $this->topLogprobs); + } + + if ($this->functionDeclarations !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::functionDeclarations(), true); + } + + if ($this->webSearch !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::webSearch(), true); + } + + if ($this->outputFileType !== null) { + $requiredOptions[] = new RequiredOption(OptionEnum::outputFileType(), $this->outputFileType->value); + } + + if ($this->outputMediaOrientation !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMediaOrientation(), + $this->outputMediaOrientation->value + ); + } + + if ($this->outputMediaAspectRatio !== null) { + $requiredOptions[] = new RequiredOption( + OptionEnum::outputMediaAspectRatio(), + $this->outputMediaAspectRatio + ); + } + + // Add custom options as individual RequiredOptions + // Custom options don't have predefined OptionEnum values, so we use the customOptions enum + // with the actual key-value pair as the value + foreach ($this->customOptions as $key => $value) { + $requiredOptions[] = new RequiredOption(OptionEnum::customOptions(), [$key => $value]); + } + + return $requiredOptions; + } + /** * {@inheritDoc} * diff --git a/src/Providers/Models/DTO/ModelMetadata.php b/src/Providers/Models/DTO/ModelMetadata.php index a5429103..23bb335e 100644 --- a/src/Providers/Models/DTO/ModelMetadata.php +++ b/src/Providers/Models/DTO/ModelMetadata.php @@ -98,7 +98,7 @@ public function __construct(string $id, string $name, array $supportedCapabiliti // Build options map for efficient lookups foreach ($supportedOptions as $option) { - $this->optionsMap[$option->getName()] = $option; + $this->optionsMap[$option->getName()->value] = $option; } } @@ -229,11 +229,11 @@ public function meetsRequirements(ModelRequirements $requirements): bool // Check if all required options are supported with the specified values foreach ($requirements->getRequiredOptions() as $requiredOption) { // Use map lookup instead of linear search - if (!isset($this->optionsMap[$requiredOption->getName()])) { + if (!isset($this->optionsMap[$requiredOption->getName()->value])) { return false; } - $supportedOption = $this->optionsMap[$requiredOption->getName()]; + $supportedOption = $this->optionsMap[$requiredOption->getName()->value]; // Check if the required value is supported by this option if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { diff --git a/src/Providers/Models/DTO/RequiredOption.php b/src/Providers/Models/DTO/RequiredOption.php index ee79c7f5..d7d26291 100644 --- a/src/Providers/Models/DTO/RequiredOption.php +++ b/src/Providers/Models/DTO/RequiredOption.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Providers\Models\DTO; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Represents an option that the implementing code requires the model to support. @@ -27,9 +28,9 @@ class RequiredOption extends AbstractDataTransferObject public const KEY_VALUE = 'value'; /** - * @var string The option name. + * @var OptionEnum The option name. */ - protected string $name; + protected OptionEnum $name; /** * @var mixed The value that the model must support for this option. @@ -41,10 +42,10 @@ class RequiredOption extends AbstractDataTransferObject * * @since n.e.x.t * - * @param string $name The option name. + * @param OptionEnum $name The option name. * @param mixed $value The value that the model must support for this option. */ - public function __construct(string $name, $value) + public function __construct(OptionEnum $name, $value) { $this->name = $name; $this->value = $value; @@ -55,9 +56,9 @@ public function __construct(string $name, $value) * * @since n.e.x.t * - * @return string The option name. + * @return OptionEnum The option name. */ - public function getName(): string + public function getName(): OptionEnum { return $this->name; } @@ -86,6 +87,7 @@ public static function getJsonSchema(): array 'properties' => [ self::KEY_NAME => [ 'type' => 'string', + 'enum' => OptionEnum::getValues(), 'description' => 'The option name.', ], self::KEY_VALUE => [ @@ -114,7 +116,7 @@ public static function getJsonSchema(): array public function toArray(): array { return [ - self::KEY_NAME => $this->name, + self::KEY_NAME => $this->name->value, self::KEY_VALUE => $this->value, ]; } @@ -129,7 +131,7 @@ public static function fromArray(array $array): self static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_VALUE]); return new self( - $array[self::KEY_NAME], + OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_VALUE] ); } diff --git a/src/Providers/Models/DTO/SupportedOption.php b/src/Providers/Models/DTO/SupportedOption.php index ddf76b62..1a4d121f 100644 --- a/src/Providers/Models/DTO/SupportedOption.php +++ b/src/Providers/Models/DTO/SupportedOption.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use WordPress\AiClient\Common\AbstractDataTransferObject; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Represents a supported configuration option for an AI model. @@ -28,9 +29,9 @@ class SupportedOption extends AbstractDataTransferObject public const KEY_SUPPORTED_VALUES = 'supportedValues'; /** - * @var string The option name. + * @var OptionEnum The option name. */ - protected string $name; + protected OptionEnum $name; /** * @var list|null The supported values for this option. @@ -42,12 +43,12 @@ class SupportedOption extends AbstractDataTransferObject * * @since n.e.x.t * - * @param string $name The option name. + * @param OptionEnum $name The option name. * @param list|null $supportedValues The supported values for this option, or null if any value is supported. * * @throws InvalidArgumentException If supportedValues is not null and not a list. */ - public function __construct(string $name, ?array $supportedValues = null) + public function __construct(OptionEnum $name, ?array $supportedValues = null) { if ($supportedValues !== null && !array_is_list($supportedValues)) { throw new InvalidArgumentException('Supported values must be a list array.'); @@ -62,9 +63,9 @@ public function __construct(string $name, ?array $supportedValues = null) * * @since n.e.x.t * - * @return string The option name. + * @return OptionEnum The option name. */ - public function getName(): string + public function getName(): OptionEnum { return $this->name; } @@ -111,6 +112,7 @@ public static function getJsonSchema(): array 'properties' => [ self::KEY_NAME => [ 'type' => 'string', + 'enum' => OptionEnum::getValues(), 'description' => 'The option name.', ], self::KEY_SUPPORTED_VALUES => [ @@ -142,7 +144,7 @@ public static function getJsonSchema(): array public function toArray(): array { $data = [ - self::KEY_NAME => $this->name, + self::KEY_NAME => $this->name->value, ]; if ($this->supportedValues !== null) { @@ -164,7 +166,7 @@ public static function fromArray(array $array): self static::validateFromArrayData($array, [self::KEY_NAME]); return new self( - $array[self::KEY_NAME], + OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_SUPPORTED_VALUES] ?? null ); } diff --git a/src/Providers/Models/Enums/OptionEnum.php b/src/Providers/Models/Enums/OptionEnum.php index 96fd37a8..508cc67d 100644 --- a/src/Providers/Models/Enums/OptionEnum.php +++ b/src/Providers/Models/Enums/OptionEnum.php @@ -4,83 +4,109 @@ namespace WordPress\AiClient\Providers\Models\Enums; +use ReflectionClass; use WordPress\AiClient\Common\AbstractEnum; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; /** * Enum for model options. * - * @since n.e.x.t + * This enum dynamically includes all options from ModelConfig KEY_* constants + * in addition to the explicitly defined constants below. * + * Explicitly defined option (not in ModelConfig): * @method static self inputModalities() Creates an instance for INPUT_MODALITIES option. - * @method static self outputModalities() Creates an instance for OUTPUT_MODALITIES option. - * @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option. + * @method bool isInputModalities() Checks if the option is INPUT_MODALITIES. + * + * Dynamically loaded from ModelConfig KEY_* constants: * @method static self candidateCount() Creates an instance for CANDIDATE_COUNT option. + * @method static self customOptions() Creates an instance for CUSTOM_OPTIONS option. + * @method static self frequencyPenalty() Creates an instance for FREQUENCY_PENALTY option. + * @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS option. + * @method static self logprobs() Creates an instance for LOGPROBS option. * @method static self maxTokens() Creates an instance for MAX_TOKENS option. + * @method static self outputFileType() Creates an instance for OUTPUT_FILE_TYPE option. + * @method static self outputMediaAspectRatio() Creates an instance for OUTPUT_MEDIA_ASPECT_RATIO option. + * @method static self outputMediaOrientation() Creates an instance for OUTPUT_MEDIA_ORIENTATION option. + * @method static self outputMimeType() Creates an instance for OUTPUT_MIME_TYPE option. + * @method static self outputModalities() Creates an instance for OUTPUT_MODALITIES option. + * @method static self outputSchema() Creates an instance for OUTPUT_SCHEMA option. + * @method static self presencePenalty() Creates an instance for PRESENCE_PENALTY option. + * @method static self stopSequences() Creates an instance for STOP_SEQUENCES option. + * @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option. * @method static self temperature() Creates an instance for TEMPERATURE option. * @method static self topK() Creates an instance for TOP_K option. + * @method static self topLogprobs() Creates an instance for TOP_LOGPROBS option. * @method static self topP() Creates an instance for TOP_P option. - * @method static self outputMimeType() Creates an instance for OUTPUT_MIME_TYPE option. - * @method static self outputSchema() Creates an instance for OUTPUT_SCHEMA option. - * @method bool isInputModalities() Checks if the option is INPUT_MODALITIES. - * @method bool isOutputModalities() Checks if the option is OUTPUT_MODALITIES. - * @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION. + * @method static self webSearch() Creates an instance for WEB_SEARCH option. * @method bool isCandidateCount() Checks if the option is CANDIDATE_COUNT. + * @method bool isCustomOptions() Checks if the option is CUSTOM_OPTIONS. + * @method bool isFrequencyPenalty() Checks if the option is FREQUENCY_PENALTY. + * @method bool isFunctionDeclarations() Checks if the option is FUNCTION_DECLARATIONS. + * @method bool isLogprobs() Checks if the option is LOGPROBS. * @method bool isMaxTokens() Checks if the option is MAX_TOKENS. + * @method bool isOutputFileType() Checks if the option is OUTPUT_FILE_TYPE. + * @method bool isOutputMediaAspectRatio() Checks if the option is OUTPUT_MEDIA_ASPECT_RATIO. + * @method bool isOutputMediaOrientation() Checks if the option is OUTPUT_MEDIA_ORIENTATION. + * @method bool isOutputMimeType() Checks if the option is OUTPUT_MIME_TYPE. + * @method bool isOutputModalities() Checks if the option is OUTPUT_MODALITIES. + * @method bool isOutputSchema() Checks if the option is OUTPUT_SCHEMA. + * @method bool isPresencePenalty() Checks if the option is PRESENCE_PENALTY. + * @method bool isStopSequences() Checks if the option is STOP_SEQUENCES. + * @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION. * @method bool isTemperature() Checks if the option is TEMPERATURE. * @method bool isTopK() Checks if the option is TOP_K. + * @method bool isTopLogprobs() Checks if the option is TOP_LOGPROBS. * @method bool isTopP() Checks if the option is TOP_P. - * @method bool isOutputMimeType() Checks if the option is OUTPUT_MIME_TYPE. - * @method bool isOutputSchema() Checks if the option is OUTPUT_SCHEMA. + * @method bool isWebSearch() Checks if the option is WEB_SEARCH. + * + * @since n.e.x.t */ class OptionEnum extends AbstractEnum { /** * Input modalities option. + * + * This constant is not in ModelConfig as it's derived from message content, + * not configured directly. */ public const INPUT_MODALITIES = 'input_modalities'; /** - * Output modalities option. + * Determines the class enumerations by reflecting on class constants. + * + * Overrides the parent method to dynamically add constants from ModelConfig + * that are prefixed with KEY_. These are transformed to remove the KEY_ prefix + * and converted to snake_case values. + * + * @since n.e.x.t + * + * @param class-string $className The fully qualified class name. + * @return array The enum constants. */ - public const OUTPUT_MODALITIES = 'output_modalities'; + protected static function determineClassEnumerations(string $className): array + { + // Start with the constants defined in this class using parent method + $constants = parent::determineClassEnumerations($className); - /** - * System instruction option. - */ - public const SYSTEM_INSTRUCTION = 'system_instruction'; + // Use reflection to get all constants from ModelConfig + $modelConfigReflection = new ReflectionClass(ModelConfig::class); + $modelConfigConstants = $modelConfigReflection->getConstants(); - /** - * Candidate count option. - */ - public const CANDIDATE_COUNT = 'candidate_count'; + // Add ModelConfig constants that start with KEY_ + foreach ($modelConfigConstants as $constantName => $constantValue) { + if (strpos($constantName, 'KEY_') === 0) { + // Remove KEY_ prefix to get the enum constant name + $enumConstantName = substr($constantName, 4); - /** - * Maximum tokens option. - */ - public const MAX_TOKENS = 'max_tokens'; + // The value is the snake_case version stored in ModelConfig + // ModelConfig already stores these as snake_case strings + if (is_string($constantValue)) { + $constants[$enumConstantName] = $constantValue; + } + } + } - /** - * Temperature option. - */ - public const TEMPERATURE = 'temperature'; - - /** - * Top K option. - */ - public const TOP_K = 'top_k'; - - /** - * Top P option. - */ - public const TOP_P = 'top_p'; - - /** - * Output MIME type option. - */ - public const OUTPUT_MIME_TYPE = 'output_mime_type'; - - /** - * Output schema option. - */ - public const OUTPUT_SCHEMA = 'output_schema'; + return $constants; + } } diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index ba7882cc..229099a2 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -268,7 +268,7 @@ public function toMessage(): Message * * @since n.e.x.t * - * @return string[] Array of text content. + * @return list Array of text content. */ public function toTexts(): array { @@ -291,7 +291,7 @@ public function toTexts(): array * * @since n.e.x.t * - * @return File[] Array of files. + * @return list Array of files. */ public function toFiles(): array { @@ -314,7 +314,7 @@ public function toFiles(): array * * @since n.e.x.t * - * @return File[] Array of image files. + * @return list Array of image files. */ public function toImageFiles(): array { @@ -329,7 +329,7 @@ public function toImageFiles(): array * * @since n.e.x.t * - * @return File[] Array of audio files. + * @return list Array of audio files. */ public function toAudioFiles(): array { @@ -344,7 +344,7 @@ public function toAudioFiles(): array * * @since n.e.x.t * - * @return File[] Array of video files. + * @return list Array of video files. */ public function toVideoFiles(): array { @@ -359,11 +359,11 @@ public function toVideoFiles(): array * * @since n.e.x.t * - * @return Message[] Array of messages. + * @return list Array of messages. */ public function toMessages(): array { - return array_map(fn(Candidate $candidate) => $candidate->getMessage(), $this->candidates); + return array_values(array_map(fn(Candidate $candidate) => $candidate->getMessage(), $this->candidates)); } /** diff --git a/tests/traits/ArrayTransformationTestTrait.php b/tests/traits/ArrayTransformationTestTrait.php index 09f175ba..d670c0f2 100644 --- a/tests/traits/ArrayTransformationTestTrait.php +++ b/tests/traits/ArrayTransformationTestTrait.php @@ -83,4 +83,33 @@ protected function assertArrayNotHasKeys(array $array, array $unexpectedKeys): v $this->assertArrayNotHasKey($key, $array, "Array should not contain key: {$key}"); } } + + /** + * Tests isArrayShape with valid and invalid arrays. + * + * @param string $className The class name to test. + * @param array $validArray A valid array that should pass isArrayShape. + * @param array[] $invalidArrays Arrays that should fail isArrayShape. + * @return void + */ + protected function assertIsArrayShapeValidation(string $className, array $validArray, array $invalidArrays): void + { + // Test valid array + $this->assertTrue( + $className::isArrayShape($validArray), + 'isArrayShape() should return true for valid array structure' + ); + + // Test that fromArray works with the valid array (ensures consistency) + $instance = $className::fromArray($validArray); + $this->assertInstanceOf($className, $instance); + + // Test invalid arrays + foreach ($invalidArrays as $description => $invalidArray) { + $this->assertFalse( + $className::isArrayShape($invalidArray), + "isArrayShape() should return false for: {$description}" + ); + } + } } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php new file mode 100644 index 00000000..0740c2ac --- /dev/null +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -0,0 +1,2465 @@ +metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + + public function streamGenerateTextResult(array $prompt): Generator + { + yield $this->result; + } + }; + } + + /** + * Creates a mock model that implements both ModelInterface and ImageGenerationModelInterface. + * + * @param ModelMetadata $metadata The metadata for the model. + * @param GenerativeAiResult $result The result to return from generation. + * @return ModelInterface&ImageGenerationModelInterface The mock model. + */ + private function createImageGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface + { + return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateImageResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + }; + } + + /** + * Creates a mock model that implements both ModelInterface and SpeechGenerationModelInterface. + * + * @param ModelMetadata $metadata The metadata for the model. + * @param GenerativeAiResult $result The result to return from generation. + * @return ModelInterface&SpeechGenerationModelInterface The mock model. + */ + private function createSpeechGenerationModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface + { + return new class ($metadata, $result) implements ModelInterface, SpeechGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateSpeechResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + }; + } + + /** + * Creates a mock model that implements both ModelInterface and TextToSpeechConversionModelInterface. + * + * @param ModelMetadata $metadata The metadata for the model. + * @param GenerativeAiResult $result The result to return from generation. + * @return ModelInterface&TextToSpeechConversionModelInterface The mock model. + */ + private function createTextToSpeechModel(ModelMetadata $metadata, GenerativeAiResult $result): ModelInterface + { + return new class ($metadata, $result) implements ModelInterface, TextToSpeechConversionModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function convertTextToSpeechResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + }; + } + + /** + * Sets up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $this->registry = $this->createMock(ProviderRegistry::class); + } + + /** + * Tests constructor with no prompt. + * + * @return void + */ + public function testConstructorWithNoPrompt(): void + { + $builder = new PromptBuilder($this->registry); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + + $this->assertEmpty($messagesProperty->getValue($builder)); + } + + /** + * Tests constructor with string prompt. + * + * @return void + */ + public function testConstructorWithStringPrompt(): void + { + $builder = new PromptBuilder($this->registry, 'Hello, world!'); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(Message::class, $messages[0]); + $this->assertEquals('Hello, world!', $messages[0]->getParts()[0]->getText()); + } + + /** + * Tests constructor with MessagePart prompt. + * + * @return void + */ + public function testConstructorWithMessagePartPrompt(): void + { + $part = new MessagePart('Test message'); + $builder = new PromptBuilder($this->registry, $part); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(Message::class, $messages[0]); + $this->assertEquals('Test message', $messages[0]->getParts()[0]->getText()); + } + + /** + * Tests constructor with Message prompt. + * + * @return void + */ + public function testConstructorWithMessagePrompt(): void + { + $message = new UserMessage([new MessagePart('User message')]); + $builder = new PromptBuilder($this->registry, $message); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertSame($message, $messages[0]); + } + + /** + * Tests constructor with list of Messages. + * + * @return void + */ + public function testConstructorWithMessagesList(): void + { + $messages = [ + new UserMessage([new MessagePart('First')]), + new ModelMessage([new MessagePart('Second')]), + new UserMessage([new MessagePart('Third')]) + ]; + $builder = new PromptBuilder($this->registry, $messages); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $actualMessages */ + $actualMessages = $messagesProperty->getValue($builder); + + $this->assertCount(3, $actualMessages); + $this->assertSame($messages, $actualMessages); + } + + /** + * Tests constructor with MessageArrayShape. + * + * @return void + */ + public function testConstructorWithMessageArrayShape(): void + { + $messageArray = [ + 'role' => 'user', + 'parts' => [ + ['type' => 'text', 'text' => 'Hello from array'] + ] + ]; + $builder = new PromptBuilder($this->registry, $messageArray); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(Message::class, $messages[0]); + $this->assertEquals('Hello from array', $messages[0]->getParts()[0]->getText()); + } + + /** + * Tests withText method. + * + * @return void + */ + public function testWithText(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->withText('Some text'); + + $this->assertSame($builder, $result); // Test fluent interface + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertEquals('Some text', $messages[0]->getParts()[0]->getText()); + } + + /** + * Tests withText appends to existing user message. + * + * @return void + */ + public function testWithTextAppendsToExistingUserMessage(): void + { + $builder = new PromptBuilder($this->registry, 'Initial text'); + $builder->withText(' Additional text'); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $parts = $messages[0]->getParts(); + $this->assertCount(2, $parts); + $this->assertEquals('Initial text', $parts[0]->getText()); + $this->assertEquals(' Additional text', $parts[1]->getText()); + } + + /** + * Tests withFile method with base64 data. + * + * @return void + */ + public function testWithInlineFile(): void + { + $builder = new PromptBuilder($this->registry); + $base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $result = $builder->withFile($base64, 'image/png'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('data:image/png;base64,' . $base64, $file->getDataUri()); + $this->assertEquals('image/png', $file->getMimeType()); + } + + /** + * Tests withFile method with remote URL. + * + * @return void + */ + public function testWithRemoteFile(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->withFile('https://example.com/image.jpg', 'image/jpeg'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('https://example.com/image.jpg', $file->getUrl()); + $this->assertEquals('image/jpeg', $file->getMimeType()); + } + + /** + * Tests withFile with data URI. + * + * @return void + */ + public function testWithInlineFileDataUri(): void + { + $builder = new PromptBuilder($this->registry); + $dataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; + $result = $builder->withFile($dataUri); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('image/jpeg', $file->getMimeType()); + } + + /** + * Tests withFile with URL without explicit MIME type. + * + * @return void + */ + public function testWithRemoteFileWithoutMimeType(): void + { + $builder = new PromptBuilder($this->registry); + // File extension should be used to determine MIME type + $result = $builder->withFile('https://example.com/audio.mp3'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $file = $messages[0]->getParts()[0]->getFile(); + $this->assertInstanceOf(File::class, $file); + $this->assertEquals('https://example.com/audio.mp3', $file->getUrl()); + $this->assertEquals('audio/mpeg', $file->getMimeType()); + } + + /** + * Tests withFunctionResponse method. + * + * @return void + */ + public function testWithFunctionResponse(): void + { + $functionResponse = new FunctionResponse('func_id', 'func_name', ['result' => 'data']); + $builder = new PromptBuilder($this->registry); + $result = $builder->withFunctionResponse($functionResponse); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertSame($functionResponse, $messages[0]->getParts()[0]->getFunctionResponse()); + } + + /** + * Tests withMessageParts method. + * + * @return void + */ + public function testWithMessageParts(): void + { + $part1 = new MessagePart('Part 1'); + $part2 = new MessagePart('Part 2'); + $part3 = new MessagePart('Part 3'); + + $builder = new PromptBuilder($this->registry); + $result = $builder->withMessageParts($part1, $part2, $part3); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $parts = $messages[0]->getParts(); + $this->assertCount(3, $parts); + $this->assertEquals('Part 1', $parts[0]->getText()); + $this->assertEquals('Part 2', $parts[1]->getText()); + $this->assertEquals('Part 3', $parts[2]->getText()); + } + + /** + * Tests withHistory method. + * + * @return void + */ + public function testWithHistory(): void + { + $history = [ + new UserMessage([new MessagePart('User 1')]), + new ModelMessage([new MessagePart('Model 1')]), + new UserMessage([new MessagePart('User 2')]) + ]; + + $builder = new PromptBuilder($this->registry); + $result = $builder->withHistory(...$history); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(3, $messages); + $this->assertEquals('User 1', $messages[0]->getParts()[0]->getText()); + $this->assertEquals('Model 1', $messages[1]->getParts()[0]->getText()); + $this->assertEquals('User 2', $messages[2]->getParts()[0]->getText()); + } + + /** + * Tests usingModel method. + * + * @return void + */ + public function testUsingModel(): void + { + $model = $this->createMock(ModelInterface::class); + $builder = new PromptBuilder($this->registry); + $result = $builder->usingModel($model); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $modelProperty = $reflection->getProperty('model'); + $modelProperty->setAccessible(true); + + /** @var ModelInterface $actualModel */ + $actualModel = $modelProperty->getValue($builder); + $this->assertSame($model, $actualModel); + } + + /** + * Tests usingSystemInstruction method. + * + * @return void + */ + public function testUsingSystemInstruction(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingSystemInstruction('You are a helpful assistant.'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals('You are a helpful assistant.', $config->getSystemInstruction()); + } + + /** + * Tests usingMaxTokens method. + * + * @return void + */ + public function testUsingMaxTokens(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingMaxTokens(1000); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(1000, $config->getMaxTokens()); + } + + /** + * Tests usingTemperature method. + * + * @return void + */ + public function testUsingTemperature(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingTemperature(0.7); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(0.7, $config->getTemperature()); + } + + /** + * Tests usingTopP method. + * + * @return void + */ + public function testUsingTopP(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingTopP(0.9); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(0.9, $config->getTopP()); + } + + /** + * Tests usingTopK method. + * + * @return void + */ + public function testUsingTopK(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingTopK(40); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(40, $config->getTopK()); + } + + /** + * Tests usingStopSequences method. + * + * @return void + */ + public function testUsingStopSequences(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingStopSequences('STOP', 'END', '###'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $customOptions = $config->getCustomOptions(); + $this->assertArrayHasKey('stopSequences', $customOptions); + $this->assertEquals(['STOP', 'END', '###'], $customOptions['stopSequences']); + } + + /** + * Tests usingCandidateCount method. + * + * @return void + */ + public function testUsingCandidateCount(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->usingCandidateCount(3); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(3, $config->getCandidateCount()); + } + + /** + * Tests usingOutputMime method. + * + * @return void + */ + public function testUsingOutputMime(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->asOutputMimeType('application/json'); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals('application/json', $config->getOutputMimeType()); + } + + /** + * Tests usingOutputSchema method. + * + * @return void + */ + public function testUsingOutputSchema(): void + { + $schema = [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'] + ] + ]; + + $builder = new PromptBuilder($this->registry); + $result = $builder->asOutputSchema($schema); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals($schema, $config->getOutputSchema()); + } + + /** + * Tests usingOutputModalities method. + * + * @return void + */ + public function testUsingOutputModalities(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->asOutputModalities( + ModalityEnum::text(), + ModalityEnum::image() + ); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertCount(2, $modalities); + $this->assertTrue($modalities[0]->isText()); + $this->assertTrue($modalities[1]->isImage()); + } + + /** + * Tests asJsonResponse method. + * + * @return void + */ + public function testAsJsonResponse(): void + { + $builder = new PromptBuilder($this->registry); + $result = $builder->asJsonResponse(); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals('application/json', $config->getOutputMimeType()); + } + + /** + * Tests asJsonResponse with schema. + * + * @return void + */ + public function testAsJsonResponseWithSchema(): void + { + $schema = ['type' => 'array']; + $builder = new PromptBuilder($this->registry); + $result = $builder->asJsonResponse($schema); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals('application/json', $config->getOutputMimeType()); + $this->assertEquals($schema, $config->getOutputSchema()); + } + + + /** + * Tests validateMessages with empty messages throws exception. + * + * @return void + */ + public function testValidateMessagesEmptyThrowsException(): void + { + $builder = new PromptBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot generate from an empty prompt'); + + $builder->generateResult(); + } + + /** + * Tests validateMessages with non-user first message throws exception. + * + * @return void + */ + public function testValidateMessagesNonUserFirstThrowsException(): void + { + $builder = new PromptBuilder($this->registry, [ + new ModelMessage([new MessagePart('Model says hi')]), + new UserMessage([new MessagePart('User response')]) + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The first message must be from a user role'); + + $builder->generateResult(); + } + + /** + * Tests validateMessages with non-user last message throws exception. + * + * @return void + */ + public function testValidateMessagesNonUserLastThrowsException(): void + { + // Start with a user message + $builder = new PromptBuilder($this->registry); + $builder->withText('Initial user message'); + + // Add history that will make the last message a model message + $builder->withHistory( + new UserMessage([new MessagePart('Historical user message')]), + new ModelMessage([new MessagePart('Historical model response')]) + ); + + // Now add a model message manually to be the last message + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + $messages = $messagesProperty->getValue($builder); + $messages[] = new ModelMessage([new MessagePart('Final model message')]); + $messagesProperty->setValue($builder, $messages); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The last message must be from a user role'); + + $builder->generateResult(); + } + + /** + * Tests parseMessage with empty string throws exception. + * + * @return void + */ + public function testParseMessageEmptyStringThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot create a message from an empty string'); + + new PromptBuilder($this->registry, ' '); + } + + /** + * Tests parseMessage with empty array throws exception. + * + * @return void + */ + public function testParseMessageEmptyArrayThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot create a message from an empty array'); + + new PromptBuilder($this->registry, []); + } + + /** + * Tests parseMessage with invalid type throws exception. + * + * @return void + */ + public function testParseMessageInvalidTypeThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Input must be a string, MessagePart, MessagePartArrayShape'); + + new PromptBuilder($this->registry, 123); + } + + /** + * Tests chaining multiple operations. + * + * @return void + */ + public function testMethodChaining(): void + { + $model = $this->createMock(ModelInterface::class); + + $builder = new PromptBuilder($this->registry); + $result = $builder + ->withText('Start of prompt') + ->withFile('https://example.com/img.jpg', 'image/jpeg') + ->usingModel($model) + ->usingSystemInstruction('Be helpful') + ->usingMaxTokens(500) + ->usingTemperature(0.8) + ->usingTopP(0.95) + ->usingTopK(50) + ->usingCandidateCount(2) + ->asJsonResponse(); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + $this->assertCount(1, $messages); + $this->assertCount(2, $messages[0]->getParts()); // Text and image + + $modelProperty = $reflection->getProperty('model'); + $modelProperty->setAccessible(true); + /** @var ModelInterface $actualModel */ + $actualModel = $modelProperty->getValue($builder); + $this->assertSame($model, $actualModel); + + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals('Be helpful', $config->getSystemInstruction()); + $this->assertEquals(500, $config->getMaxTokens()); + $this->assertEquals(0.8, $config->getTemperature()); + $this->assertEquals(0.95, $config->getTopP()); + $this->assertEquals(50, $config->getTopK()); + $this->assertEquals(2, $config->getCandidateCount()); + $this->assertEquals('application/json', $config->getOutputMimeType()); + } + + /** + * Tests generateResult with text output modality. + * + * @return void + */ + public function testGenerateResultWithTextModality(): void + { + $result = $this->createMock(GenerativeAiResult::class); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $actualResult = $builder->generateResult(); + $this->assertSame($result, $actualResult); + } + + /** + * Tests generateResult with image output modality. + * + * @return void + */ + public function testGenerateResultWithImageModality(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate an image'); + $builder->usingModel($model); + $builder->asOutputModalities(ModalityEnum::image()); + + $actualResult = $builder->generateResult(); + $this->assertSame($result, $actualResult); + } + + /** + * Tests generateResult with audio output modality. + * + * @return void + */ + public function testGenerateResultWithAudioModality(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:audio/wav;base64,UklGRigE=', 'audio/wav'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createSpeechGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate speech'); + $builder->usingModel($model); + $builder->asOutputModalities(ModalityEnum::audio()); + + $actualResult = $builder->generateResult(); + $this->assertSame($result, $actualResult); + } + + /** + * Tests generateResult with multimodal output. + * + * @return void + */ + public function testGenerateResultWithMultimodalOutput(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate multimodal'); + $builder->usingModel($model); + $builder->asOutputModalities(ModalityEnum::text(), ModalityEnum::image()); + + $actualResult = $builder->generateResult(); + $this->assertSame($result, $actualResult); + } + + /** + * Tests generateResult throws exception when model doesn't support modality. + * + * @return void + */ + public function testGenerateResultThrowsExceptionForUnsupportedModality(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + // Model that only implements ModelInterface, not TextGenerationModelInterface + $model = $this->createMock(ModelInterface::class); + $model->method('metadata')->willReturn($metadata); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "test-model" does not support text generation'); + + $builder->generateResult(); + } + + /** + * Tests generateResult throws exception for unsupported output modality. + * + * @return void + */ + public function testGenerateResultThrowsExceptionForUnsupportedOutputModality(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createMock(ModelInterface::class); + $model->method('metadata')->willReturn($metadata); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + $builder->asOutputModalities(ModalityEnum::video()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Output modality "video" is not yet supported'); + + $builder->generateResult(); + } + + /** + * Tests generateTextResult method. + * + * @return void + */ + public function testGenerateTextResult(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $actualResult = $builder->generateTextResult(); + $this->assertSame($result, $actualResult); + + // Verify text modality was included + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull($modalities); + $this->assertTrue($modalities[0]->isText()); + } + + /** + * Tests generateImageResult method. + * + * @return void + */ + public function testGenerateImageResult(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate image'); + $builder->usingModel($model); + + $actualResult = $builder->generateImageResult(); + $this->assertSame($result, $actualResult); + + // Verify image modality was included + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull($modalities); + $this->assertTrue($modalities[0]->isImage()); + } + + /** + * Tests generateSpeechResult method. + * + * @return void + */ + public function testGenerateSpeechResult(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:audio/wav;base64,UklGRigE=', 'audio/wav'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createSpeechGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate speech'); + $builder->usingModel($model); + + $actualResult = $builder->generateSpeechResult(); + $this->assertSame($result, $actualResult); + + // Verify audio modality was included + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull($modalities); + $this->assertTrue($modalities[0]->isAudio()); + } + + /** + * Tests convertTextToSpeechResult method. + * + * @return void + */ + public function testConvertTextToSpeechResult(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:audio/wav;base64,UklGRigE=', 'audio/wav'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextToSpeechModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Convert to speech'); + $builder->usingModel($model); + + $actualResult = $builder->convertTextToSpeechResult(); + $this->assertSame($result, $actualResult); + + // Verify audio modality was included + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertNotNull($modalities); + $this->assertTrue($modalities[0]->isAudio()); + } + + /** + * Tests convertTextToSpeechResult throws exception for unsupported model. + * + * @return void + */ + public function testConvertTextToSpeechResultThrowsExceptionForUnsupportedModel(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + // Model that doesn't implement TextToSpeechConversionModelInterface + $model = $this->createMock(ModelInterface::class); + $model->method('metadata')->willReturn($metadata); + + $builder = new PromptBuilder($this->registry, 'Convert to speech'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "test-model" does not support text-to-speech conversion'); + + $builder->convertTextToSpeechResult(); + } + + /** + * Tests generateText method. + * + * @return void + */ + public function testGenerateText(): void + { + $messagePart = new MessagePart('Generated text content'); + $message = new ModelMessage([$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $text = $builder->generateText(); + $this->assertEquals('Generated text content', $text); + } + + /** + * Tests generateText throws exception when no candidates. + * + * @return void + */ + public function testGenerateTextThrowsExceptionWhenNoCandidates(): void + { + // Since GenerativeAiResult constructor requires at least one candidate, + // we need to create a mock that throws an exception or test a different scenario + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = new class ($metadata) implements ModelInterface, TextGenerationModelInterface { + private ModelMetadata $metadata; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata) + { + $this->metadata = $metadata; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + throw new RuntimeException('No candidates were generated'); + } + + public function streamGenerateTextResult(array $prompt): Generator + { + yield from []; + } + }; + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No candidates were generated'); + + $builder->generateText(); + } + + /** + * Tests generateText throws exception when message has no parts. + * + * @return void + */ + public function testGenerateTextThrowsExceptionWhenNoParts(): void + { + $message = new ModelMessage([]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $builder->generateText(); + } + + /** + * Tests generateText throws exception when part has no text. + * + * @return void + */ + public function testGenerateTextThrowsExceptionWhenPartHasNoText(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $messagePart = new MessagePart($file); + $message = new ModelMessage([$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $builder->generateText(); + } + + /** + * Tests generateTexts method. + * + * @return void + */ + public function testGenerateTexts(): void + { + $candidates = [ + new Candidate( + new ModelMessage([new MessagePart('Text 1')]), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage([new MessagePart('Text 2')]), + FinishReasonEnum::stop() + ), + new Candidate( + new ModelMessage([new MessagePart('Text 3')]), + FinishReasonEnum::stop() + ) + ]; + + $result = new GenerativeAiResult('test-result-id', $candidates, new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate texts'); + $builder->usingModel($model); + + $texts = $builder->generateTexts(3); + + $this->assertCount(3, $texts); + $this->assertEquals('Text 1', $texts[0]); + $this->assertEquals('Text 2', $texts[1]); + $this->assertEquals('Text 3', $texts[2]); + + // Verify candidate count was set + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertEquals(3, $config->getCandidateCount()); + } + + /** + * Tests generateTexts throws exception when no text generated. + * + * @return void + */ + public function testGenerateTextsThrowsExceptionWhenNoTextGenerated(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = new class ($metadata) implements ModelInterface, TextGenerationModelInterface { + private ModelMetadata $metadata; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata) + { + $this->metadata = $metadata; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + throw new RuntimeException('No text was generated from any candidates'); + } + + public function streamGenerateTextResult(array $prompt): Generator + { + yield from []; + } + }; + + $builder = new PromptBuilder($this->registry, 'Generate texts'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text was generated from any candidates'); + + $builder->generateTexts(); + } + + /** + * Tests generateImage method. + * + * @return void + */ + public function testGenerateImage(): void + { + $file = new File('https://example.com/generated.jpg', 'image/jpeg'); + $messagePart = new MessagePart($file); + $message = new ModelMessage([$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate image'); + $builder->usingModel($model); + + $generatedFile = $builder->generateImage(); + $this->assertSame($file, $generatedFile); + } + + /** + * Tests generateImage throws exception when no image file. + * + * @return void + */ + public function testGenerateImageThrowsExceptionWhenNoFile(): void + { + $messagePart = new MessagePart('Text instead of image'); + $message = new ModelMessage([$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate image'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No file content found in first candidate'); + + $builder->generateImage(); + } + + /** + * Tests generateImages method. + * + * @return void + */ + public function testGenerateImages(): void + { + $files = [ + new File('https://example.com/img1.jpg', 'image/jpeg'), + new File('https://example.com/img2.jpg', 'image/jpeg'), + ]; + + $candidates = []; + foreach ($files as $file) { + $candidates[] = new Candidate( + new Message(MessageRoleEnum::model(), [new MessagePart($file)]), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult('test-result-id', $candidates, new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate images'); + $builder->usingModel($model); + + $generatedFiles = $builder->generateImages(2); + + $this->assertCount(2, $generatedFiles); + $this->assertSame($files[0], $generatedFiles[0]); + $this->assertSame($files[1], $generatedFiles[1]); + } + + /** + * Tests convertTextToSpeech method. + * + * @return void + */ + public function testConvertTextToSpeech(): void + { + $file = new File('https://example.com/audio.mp3', 'audio/mp3'); + $messagePart = new MessagePart($file); + $message = new Message(MessageRoleEnum::model(), [$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextToSpeechModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Convert this text'); + $builder->usingModel($model); + + $audioFile = $builder->convertTextToSpeech(); + $this->assertSame($file, $audioFile); + } + + /** + * Tests convertTextToSpeeches method. + * + * @return void + */ + public function testConvertTextToSpeeches(): void + { + $files = [ + new File('https://example.com/audio1.mp3', 'audio/mp3'), + new File('https://example.com/audio2.mp3', 'audio/mp3'), + ]; + + $candidates = []; + foreach ($files as $file) { + $candidates[] = new Candidate( + new Message(MessageRoleEnum::model(), [new MessagePart($file)]), + FinishReasonEnum::stop() + ); + } + + $result = new GenerativeAiResult('test-result-id', $candidates, new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextToSpeechModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Convert this text'); + $builder->usingModel($model); + + $audioFiles = $builder->convertTextToSpeeches(2); + + $this->assertCount(2, $audioFiles); + $this->assertSame($files[0], $audioFiles[0]); + $this->assertSame($files[1], $audioFiles[1]); + } + + /** + * Tests generateSpeech method. + * + * @return void + */ + public function testGenerateSpeech(): void + { + $file = new File('https://example.com/speech.mp3', 'audio/mp3'); + $messagePart = new MessagePart($file); + $message = new Message(MessageRoleEnum::model(), [$messagePart]); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createSpeechGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate speech'); + $builder->usingModel($model); + + $speechFile = $builder->generateSpeech(); + $this->assertSame($file, $speechFile); + } + + /** + * Tests generateSpeeches method. + * + * @return void + */ + public function testGenerateSpeeches(): void + { + $files = [ + new File('https://example.com/speech1.mp3', 'audio/mp3'), + new File('https://example.com/speech2.mp3', 'audio/mp3'), + new File('https://example.com/speech3.mp3', 'audio/mp3'), + ]; + + $candidates = []; + foreach ($files as $file) { + $candidates[] = new Candidate( + new Message(MessageRoleEnum::model(), [new MessagePart($file)]), + FinishReasonEnum::stop(), + 10 + ); + } + + $result = new GenerativeAiResult( + 'test-result-id', + $candidates, + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createSpeechGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate speech'); + $builder->usingModel($model); + + $speechFiles = $builder->generateSpeeches(3); + + $this->assertCount(3, $speechFiles); + $this->assertSame($files[0], $speechFiles[0]); + $this->assertSame($files[1], $speechFiles[1]); + $this->assertSame($files[2], $speechFiles[2]); + } + + /** + * Tests appendPartToMessages creates new user message when empty. + * + * @return void + */ + public function testAppendPartToMessagesCreatesNewUserMessage(): void + { + $builder = new PromptBuilder($this->registry); + + $reflection = new \ReflectionClass($builder); + $method = $reflection->getMethod('appendPartToMessages'); + $method->setAccessible(true); + + $part = new MessagePart('Test part'); + $method->invoke($builder, $part); + + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(Message::class, $messages[0]); + $this->assertEquals('Test part', $messages[0]->getParts()[0]->getText()); + } + + /** + * Tests appendPartToMessages appends to existing user message. + * + * @return void + */ + public function testAppendPartToMessagesAppendsToExistingUserMessage(): void + { + $builder = new PromptBuilder($this->registry, 'Initial'); + + $reflection = new \ReflectionClass($builder); + $method = $reflection->getMethod('appendPartToMessages'); + $method->setAccessible(true); + + $part = new MessagePart('Additional'); + $method->invoke($builder, $part); + + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $parts = $messages[0]->getParts(); + $this->assertCount(2, $parts); + $this->assertEquals('Initial', $parts[0]->getText()); + $this->assertEquals('Additional', $parts[1]->getText()); + } + + /** + * Tests appendPartToMessages creates new message when last is model message. + * + * @return void + */ + public function testAppendPartToMessagesCreatesNewMessageWhenLastIsModel(): void + { + $builder = new PromptBuilder($this->registry, [ + new UserMessage([new MessagePart('User')]), + new ModelMessage([new MessagePart('Model')]) + ]); + + $reflection = new \ReflectionClass($builder); + $method = $reflection->getMethod('appendPartToMessages'); + $method->setAccessible(true); + + $part = new MessagePart('New user message'); + $method->invoke($builder, $part); + + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(3, $messages); + $this->assertInstanceOf(Message::class, $messages[2]); + $this->assertEquals('New user message', $messages[2]->getParts()[0]->getText()); + } + + /** + * Tests complex multimodal prompt building. + * + * @return void + */ + 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')]) + ) + ->withText(' Final instruction'); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + // Should have 3 messages: 2 from history + 1 current being built + $this->assertCount(3, $messages); + + // Check history messages (now at the beginning) + $this->assertEquals('Previous question', $messages[0]->getParts()[0]->getText()); + $this->assertEquals('Previous answer', $messages[1]->getParts()[0]->getText()); + + // 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->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()); + } + + /** + * Tests that withHistory prepends messages to the beginning. + * + * @return void + */ + public function testWithHistoryPrependsMessages(): void + { + $builder = new PromptBuilder($this->registry); + + // Start building current message + $builder->withText('Current message content'); + + // Add history + $builder->withHistory( + new UserMessage([new MessagePart('First history message')]), + new ModelMessage([new MessagePart('Second history message')]) + ); + + // Add more to current message + $builder->withText(' with additional content'); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + // Should have 3 messages: 2 history + 1 current + $this->assertCount(3, $messages); + + // History should be at the beginning + $this->assertTrue($messages[0]->getRole()->isUser()); + $this->assertEquals('First history message', $messages[0]->getParts()[0]->getText()); + + $this->assertTrue($messages[1]->getRole()->isModel()); + $this->assertEquals('Second history message', $messages[1]->getParts()[0]->getText()); + + // Current message should be at the end + $this->assertTrue($messages[2]->getRole()->isUser()); + $currentParts = $messages[2]->getParts(); + $this->assertCount(2, $currentParts); + $this->assertEquals('Current message content', $currentParts[0]->getText()); + $this->assertEquals(' with additional content', $currentParts[1]->getText()); + } + + /** + * Tests includeOutputModality preserves existing modalities. + * + * @return void + */ + public function testIncludeOutputModalityPreservesExisting(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate(new ModelMessage([new MessagePart('Generated text')]), FinishReasonEnum::stop())], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Test'); + $builder->usingModel($model); + + // Set initial modality + $builder->asOutputModalities(ModalityEnum::audio()); + + // Generate text should add text modality, not replace audio + $builder->generateTextResult(); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $modalities = $config->getOutputModalities(); + $this->assertCount(2, $modalities); + $this->assertTrue($modalities[0]->isAudio()); + $this->assertTrue($modalities[1]->isText()); + } + + /** + * Tests constructor with list of string parts. + * + * @return void + */ + public function testConstructorWithStringPartsList(): void + { + $builder = new PromptBuilder($this->registry, ['Part 1', 'Part 2', 'Part 3']); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $this->assertInstanceOf(Message::class, $messages[0]); + $parts = $messages[0]->getParts(); + $this->assertCount(3, $parts); + $this->assertEquals('Part 1', $parts[0]->getText()); + $this->assertEquals('Part 2', $parts[1]->getText()); + $this->assertEquals('Part 3', $parts[2]->getText()); + } + + /** + * Tests constructor with mixed parts list. + * + * @return void + */ + public function testConstructorWithMixedPartsList(): void + { + $part1 = new MessagePart('Part 1'); + $part2Array = ['type' => 'text', 'text' => 'Part 2']; + + $builder = new PromptBuilder($this->registry, ['String part', $part1, $part2Array]); + + $reflection = new \ReflectionClass($builder); + $messagesProperty = $reflection->getProperty('messages'); + $messagesProperty->setAccessible(true); + /** @var list $messages */ + $messages = $messagesProperty->getValue($builder); + + $this->assertCount(1, $messages); + $parts = $messages[0]->getParts(); + $this->assertCount(3, $parts); + $this->assertEquals('String part', $parts[0]->getText()); + $this->assertEquals('Part 1', $parts[1]->getText()); + $this->assertEquals('Part 2', $parts[2]->getText()); + } + + /** + * Tests parseMessage with non-list array throws exception. + * + * @return void + */ + public function testParseMessageWithNonListArrayThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array input must be a list array'); + + new PromptBuilder($this->registry, ['key' => 'value']); + } + + /** + * Tests parseMessage with invalid array item throws exception. + * + * @return void + */ + public function testParseMessageWithInvalidArrayItemThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array items must be strings, MessagePart instances, or MessagePartArrayShape'); + + new PromptBuilder($this->registry, ['valid string', 123, 'another string']); + } + + /** + * Tests last message must have parts validation. + * + * @return void + */ + public function testValidateMessagesLastMessageMustHaveParts(): void + { + // Create a message with empty parts + $emptyMessage = new UserMessage([]); + + $builder = new PromptBuilder($this->registry, [ + new UserMessage([new MessagePart('First')]), + new ModelMessage([new MessagePart('Response')]), + $emptyMessage + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The last message must have content parts'); + + $builder->generateResult(); + } + + /** + * Tests generateImageResult method creates proper operation. + * + * @return void + */ + public function testGenerateImageResultCreatesProperOperation(): void + { + $result = new GenerativeAiResult( + 'test-result', + [new Candidate( + new ModelMessage([new MessagePart(new File('data:image/png;base64,iVBORw0KGgo=', 'image/png'))]), + FinishReasonEnum::stop() + )], + new TokenUsage(100, 50, 150) + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate an image'); + $builder->usingModel($model); + + $actualResult = $builder->generateImageResult(); + $this->assertSame($result, $actualResult); + + // Verify that image modality was included in the model config + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $outputModalities = $config->getOutputModalities(); + $this->assertCount(1, $outputModalities); + $this->assertTrue($outputModalities[0]->isImage()); + } + + /** + * Tests generateAudioResult method creates proper operation. + * + * @return void + */ + public function testGenerateAudioResultCreatesProperOperation(): void + { + $this->markTestSkipped('generateAudioResult method does not exist yet'); + } + + /** + * Tests generateVideoResult method creates proper operation. + * + * @return void + */ + public function testGenerateVideoResultCreatesProperOperation(): void + { + $this->markTestSkipped('generateVideoResult method does not exist yet'); + } + + /** + * Tests generateImage shorthand method returns file directly. + * + * @return void + */ + public function testGenerateImageReturnsFileDirectly(): void + { + $file = new File('https://example.com/generated.jpg', 'image/jpeg'); + $candidate = new Candidate( + new Message(MessageRoleEnum::model(), [new MessagePart($file)]), + FinishReasonEnum::stop() + ); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createImageGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate an image'); + $builder->usingModel($model); + + $generatedFile = $builder->generateImage(); + $this->assertSame($file, $generatedFile); + } + + /** + * Tests generateAudio shorthand method returns file directly. + * + * @return void + */ + public function testGenerateAudioReturnsFileDirectly(): void + { + $this->markTestSkipped('generateAudio method does not exist yet'); + } + + /** + * Tests generateVideo shorthand method returns file directly. + * + * @return void + */ + public function testGenerateVideoReturnsFileDirectly(): void + { + $this->markTestSkipped('generateVideo method does not exist yet'); + } + + /** + * Tests generation method with multiple output modalities. + * + * @return void + */ + public function testGenerationWithMultipleOutputModalities(): void + { + $this->markTestSkipped('Operations-based generation not implemented yet'); + } + + /** + * Tests streaming generation methods. + * + * @return void + */ + public function testStreamingGenerationMethods(): void + { + $this->markTestSkipped('Streaming methods do not exist yet'); + } + + /** + * Tests generateText with no candidates throws exception. + * + * @return void + */ + public function testGenerateTextWithNoCandidatesThrowsException(): void + { + // Create a mock result that throws when toText is called + $result = $this->createMock(GenerativeAiResult::class); + $result->method('toText')->willThrowException(new RuntimeException('No text content found in first candidate')); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $builder->generateText(); + } + + /** + * Tests generateText with non-string part throws exception. + * + * @return void + */ + public function testGenerateTextWithNonStringPartThrowsException(): void + { + $file = new File('https://example.com/file.jpg', 'image/jpeg'); + $candidate = new Candidate( + new Message(MessageRoleEnum::model(), [new MessagePart($file)]), + FinishReasonEnum::stop() + ); + + $result = new GenerativeAiResult('test-result', [$candidate], new TokenUsage(100, 50, 150)); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('test-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate text'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $builder->generateText(); + } + + /** + * Tests chain generation with multiple prompts. + * + * @return void + */ + public function testChainGenerationWithMultiplePrompts(): void + { + $this->markTestSkipped('Complex chaining with model response methods not fully implemented yet'); + } + + + /** + * Tests isSupportedForText convenience method. + * + * @return void + */ + public function testIsSupportedForText(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('text-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $result = new GenerativeAiResult('test-id', [ + new Candidate( + new ModelMessage([new MessagePart('Test')]), + FinishReasonEnum::stop() + ) + ], new TokenUsage(10, 5, 15)); + + $model = $this->createTextGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModel($model); + + $this->assertTrue($builder->isSupportedForTextGeneration()); + } + + /** + * Tests isSupportedForImageGeneration convenience method. + * + * @return void + */ + public function testIsSupportedForImageGeneration(): void + { + $builder = new PromptBuilder($this->registry, 'Generate an image'); + + // Mock registry to return no models for image generation + $this->registry->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->assertFalse($builder->isSupportedForImageGeneration()); + } + + /** + * Tests isSupportedForTextToSpeechConversion convenience method. + * + * @return void + */ + public function testIsSupportedForTextToSpeechConversion(): void + { + $builder = new PromptBuilder($this->registry, 'Generate audio'); + + // Mock registry to return no models for text to speech conversion + $this->registry->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->assertFalse($builder->isSupportedForTextToSpeechConversion()); + } + + /** + * Tests isSupportedForVideoGeneration convenience method. + * + * @return void + */ + public function testIsSupportedForVideoGeneration(): void + { + $builder = new PromptBuilder($this->registry, 'Generate video'); + + // Mock registry to return no models for video generation + $this->registry->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->assertFalse($builder->isSupportedForVideoGeneration()); + } + + /** + * Tests isSupportedForSpeechGeneration convenience method. + * + * @return void + */ + public function testIsSupportedForSpeechGeneration(): void + { + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getId')->willReturn('speech-model'); + $metadata->method('meetsRequirements')->willReturn(true); + + $result = new GenerativeAiResult('test-id', [ + new Candidate( + new ModelMessage([new MessagePart(new File('https://example.com/speech.mp3', 'audio/mp3'))]), + FinishReasonEnum::stop() + ) + ], new TokenUsage(10, 5, 15)); + + $model = $this->createSpeechGenerationModel($metadata, $result); + + $builder = new PromptBuilder($this->registry, 'Generate speech'); + $builder->usingModel($model); + + $this->assertTrue($builder->isSupportedForSpeechGeneration()); + } +} diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index 9ed4e6d7..84d6adcf 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -405,4 +405,168 @@ public function testImplementsWithArrayTransformationInterface(): void $file ); } + + /** + * Tests isImage passthrough method. + * + * @return void + */ + public function testIsImage(): void + { + $imageFile = new File('https://example.com/test.jpg'); + $this->assertTrue($imageFile->isImage()); + + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $pngFile = new File('data:image/png;base64,' . $pngBase64); + $this->assertTrue($pngFile->isImage()); + + $textFile = new File('https://example.com/test.txt', 'text/plain'); + $this->assertFalse($textFile->isImage()); + } + + /** + * Tests isVideo passthrough method. + * + * @return void + */ + public function testIsVideo(): void + { + $videoFile = new File('https://example.com/test.mp4'); + $this->assertTrue($videoFile->isVideo()); + + $aviFile = new File('https://example.com/test.avi'); + $this->assertTrue($aviFile->isVideo()); + + $imageFile = new File('https://example.com/test.jpg'); + $this->assertFalse($imageFile->isVideo()); + } + + /** + * Tests isAudio passthrough method. + * + * @return void + */ + public function testIsAudio(): void + { + $audioFile = new File('https://example.com/test.mp3'); + $this->assertTrue($audioFile->isAudio()); + + $wavFile = new File('https://example.com/test.wav'); + $this->assertTrue($wavFile->isAudio()); + + $imageFile = new File('https://example.com/test.jpg'); + $this->assertFalse($imageFile->isAudio()); + } + + /** + * Tests isText passthrough method. + * + * @return void + */ + public function testIsText(): void + { + $textFile = new File('https://example.com/test.txt'); + $this->assertTrue($textFile->isText()); + + $csvFile = new File('https://example.com/test.csv'); + $this->assertTrue($csvFile->isText()); + + $htmlFile = new File('https://example.com/test.html'); + $this->assertTrue($htmlFile->isText()); + + $imageFile = new File('https://example.com/test.jpg'); + $this->assertFalse($imageFile->isText()); + } + + /** + * Tests isDocument passthrough method. + * + * @return void + */ + public function testIsDocument(): void + { + $pdfFile = new File('https://example.com/test.pdf'); + $this->assertTrue($pdfFile->isDocument()); + + $docFile = new File('https://example.com/test.doc'); + $this->assertTrue($docFile->isDocument()); + + $docxFile = new File('https://example.com/test.docx'); + $this->assertTrue($docxFile->isDocument()); + + $imageFile = new File('https://example.com/test.jpg'); + $this->assertFalse($imageFile->isDocument()); + + $audioFile = new File('https://example.com/test.mp3'); + $this->assertFalse($audioFile->isDocument()); + } + + /** + * Tests isMimeType passthrough method. + * + * @return void + */ + public function testIsMimeType(): void + { + $imageFile = new File('https://example.com/test.jpg'); + $this->assertTrue($imageFile->isMimeType('image')); + $this->assertFalse($imageFile->isMimeType('video')); + + $videoFile = new File('https://example.com/test.mp4'); + $this->assertTrue($videoFile->isMimeType('video')); + $this->assertFalse($videoFile->isMimeType('audio')); + + $textFile = new File('https://example.com/test.txt'); + $this->assertTrue($textFile->isMimeType('text')); + $this->assertFalse($textFile->isMimeType('image')); + } + + /** + * Tests isInline method for inline files. + * + * @return void + */ + public function testIsInlineForInlineFiles(): void + { + // Test with base64 data + $base64File = new File('SGVsbG8gV29ybGQ=', 'text/plain'); + $this->assertTrue($base64File->isInline()); + $this->assertFalse($base64File->isRemote()); + + // Test with data URI + $dataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJA' + . 'AAADUJEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $dataUriFile = new File($dataUri); + $this->assertTrue($dataUriFile->isInline()); + $this->assertFalse($dataUriFile->isRemote()); + + // Test with local file (becomes inline) + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'test content'); + try { + $localFile = new File($tempFile, 'text/plain'); + $this->assertTrue($localFile->isInline()); + $this->assertFalse($localFile->isRemote()); + } finally { + unlink($tempFile); + } + } + + /** + * Tests isRemote method for remote files. + * + * @return void + */ + public function testIsRemoteForRemoteFiles(): void + { + // Test with URL + $urlFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $this->assertTrue($urlFile->isRemote()); + $this->assertFalse($urlFile->isInline()); + + // Test with URL without explicit MIME type + $urlFileNoMime = new File('https://example.com/document.pdf'); + $this->assertTrue($urlFileNoMime->isRemote()); + $this->assertFalse($urlFileNoMime->isInline()); + } } diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php index c5a26105..db77cad8 100644 --- a/tests/unit/Messages/DTO/MessageTest.php +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -10,6 +10,7 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -18,6 +19,8 @@ */ class MessageTest extends TestCase { + use ArrayTransformationTestTrait; + /** * Tests creating Message with single text part. * @@ -105,28 +108,40 @@ public function roleProvider(): array */ public function testComplexMessageWithAllPartTypes(): void { - $role = MessageRoleEnum::model(); + // Test with user role since it can have function responses but not function calls + $role = MessageRoleEnum::user(); $parts = [ - new MessagePart('I\'ll help you with that. Let me search for the information.'), - new MessagePart(new FunctionCall('search_123', 'webSearch', ['query' => 'latest PHP news'])), + new MessagePart('I need help with searching.'), new MessagePart(new FunctionResponse('search_123', 'webSearch', ['results' => ['item1', 'item2']])), - new MessagePart('Based on my search, here are the latest PHP news:'), + new MessagePart('Here is additional information:'), new MessagePart(new File('data:text/plain;base64,SGVsbG8=', 'text/plain')), ]; $message = new Message($role, $parts); - $this->assertCount(5, $message->getParts()); + $this->assertCount(4, $message->getParts()); // Verify each part type $this->assertEquals( - 'I\'ll help you with that. Let me search for the information.', + 'I need help with searching.', $message->getParts()[0]->getText() ); - $this->assertInstanceOf(FunctionCall::class, $message->getParts()[1]->getFunctionCall()); - $this->assertInstanceOf(FunctionResponse::class, $message->getParts()[2]->getFunctionResponse()); - $this->assertEquals('Based on my search, here are the latest PHP news:', $message->getParts()[3]->getText()); - $this->assertInstanceOf(File::class, $message->getParts()[4]->getFile()); + $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()); + + // Also test model role with function calls + $modelRole = MessageRoleEnum::model(); + $modelParts = [ + new MessagePart('I\'ll help you with that. Let me search for the information.'), + new MessagePart(new FunctionCall('search_123', 'webSearch', ['query' => 'latest PHP news'])), + new MessagePart('Based on my search, here are the latest PHP news:'), + ]; + + $modelMessage = new Message($modelRole, $modelParts); + + $this->assertCount(3, $modelMessage->getParts()); + $this->assertInstanceOf(FunctionCall::class, $modelMessage->getParts()[1]->getFunctionCall()); } /** @@ -186,6 +201,51 @@ public function testMessageWithManyParts(): void $this->assertEquals('Part number 99', $message->getParts()[99]->getText()); } + /** + * Tests isArrayShape validation. + * + * @return void + */ + public function testIsArrayShapeValidation(): void + { + $validArray = [ + Message::KEY_ROLE => MessageRoleEnum::user()->value, + Message::KEY_PARTS => [ + [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Test message' + ] + ] + ]; + + $invalidArrays = [ + 'missing role' => [ + Message::KEY_PARTS => [ + [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Test message' + ] + ] + ], + 'missing parts' => [ + Message::KEY_ROLE => MessageRoleEnum::user()->value + ], + 'invalid role value' => [ + Message::KEY_ROLE => 'invalid_role', + Message::KEY_PARTS => [ + [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Test message' + ] + ] + ], + 'empty array' => [], + 'non-associative array' => ['user', 'parts'] + ]; + + $this->assertIsArrayShapeValidation(Message::class, $validArray, $invalidArrays); + } + /** * Tests preserving part order. * @@ -210,13 +270,13 @@ public function testPreservesPartOrder(): void } /** - * Tests model message with function response. + * Tests that user message can have function response. * * @return void */ - public function testModelMessageWithFunctionResponse(): void + public function testUserMessageWithFunctionResponse(): void { - $role = MessageRoleEnum::model(); + $role = MessageRoleEnum::user(); $functionResponse = new FunctionResponse( 'calc_123', 'calculate', @@ -226,7 +286,7 @@ public function testModelMessageWithFunctionResponse(): void $message = new Message($role, [$part]); - $this->assertTrue($message->getRole()->isModel()); + $this->assertTrue($message->getRole()->isUser()); $this->assertNotNull($message->getParts()[0]->getFunctionResponse()); } @@ -319,4 +379,133 @@ public function testImplementsWithArrayTransformationInterface(): void $message ); } + + /** + * Tests that withPart creates a new instance with the part appended. + * + * @since n.e.x.t + */ + public function testWithPartCreatesNewInstance(): void + { + $original = new Message( + MessageRoleEnum::user(), + [new MessagePart('Original text')] + ); + + $newPart = new MessagePart('Additional text'); + $updated = $original->withPart($newPart); + + // Assert that a new instance was created + $this->assertNotSame($original, $updated); + + // Assert original is unchanged + $this->assertCount(1, $original->getParts()); + $this->assertEquals('Original text', $original->getParts()[0]->getText()); + + // Assert updated has both parts + $this->assertCount(2, $updated->getParts()); + $this->assertEquals('Original text', $updated->getParts()[0]->getText()); + $this->assertEquals('Additional text', $updated->getParts()[1]->getText()); + } + + /** + * Tests that user messages cannot contain function call parts. + * + * @return void + */ + public function testUserMessageCannotContainFunctionCall(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User messages cannot contain function calls.'); + + $functionCall = new FunctionCall('testFunc', 'test', ['param' => 'value']); + $part = new MessagePart($functionCall); + + new Message(MessageRoleEnum::user(), [$part]); + } + + /** + * Tests that model messages cannot contain function response parts. + * + * @return void + */ + public function testModelMessageCannotContainFunctionResponse(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Model messages cannot contain function responses.'); + + $functionResponse = new FunctionResponse('resp1', 'test', ['result' => 'value']); + $part = new MessagePart($functionResponse); + + new Message(MessageRoleEnum::model(), [$part]); + } + + /** + * Tests that user messages can contain function response parts. + * + * @return void + */ + public function testUserMessageCanContainFunctionResponse(): void + { + $functionResponse = new FunctionResponse('resp1', 'test', ['result' => 'value']); + $part = new MessagePart($functionResponse); + + $message = new Message(MessageRoleEnum::user(), [$part]); + + $this->assertCount(1, $message->getParts()); + $this->assertSame($functionResponse, $message->getParts()[0]->getFunctionResponse()); + } + + /** + * Tests that model messages can contain function call parts. + * + * @return void + */ + public function testModelMessageCanContainFunctionCall(): void + { + $functionCall = new FunctionCall('call1', 'test', ['param' => 'value']); + $part = new MessagePart($functionCall); + + $message = new Message(MessageRoleEnum::model(), [$part]); + + $this->assertCount(1, $message->getParts()); + $this->assertSame($functionCall, $message->getParts()[0]->getFunctionCall()); + } + + /** + * Tests that withPart validates the new part against the role. + * + * @return void + */ + public function testWithPartValidatesAgainstRole(): void + { + $message = new Message(MessageRoleEnum::user(), [new MessagePart('Initial text')]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User messages cannot contain function calls.'); + + $functionCall = new FunctionCall('call1', 'test', ['param' => 'value']); + $invalidPart = new MessagePart($functionCall); + + $message->withPart($invalidPart); + } + + /** + * Tests validation with multiple parts including invalid ones. + * + * @return void + */ + public function testValidationWithMixedParts(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('User messages cannot contain function calls.'); + + $parts = [ + new MessagePart('Text part'), + new MessagePart(new File('https://example.com/image.jpg', 'image/jpeg')), + new MessagePart(new FunctionCall('call1', 'test', [])), // Invalid for user role + ]; + + new Message(MessageRoleEnum::user(), $parts); + } } diff --git a/tests/unit/Messages/DTO/ModelMessageTest.php b/tests/unit/Messages/DTO/ModelMessageTest.php index f315defe..c7c25e25 100644 --- a/tests/unit/Messages/DTO/ModelMessageTest.php +++ b/tests/unit/Messages/DTO/ModelMessageTest.php @@ -12,7 +12,6 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionCall; -use WordPress\AiClient\Tools\DTO\FunctionResponse; /** * @covers \WordPress\AiClient\Messages\DTO\ModelMessage @@ -92,13 +91,12 @@ public function testWithVariousContentTypes(): void { $file = new File('https://example.com/image.jpg', 'image/jpeg'); $functionCall = new FunctionCall('func_123', 'search', ['q' => 'test']); - $functionResponse = new FunctionResponse('func_123', 'search', ['results' => []]); $parts = [ new MessagePart('I found the following:'), new MessagePart($file), new MessagePart($functionCall), - new MessagePart($functionResponse), + new MessagePart('Here are the results based on my search.'), ]; $message = new ModelMessage($parts); @@ -106,7 +104,7 @@ public function testWithVariousContentTypes(): void $this->assertEquals('I found the following:', $message->getParts()[0]->getText()); $this->assertSame($file, $message->getParts()[1]->getFile()); $this->assertSame($functionCall, $message->getParts()[2]->getFunctionCall()); - $this->assertSame($functionResponse, $message->getParts()[3]->getFunctionResponse()); + $this->assertEquals('Here are the results based on my search.', $message->getParts()[3]->getText()); } /** diff --git a/tests/unit/Messages/DTO/UserMessageTest.php b/tests/unit/Messages/DTO/UserMessageTest.php index cc811243..5b5df92f 100644 --- a/tests/unit/Messages/DTO/UserMessageTest.php +++ b/tests/unit/Messages/DTO/UserMessageTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -310,4 +311,23 @@ public function testImplementsWithArrayTransformationInterface(): void $message = new UserMessage([new MessagePart('test')]); $this->assertImplementsArrayTransformation($message); } + + /** + * Tests that withPart returns a new Message with user role. + * + * @since n.e.x.t + */ + public function testWithPartReturnsNewMessage(): void + { + $original = new UserMessage([new MessagePart('User text')]); + $updated = $original->withPart(new MessagePart('More text')); + + $this->assertInstanceOf(Message::class, $updated); + $this->assertNotSame($original, $updated); + $this->assertCount(2, $updated->getParts()); + $this->assertEquals(MessageRoleEnum::user(), $updated->getRole()); + $this->assertTrue($updated->getRole()->isUser()); + $this->assertEquals('User text', $updated->getParts()[0]->getText()); + $this->assertEquals('More text', $updated->getParts()[1]->getText()); + } } diff --git a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php index 0141cc60..cd46472e 100644 --- a/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php +++ b/tests/unit/Providers/DTO/ProviderModelsMetadataTest.php @@ -12,6 +12,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * @covers \WordPress\AiClient\Providers\DTO\ProviderModelsMetadata @@ -41,7 +42,7 @@ private function createModelMetadata(string $id, string $name): ModelMetadata $id, $name, [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], - [new SupportedOption('temperature', [0.0, 0.5, 1.0, 1.5, 2.0])] + [new SupportedOption(OptionEnum::temperature(), [0.0, 0.5, 1.0, 1.5, 2.0])] ); } @@ -178,7 +179,7 @@ public function testFromArray(): void ModelMetadata::KEY_SUPPORTED_CAPABILITIES => ['text_generation', 'chat_history'], ModelMetadata::KEY_SUPPORTED_OPTIONS => [ [ - SupportedOption::KEY_NAME => 'max_tokens', + SupportedOption::KEY_NAME => OptionEnum::maxTokens()->value, SupportedOption::KEY_SUPPORTED_VALUES => [100, 1000, 10000] ] ] @@ -295,15 +296,15 @@ public function testWithMultipleModelsAndCapabilities(): void CapabilityEnum::chatHistory() ], [ - new SupportedOption('resolution', ['256x256', '512x512', '1024x1024']), - new SupportedOption('style', ['realistic', 'artistic', 'cartoon']) + new SupportedOption(OptionEnum::outputSchema(), ['256x256', '512x512', '1024x1024']), + new SupportedOption(OptionEnum::outputSchema(), ['realistic', 'artistic', 'cartoon']) ] ), new ModelMetadata( 'embedding', 'Embedding Model', [CapabilityEnum::embeddingGeneration()], - [new SupportedOption('dimensions', [256, 512, 1024])] + [new SupportedOption(OptionEnum::outputSchema(), [256, 512, 1024])] ) ]; diff --git a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php index 7954d6f6..766e2dd1 100644 --- a/tests/unit/Providers/Models/DTO/ModelMetadataTest.php +++ b/tests/unit/Providers/Models/DTO/ModelMetadataTest.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * @covers \WordPress\AiClient\Providers\Models\DTO\ModelMetadata @@ -22,7 +23,7 @@ class ModelMetadataTest extends TestCase */ private function createSampleOption(): SupportedOption { - return new SupportedOption('temperature', [0.0, 0.5, 1.0, 1.5, 2.0]); + return new SupportedOption(OptionEnum::temperature(), [0.0, 0.5, 1.0, 1.5, 2.0]); } /** @@ -40,8 +41,8 @@ public function testConstructorAndGetters(): void CapabilityEnum::textGeneration() ]; $options = [ - new SupportedOption('temperature', [0.0, 0.7, 1.0, 2.0]), - new SupportedOption('max_tokens', [100, 1000, 4000]) + new SupportedOption(OptionEnum::temperature(), [0.0, 0.7, 1.0, 2.0]), + new SupportedOption(OptionEnum::maxTokens(), [100, 1000, 4000]) ]; $metadata = new ModelMetadata($id, $name, $capabilities, $options); @@ -137,8 +138,8 @@ public function testToArray(): void 'Claude 2', [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], [ - new SupportedOption('max_tokens', [100, 1000, 10000]), - new SupportedOption('temperature', [0.0, 1.0]) + new SupportedOption(OptionEnum::maxTokens(), [100, 1000, 10000]), + new SupportedOption(OptionEnum::temperature(), [0.0, 1.0]) ] ); @@ -149,12 +150,18 @@ public function testToArray(): void $this->assertEquals('Claude 2', $array[ModelMetadata::KEY_NAME]); $this->assertEquals(['text_generation', 'chat_history'], $array[ModelMetadata::KEY_SUPPORTED_CAPABILITIES]); $this->assertCount(2, $array[ModelMetadata::KEY_SUPPORTED_OPTIONS]); - $this->assertEquals('max_tokens', $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_NAME]); + $this->assertEquals( + OptionEnum::maxTokens()->value, + $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_NAME] + ); $this->assertEquals( [100, 1000, 10000], $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_SUPPORTED_VALUES] ); - $this->assertEquals('temperature', $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][1][SupportedOption::KEY_NAME]); + $this->assertEquals( + OptionEnum::temperature()->value, + $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][1][SupportedOption::KEY_NAME] + ); $this->assertEquals( [0.0, 1.0], $array[ModelMetadata::KEY_SUPPORTED_OPTIONS][1][SupportedOption::KEY_SUPPORTED_VALUES] @@ -174,11 +181,11 @@ public function testFromArray(): void ModelMetadata::KEY_SUPPORTED_CAPABILITIES => ['text_generation', 'chat_history', 'embedding_generation'], ModelMetadata::KEY_SUPPORTED_OPTIONS => [ [ - SupportedOption::KEY_NAME => 'temperature', + SupportedOption::KEY_NAME => OptionEnum::temperature()->value, SupportedOption::KEY_SUPPORTED_VALUES => [0.1, 0.5, 0.9] ], [ - SupportedOption::KEY_NAME => 'top_p', + SupportedOption::KEY_NAME => OptionEnum::topP()->value, SupportedOption::KEY_SUPPORTED_VALUES => [0.5, 0.9, 0.95] ] ] @@ -198,9 +205,9 @@ public function testFromArray(): void $options = $metadata->getSupportedOptions(); $this->assertCount(2, $options); - $this->assertEquals('temperature', $options[0]->getName()); + $this->assertEquals(OptionEnum::temperature()->value, $options[0]->getName()); $this->assertEquals([0.1, 0.5, 0.9], $options[0]->getSupportedValues()); - $this->assertEquals('top_p', $options[1]->getName()); + $this->assertEquals(OptionEnum::topP()->value, $options[1]->getName()); $this->assertEquals([0.5, 0.9, 0.95], $options[1]->getSupportedValues()); } @@ -220,8 +227,8 @@ public function testArrayRoundTrip(): void CapabilityEnum::textToSpeechConversion() ], [ - new SupportedOption('resolution', ['256x256', '512x512', '1024x1024']), - new SupportedOption('voice', ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']) + new SupportedOption(OptionEnum::outputSchema(), ['256x256', '512x512', '1024x1024']), + new SupportedOption(OptionEnum::outputSchema(), ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer']) ] ); @@ -258,7 +265,7 @@ public function testJsonSerialize(): void 'json-model', 'JSON Test Model', [CapabilityEnum::embeddingGeneration()], - [new SupportedOption('dimensions', [256, 512, 1024])] + [new SupportedOption(OptionEnum::outputSchema(), [256, 512, 1024])] ); $json = json_encode($metadata); @@ -270,7 +277,10 @@ public function testJsonSerialize(): void $this->assertEquals('JSON Test Model', $decoded[ModelMetadata::KEY_NAME]); $this->assertEquals(['embedding_generation'], $decoded[ModelMetadata::KEY_SUPPORTED_CAPABILITIES]); $this->assertCount(1, $decoded[ModelMetadata::KEY_SUPPORTED_OPTIONS]); - $this->assertEquals('dimensions', $decoded[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_NAME]); + $this->assertEquals( + OptionEnum::outputSchema()->value, + $decoded[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_NAME] + ); $this->assertEquals( [256, 512, 1024], $decoded[ModelMetadata::KEY_SUPPORTED_OPTIONS][0][SupportedOption::KEY_SUPPORTED_VALUES] @@ -322,13 +332,13 @@ public function testWithAllCapabilities(): void public function testWithComplexSupportedOptions(): void { $options = [ - new SupportedOption('string_values', ['option1', 'option2', 'option3']), - new SupportedOption('numeric_values', [1, 2, 3, 4, 5]), - new SupportedOption('float_values', [0.1, 0.5, 0.9]), - new SupportedOption('boolean_values', [true, false]), - new SupportedOption('mixed_values', ['text', 123, true, null]), - new SupportedOption('nested_arrays', [['a', 'b'], ['c', 'd']]), - new SupportedOption('objects', [['key' => 'value'], ['another' => 'object']]) + new SupportedOption(OptionEnum::outputSchema(), ['option1', 'option2', 'option3']), + new SupportedOption(OptionEnum::outputSchema(), [1, 2, 3, 4, 5]), + new SupportedOption(OptionEnum::temperature(), [0.1, 0.5, 0.9]), + new SupportedOption(OptionEnum::outputSchema(), [true, false]), + new SupportedOption(OptionEnum::outputSchema(), ['text', 123, true, null]), + new SupportedOption(OptionEnum::outputSchema(), [['a', 'b'], ['c', 'd']]), + new SupportedOption(OptionEnum::customOptions(), [['key' => 'value'], ['another' => 'object']]) ]; $metadata = new ModelMetadata('complex-model', 'Complex Model', [], $options); @@ -378,7 +388,7 @@ public function testSpecialCharactersInNames(): void 'special-model-123', 'Model with "quotes" & special ', [CapabilityEnum::textGeneration()], - [new SupportedOption('option_with_underscore', ['value'])] + [new SupportedOption(OptionEnum::outputSchema(), ['value'])] ); $array = $metadata->toArray(); @@ -404,8 +414,8 @@ public function testArrayValuesProperlyIndexed(): void CapabilityEnum::embeddingGeneration() ], [ - new SupportedOption('opt1', [1, 2, 3]), - new SupportedOption('opt2', ['a', 'b', 'c']) + new SupportedOption(OptionEnum::maxTokens(), [1, 2, 3]), + new SupportedOption(OptionEnum::outputSchema(), ['a', 'b', 'c']) ] ); diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 706859a5..21f71b2b 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * @covers \WordPress\AiClient\Providers\Models\DTO\ModelRequirements @@ -27,8 +28,8 @@ public function testConstructorAndGetters(): void CapabilityEnum::chatHistory() ]; $options = [ - new RequiredOption('temperature', 0.7), - new RequiredOption('max_tokens', 1000) + new RequiredOption(OptionEnum::temperature(), 0.7), + new RequiredOption(OptionEnum::maxTokens(), 1000) ]; $requirements = new ModelRequirements($capabilities, $options); @@ -109,8 +110,8 @@ public function testToArray(): void $requirements = new ModelRequirements( [CapabilityEnum::imageGeneration(), CapabilityEnum::textGeneration()], [ - new RequiredOption('resolution', '1024x1024'), - new RequiredOption('style', 'realistic') + new RequiredOption(OptionEnum::outputSchema(), '1024x1024'), + new RequiredOption(OptionEnum::outputSchema(), 'realistic') ] ); @@ -122,9 +123,15 @@ public function testToArray(): void $array[ModelRequirements::KEY_REQUIRED_CAPABILITIES] ); $this->assertCount(2, $array[ModelRequirements::KEY_REQUIRED_OPTIONS]); - $this->assertEquals('resolution', $array[ModelRequirements::KEY_REQUIRED_OPTIONS][0][RequiredOption::KEY_NAME]); + $this->assertEquals( + OptionEnum::outputSchema()->value, + $array[ModelRequirements::KEY_REQUIRED_OPTIONS][0][RequiredOption::KEY_NAME] + ); $this->assertEquals('1024x1024', $array[ModelRequirements::KEY_REQUIRED_OPTIONS][0][RequiredOption::KEY_VALUE]); - $this->assertEquals('style', $array[ModelRequirements::KEY_REQUIRED_OPTIONS][1][RequiredOption::KEY_NAME]); + $this->assertEquals( + OptionEnum::outputSchema()->value, + $array[ModelRequirements::KEY_REQUIRED_OPTIONS][1][RequiredOption::KEY_NAME] + ); $this->assertEquals('realistic', $array[ModelRequirements::KEY_REQUIRED_OPTIONS][1][RequiredOption::KEY_VALUE]); } @@ -139,11 +146,11 @@ public function testFromArray(): void ModelRequirements::KEY_REQUIRED_CAPABILITIES => ['text_generation', 'chat_history', 'embedding_generation'], ModelRequirements::KEY_REQUIRED_OPTIONS => [ [ - RequiredOption::KEY_NAME => 'response_format', + RequiredOption::KEY_NAME => OptionEnum::outputSchema()->value, RequiredOption::KEY_VALUE => ['type' => 'json_object'] ], [ - RequiredOption::KEY_NAME => 'temperature', + RequiredOption::KEY_NAME => OptionEnum::temperature()->value, RequiredOption::KEY_VALUE => 0.5 ] ] @@ -161,9 +168,9 @@ public function testFromArray(): void $options = $requirements->getRequiredOptions(); $this->assertCount(2, $options); - $this->assertEquals('response_format', $options[0]->getName()); + $this->assertEquals(OptionEnum::outputSchema()->value, $options[0]->getName()); $this->assertEquals(['type' => 'json_object'], $options[0]->getValue()); - $this->assertEquals('temperature', $options[1]->getName()); + $this->assertEquals(OptionEnum::temperature()->value, $options[1]->getName()); $this->assertEquals(0.5, $options[1]->getValue()); } @@ -181,9 +188,9 @@ public function testArrayRoundTrip(): void CapabilityEnum::musicGeneration() ], [ - new RequiredOption('voice', 'alloy'), - new RequiredOption('language', 'en-US'), - new RequiredOption('sample_rate', 44100) + new RequiredOption(OptionEnum::outputSchema(), 'alloy'), + new RequiredOption(OptionEnum::outputSchema(), 'en-US'), + new RequiredOption(OptionEnum::outputSchema(), 44100) ] ); @@ -215,7 +222,7 @@ public function testJsonSerialize(): void { $requirements = new ModelRequirements( [CapabilityEnum::embeddingGeneration()], - [new RequiredOption('dimensions', 1536)] + [new RequiredOption(OptionEnum::outputSchema(), 1536)] ); $json = json_encode($requirements); @@ -226,7 +233,7 @@ public function testJsonSerialize(): void $this->assertEquals(['embedding_generation'], $decoded[ModelRequirements::KEY_REQUIRED_CAPABILITIES]); $this->assertCount(1, $decoded[ModelRequirements::KEY_REQUIRED_OPTIONS]); $this->assertEquals( - 'dimensions', + OptionEnum::outputSchema()->value, $decoded[ModelRequirements::KEY_REQUIRED_OPTIONS][0][RequiredOption::KEY_NAME] ); $this->assertEquals(1536, $decoded[ModelRequirements::KEY_REQUIRED_OPTIONS][0][RequiredOption::KEY_VALUE]); @@ -271,13 +278,13 @@ public function testWithAllCapabilityTypes(): void public function testWithVariousOptionValueTypes(): void { $options = [ - new RequiredOption('string_option', 'text value'), - new RequiredOption('int_option', 42), - new RequiredOption('float_option', 3.14), - new RequiredOption('bool_option', true), - new RequiredOption('null_option', null), - new RequiredOption('array_option', ['a', 'b', 'c']), - new RequiredOption('object_option', ['key' => 'value', 'nested' => ['inner' => true]]) + new RequiredOption(OptionEnum::outputSchema(), 'text value'), + new RequiredOption(OptionEnum::outputSchema(), 42), + new RequiredOption(OptionEnum::temperature(), 3.14), + new RequiredOption(OptionEnum::outputSchema(), true), + new RequiredOption(OptionEnum::outputSchema(), null), + new RequiredOption(OptionEnum::outputSchema(), ['a', 'b', 'c']), + new RequiredOption(OptionEnum::customOptions(), ['key' => 'value', 'nested' => ['inner' => true]]) ]; $requirements = new ModelRequirements([], $options); @@ -323,15 +330,15 @@ public function testOnlyOptionsNoCapabilities(): void $requirements = new ModelRequirements( [], [ - new RequiredOption('api_key', 'secret-key'), - new RequiredOption('base_url', 'https://api.example.com') + new RequiredOption(OptionEnum::outputSchema(), 'secret-key'), + new RequiredOption(OptionEnum::outputSchema(), 'https://api.example.com') ] ); $array = $requirements->toArray(); $this->assertEquals([], $array['requiredCapabilities']); $this->assertCount(2, $array['requiredOptions']); - $this->assertEquals('api_key', $array['requiredOptions'][0]['name']); + $this->assertEquals(OptionEnum::outputSchema()->value, $array['requiredOptions'][0]['name']); $this->assertEquals('secret-key', $array['requiredOptions'][0]['value']); } @@ -349,8 +356,8 @@ public function testArrayValuesProperlyIndexed(): void CapabilityEnum::embeddingGeneration() ], [ - new RequiredOption('opt1', 'val1'), - new RequiredOption('opt2', 'val2') + new RequiredOption(OptionEnum::outputSchema(), 'val1'), + new RequiredOption(OptionEnum::outputSchema(), 'val2') ] ); diff --git a/tests/unit/Providers/Models/DTO/RequiredOptionTest.php b/tests/unit/Providers/Models/DTO/RequiredOptionTest.php index b636fd7e..3b20551b 100644 --- a/tests/unit/Providers/Models/DTO/RequiredOptionTest.php +++ b/tests/unit/Providers/Models/DTO/RequiredOptionTest.php @@ -7,6 +7,7 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * @covers \WordPress\AiClient\Providers\Models\DTO\RequiredOption @@ -20,12 +21,12 @@ class RequiredOptionTest extends TestCase */ public function testConstructorAndGettersWithStringValue(): void { - $name = 'api_key'; + $name = OptionEnum::maxTokens(); $value = 'secret-key-123'; $option = new RequiredOption($name, $value); - $this->assertEquals($name, $option->getName()); + $this->assertSame($name, $option->getName()); $this->assertEquals($value, $option->getValue()); } @@ -36,9 +37,9 @@ public function testConstructorAndGettersWithStringValue(): void */ public function testWithIntegerValue(): void { - $option = new RequiredOption('max_tokens', 1000); + $option = new RequiredOption(OptionEnum::maxTokens(), 1000); - $this->assertEquals('max_tokens', $option->getName()); + $this->assertSame(OptionEnum::maxTokens(), $option->getName()); $this->assertEquals(1000, $option->getValue()); $this->assertIsInt($option->getValue()); } @@ -50,9 +51,9 @@ public function testWithIntegerValue(): void */ public function testWithFloatValue(): void { - $option = new RequiredOption('temperature', 0.7); + $option = new RequiredOption(OptionEnum::temperature(), 0.7); - $this->assertEquals('temperature', $option->getName()); + $this->assertSame(OptionEnum::temperature(), $option->getName()); $this->assertEquals(0.7, $option->getValue()); $this->assertIsFloat($option->getValue()); } @@ -64,14 +65,14 @@ public function testWithFloatValue(): void */ public function testWithBooleanValue(): void { - $optionTrue = new RequiredOption('stream', true); - $optionFalse = new RequiredOption('logprobs', false); + $optionTrue = new RequiredOption(OptionEnum::webSearch(), true); + $optionFalse = new RequiredOption(OptionEnum::logprobs(), false); - $this->assertEquals('stream', $optionTrue->getName()); + $this->assertSame(OptionEnum::webSearch(), $optionTrue->getName()); $this->assertTrue($optionTrue->getValue()); $this->assertIsBool($optionTrue->getValue()); - $this->assertEquals('logprobs', $optionFalse->getName()); + $this->assertSame(OptionEnum::logprobs(), $optionFalse->getName()); $this->assertFalse($optionFalse->getValue()); $this->assertIsBool($optionFalse->getValue()); } @@ -83,9 +84,9 @@ public function testWithBooleanValue(): void */ public function testWithNullValue(): void { - $option = new RequiredOption('optional_field', null); + $option = new RequiredOption(OptionEnum::outputSchema(), null); - $this->assertEquals('optional_field', $option->getName()); + $this->assertSame(OptionEnum::outputSchema(), $option->getName()); $this->assertNull($option->getValue()); } @@ -97,9 +98,9 @@ public function testWithNullValue(): void public function testWithArrayValue(): void { $arrayValue = ['option1', 'option2', 'option3']; - $option = new RequiredOption('allowed_values', $arrayValue); + $option = new RequiredOption(OptionEnum::stopSequences(), $arrayValue); - $this->assertEquals('allowed_values', $option->getName()); + $this->assertSame(OptionEnum::stopSequences(), $option->getName()); $this->assertEquals($arrayValue, $option->getValue()); $this->assertIsArray($option->getValue()); } @@ -121,9 +122,9 @@ public function testWithObjectValue(): void ] ] ]; - $option = new RequiredOption('response_format', $objectValue); + $option = new RequiredOption(OptionEnum::outputSchema(), $objectValue); - $this->assertEquals('response_format', $option->getName()); + $this->assertSame(OptionEnum::outputSchema(), $option->getName()); $this->assertEquals($objectValue, $option->getValue()); $this->assertIsArray($option->getValue()); } @@ -147,6 +148,8 @@ public function testGetJsonSchema(): void // Check name property $this->assertEquals('string', $schema['properties'][RequiredOption::KEY_NAME]['type']); + $this->assertArrayHasKey('enum', $schema['properties'][RequiredOption::KEY_NAME]); + $this->assertIsArray($schema['properties'][RequiredOption::KEY_NAME]['enum']); $this->assertEquals('The option name.', $schema['properties'][RequiredOption::KEY_NAME]['description']); // Check value property with oneOf @@ -178,50 +181,50 @@ public function testGetJsonSchema(): void public function testToArrayWithDifferentValueTypes(): void { // String value - $stringOption = new RequiredOption('string_opt', 'value'); + $stringOption = new RequiredOption(OptionEnum::maxTokens(), 'value'); $this->assertEquals( - [RequiredOption::KEY_NAME => 'string_opt', RequiredOption::KEY_VALUE => 'value'], + [RequiredOption::KEY_NAME => 'maxTokens', RequiredOption::KEY_VALUE => 'value'], $stringOption->toArray() ); // Number values - $intOption = new RequiredOption('int_opt', 42); + $intOption = new RequiredOption(OptionEnum::candidateCount(), 42); $this->assertEquals( - [RequiredOption::KEY_NAME => 'int_opt', RequiredOption::KEY_VALUE => 42], + [RequiredOption::KEY_NAME => 'candidateCount', RequiredOption::KEY_VALUE => 42], $intOption->toArray() ); - $floatOption = new RequiredOption('float_opt', 3.14); + $floatOption = new RequiredOption(OptionEnum::temperature(), 3.14); $this->assertEquals( - [RequiredOption::KEY_NAME => 'float_opt', RequiredOption::KEY_VALUE => 3.14], + [RequiredOption::KEY_NAME => 'temperature', RequiredOption::KEY_VALUE => 3.14], $floatOption->toArray() ); // Boolean value - $boolOption = new RequiredOption('bool_opt', true); + $boolOption = new RequiredOption(OptionEnum::webSearch(), true); $this->assertEquals( - [RequiredOption::KEY_NAME => 'bool_opt', RequiredOption::KEY_VALUE => true], + [RequiredOption::KEY_NAME => 'webSearch', RequiredOption::KEY_VALUE => true], $boolOption->toArray() ); // Null value - $nullOption = new RequiredOption('null_opt', null); + $nullOption = new RequiredOption(OptionEnum::outputSchema(), null); $this->assertEquals( - [RequiredOption::KEY_NAME => 'null_opt', RequiredOption::KEY_VALUE => null], + [RequiredOption::KEY_NAME => 'outputSchema', RequiredOption::KEY_VALUE => null], $nullOption->toArray() ); // Array value - $arrayOption = new RequiredOption('array_opt', [1, 2, 3]); + $arrayOption = new RequiredOption(OptionEnum::stopSequences(), [1, 2, 3]); $this->assertEquals( - [RequiredOption::KEY_NAME => 'array_opt', RequiredOption::KEY_VALUE => [1, 2, 3]], + [RequiredOption::KEY_NAME => 'stopSequences', RequiredOption::KEY_VALUE => [1, 2, 3]], $arrayOption->toArray() ); // Object value - $objectOption = new RequiredOption('object_opt', ['key' => 'value']); + $objectOption = new RequiredOption(OptionEnum::outputSchema(), ['key' => 'value']); $this->assertEquals( - [RequiredOption::KEY_NAME => 'object_opt', RequiredOption::KEY_VALUE => ['key' => 'value']], + [RequiredOption::KEY_NAME => 'outputSchema', RequiredOption::KEY_VALUE => ['key' => 'value']], $objectOption->toArray() ); } @@ -235,51 +238,51 @@ public function testFromArrayWithDifferentValueTypes(): void { // String value $stringOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'str', RequiredOption::KEY_VALUE => 'test'] + [RequiredOption::KEY_NAME => 'maxTokens', RequiredOption::KEY_VALUE => 'test'] ); - $this->assertEquals('str', $stringOption->getName()); + $this->assertEquals(OptionEnum::maxTokens(), $stringOption->getName()); $this->assertEquals('test', $stringOption->getValue()); // Integer value $intOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'num', RequiredOption::KEY_VALUE => 100] + [RequiredOption::KEY_NAME => 'candidateCount', RequiredOption::KEY_VALUE => 100] ); - $this->assertEquals('num', $intOption->getName()); + $this->assertEquals(OptionEnum::candidateCount(), $intOption->getName()); $this->assertEquals(100, $intOption->getValue()); // Float value $floatOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'float', RequiredOption::KEY_VALUE => 1.5] + [RequiredOption::KEY_NAME => 'temperature', RequiredOption::KEY_VALUE => 1.5] ); - $this->assertEquals('float', $floatOption->getName()); + $this->assertEquals(OptionEnum::temperature(), $floatOption->getName()); $this->assertEquals(1.5, $floatOption->getValue()); // Boolean value $boolOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'bool', RequiredOption::KEY_VALUE => false] + [RequiredOption::KEY_NAME => 'logprobs', RequiredOption::KEY_VALUE => false] ); - $this->assertEquals('bool', $boolOption->getName()); + $this->assertEquals(OptionEnum::logprobs(), $boolOption->getName()); $this->assertFalse($boolOption->getValue()); // Null value $nullOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'nullable', RequiredOption::KEY_VALUE => null] + [RequiredOption::KEY_NAME => 'outputSchema', RequiredOption::KEY_VALUE => null] ); - $this->assertEquals('nullable', $nullOption->getName()); + $this->assertEquals(OptionEnum::outputSchema(), $nullOption->getName()); $this->assertNull($nullOption->getValue()); // Array value $arrayOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'arr', RequiredOption::KEY_VALUE => ['a', 'b', 'c']] + [RequiredOption::KEY_NAME => 'stopSequences', RequiredOption::KEY_VALUE => ['a', 'b', 'c']] ); - $this->assertEquals('arr', $arrayOption->getName()); + $this->assertEquals(OptionEnum::stopSequences(), $arrayOption->getName()); $this->assertEquals(['a', 'b', 'c'], $arrayOption->getValue()); // Object value $objectOption = RequiredOption::fromArray( - [RequiredOption::KEY_NAME => 'obj', RequiredOption::KEY_VALUE => ['nested' => ['deep' => true]]] + [RequiredOption::KEY_NAME => 'outputSchema', RequiredOption::KEY_VALUE => ['nested' => ['deep' => true]]] ); - $this->assertEquals('obj', $objectOption->getName()); + $this->assertEquals(OptionEnum::outputSchema(), $objectOption->getName()); $this->assertEquals(['nested' => ['deep' => true]], $objectOption->getValue()); } @@ -291,13 +294,16 @@ public function testFromArrayWithDifferentValueTypes(): void public function testArrayRoundTrip(): void { $testCases = [ - new RequiredOption('string', 'hello world'), - new RequiredOption('integer', 42), - new RequiredOption('float', 99.99), - new RequiredOption('boolean', true), - new RequiredOption('null', null), - new RequiredOption('array', ['one', 'two', 'three']), - new RequiredOption('object', ['type' => 'config', 'enabled' => true, 'settings' => ['a' => 1, 'b' => 2]]) + new RequiredOption(OptionEnum::maxTokens(), 'hello world'), + new RequiredOption(OptionEnum::candidateCount(), 42), + new RequiredOption(OptionEnum::temperature(), 99.99), + new RequiredOption(OptionEnum::webSearch(), true), + new RequiredOption(OptionEnum::outputSchema(), null), + new RequiredOption(OptionEnum::stopSequences(), ['one', 'two', 'three']), + new RequiredOption( + OptionEnum::customOptions(), + ['type' => 'config', 'enabled' => true, 'settings' => ['a' => 1, 'b' => 2]] + ) ]; foreach ($testCases as $original) { @@ -316,41 +322,41 @@ public function testArrayRoundTrip(): void */ public function testJsonSerialize(): void { - $option = new RequiredOption('json_test', ['enabled' => true, 'count' => 5]); + $option = new RequiredOption(OptionEnum::outputSchema(), ['enabled' => true, 'count' => 5]); $json = json_encode($option); $decoded = json_decode($json, true); $this->assertIsString($json); $this->assertIsArray($decoded); - $this->assertEquals('json_test', $decoded[RequiredOption::KEY_NAME]); + $this->assertEquals('outputSchema', $decoded[RequiredOption::KEY_NAME]); $this->assertEquals(['enabled' => true, 'count' => 5], $decoded[RequiredOption::KEY_VALUE]); } /** - * Tests with empty string name. + * Tests with custom options enum. * * @return void */ - public function testWithEmptyStringName(): void + public function testWithCustomOptions(): void { - $option = new RequiredOption('', 'value'); + $option = new RequiredOption(OptionEnum::customOptions(), ['key' => 'value']); - $this->assertEquals('', $option->getName()); - $this->assertEquals('value', $option->getValue()); + $this->assertEquals(OptionEnum::customOptions(), $option->getName()); + $this->assertEquals(['key' => 'value'], $option->getValue()); } /** - * Tests with special characters in name. + * Tests with input modalities enum. * * @return void */ - public function testWithSpecialCharactersInName(): void + public function testWithInputModalitiesEnum(): void { - $option = new RequiredOption('option-with_special.chars', 'value'); + $option = new RequiredOption(OptionEnum::inputModalities(), ['text', 'image']); - $this->assertEquals('option-with_special.chars', $option->getName()); - $this->assertEquals('value', $option->getValue()); + $this->assertEquals(OptionEnum::inputModalities(), $option->getName()); + $this->assertEquals(['text', 'image'], $option->getValue()); } /** @@ -373,7 +379,7 @@ public function testWithDeeplyNestedArrayValue(): void ] ]; - $option = new RequiredOption('nested_config', $deeplyNested); + $option = new RequiredOption(OptionEnum::outputSchema(), $deeplyNested); $array = $option->toArray(); $this->assertEquals($deeplyNested, $array['value']); @@ -401,7 +407,7 @@ public function testWithMixedArrayValue(): void ['another', 'array'] ]; - $option = new RequiredOption('mixed_types', $mixedArray); + $option = new RequiredOption(OptionEnum::customOptions(), $mixedArray); $this->assertEquals($mixedArray, $option->getValue()); // Verify exact types are preserved @@ -423,7 +429,7 @@ public function testWithMixedArrayValue(): void */ public function testImplementsCorrectInterfaces(): void { - $option = new RequiredOption('test', 'value'); + $option = new RequiredOption(OptionEnum::maxTokens(), 'value'); $this->assertInstanceOf( \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, diff --git a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php index 49e313a2..79c44ca1 100644 --- a/tests/unit/Providers/Models/DTO/SupportedOptionTest.php +++ b/tests/unit/Providers/Models/DTO/SupportedOptionTest.php @@ -7,6 +7,7 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * @covers \WordPress\AiClient\Providers\Models\DTO\SupportedOption @@ -20,12 +21,12 @@ class SupportedOptionTest extends TestCase */ public function testConstructorAndGettersWithStringValues(): void { - $name = 'model'; - $values = ['gpt-3.5-turbo', 'gpt-4', 'gpt-4-turbo']; + $name = OptionEnum::outputModalities(); + $values = ['text', 'image', 'audio']; $option = new SupportedOption($name, $values); - $this->assertEquals($name, $option->getName()); + $this->assertSame($name, $option->getName()); $this->assertEquals($values, $option->getSupportedValues()); } @@ -36,7 +37,7 @@ public function testConstructorAndGettersWithStringValues(): void */ public function testIsSupportedValue(): void { - $option = new SupportedOption('temperature', [0.0, 0.5, 1.0, 1.5, 2.0]); + $option = new SupportedOption(OptionEnum::temperature(), [0.0, 0.5, 1.0, 1.5, 2.0]); $this->assertTrue($option->isSupportedValue(0.0)); $this->assertTrue($option->isSupportedValue(0.5)); @@ -57,9 +58,9 @@ public function testIsSupportedValue(): void */ public function testWithIntegerValues(): void { - $option = new SupportedOption('max_tokens', [100, 500, 1000, 2000, 4000]); + $option = new SupportedOption(OptionEnum::maxTokens(), [100, 500, 1000, 2000, 4000]); - $this->assertEquals('max_tokens', $option->getName()); + $this->assertSame(OptionEnum::maxTokens(), $option->getName()); $this->assertEquals([100, 500, 1000, 2000, 4000], $option->getSupportedValues()); $this->assertTrue($option->isSupportedValue(100)); @@ -75,9 +76,9 @@ public function testWithIntegerValues(): void */ public function testWithBooleanValues(): void { - $option = new SupportedOption('stream', [true, false]); + $option = new SupportedOption(OptionEnum::webSearch(), [true, false]); - $this->assertEquals('stream', $option->getName()); + $this->assertSame(OptionEnum::webSearch(), $option->getName()); $this->assertEquals([true, false], $option->getSupportedValues()); $this->assertTrue($option->isSupportedValue(true)); @@ -93,7 +94,7 @@ public function testWithBooleanValues(): void */ public function testWithNullValue(): void { - $option = new SupportedOption('optional_param', ['value1', 'value2', null]); + $option = new SupportedOption(OptionEnum::outputSchema(), ['value1', 'value2', null]); $this->assertTrue($option->isSupportedValue('value1')); $this->assertTrue($option->isSupportedValue('value2')); @@ -108,9 +109,9 @@ public function testWithNullValue(): void */ public function testWithArrayValues(): void { - $option = new SupportedOption('dimensions', [[256, 256], [512, 512], [1024, 1024]]); + $option = new SupportedOption(OptionEnum::outputMediaAspectRatio(), [[256, 256], [512, 512], [1024, 1024]]); - $this->assertEquals('dimensions', $option->getName()); + $this->assertSame(OptionEnum::outputMediaAspectRatio(), $option->getName()); $supportedValues = $option->getSupportedValues(); $this->assertCount(3, $supportedValues); @@ -129,7 +130,7 @@ public function testWithObjectValues(): void { $format1 = ['type' => 'json_object']; $format2 = ['type' => 'text']; - $option = new SupportedOption('response_format', [$format1, $format2]); + $option = new SupportedOption(OptionEnum::outputSchema(), [$format1, $format2]); $this->assertTrue($option->isSupportedValue(['type' => 'json_object'])); $this->assertTrue($option->isSupportedValue(['type' => 'text'])); @@ -186,11 +187,11 @@ public function testGetJsonSchema(): void */ public function testToArray(): void { - $option = new SupportedOption('style', ['realistic', 'artistic', 'cartoon', 'abstract']); + $option = new SupportedOption(OptionEnum::outputFileType(), ['realistic', 'artistic', 'cartoon', 'abstract']); $array = $option->toArray(); $this->assertIsArray($array); - $this->assertEquals('style', $array[SupportedOption::KEY_NAME]); + $this->assertEquals(OptionEnum::outputFileType()->value, $array[SupportedOption::KEY_NAME]); $this->assertEquals( ['realistic', 'artistic', 'cartoon', 'abstract'], $array[SupportedOption::KEY_SUPPORTED_VALUES] @@ -206,14 +207,14 @@ public function testToArray(): void public function testFromArray(): void { $data = [ - SupportedOption::KEY_NAME => 'voice', + SupportedOption::KEY_NAME => OptionEnum::outputFileType()->value, SupportedOption::KEY_SUPPORTED_VALUES => ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'] ]; $option = SupportedOption::fromArray($data); $this->assertInstanceOf(SupportedOption::class, $option); - $this->assertEquals('voice', $option->getName()); + $this->assertEquals(OptionEnum::outputFileType()->value, $option->getName()->value); $this->assertEquals(['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'], $option->getSupportedValues()); } @@ -225,7 +226,7 @@ public function testFromArray(): void public function testArrayRoundTrip(): void { $original = new SupportedOption( - 'complex_option', + OptionEnum::customOptions(), [ 'string', 123, @@ -241,7 +242,7 @@ public function testArrayRoundTrip(): void $array = $original->toArray(); $restored = SupportedOption::fromArray($array); - $this->assertEquals($original->getName(), $restored->getName()); + $this->assertSame($original->getName(), $restored->getName()); $this->assertEquals($original->getSupportedValues(), $restored->getSupportedValues()); // Verify each value type is preserved @@ -263,14 +264,14 @@ public function testArrayRoundTrip(): void */ public function testJsonSerialize(): void { - $option = new SupportedOption('quality', ['low', 'medium', 'high', 'ultra']); + $option = new SupportedOption(OptionEnum::candidateCount(), ['low', 'medium', 'high', 'ultra']); $json = json_encode($option); $decoded = json_decode($json, true); $this->assertIsString($json); $this->assertIsArray($decoded); - $this->assertEquals('quality', $decoded[SupportedOption::KEY_NAME]); + $this->assertEquals(OptionEnum::candidateCount()->value, $decoded[SupportedOption::KEY_NAME]); $this->assertEquals(['low', 'medium', 'high', 'ultra'], $decoded[SupportedOption::KEY_SUPPORTED_VALUES]); } @@ -281,9 +282,9 @@ public function testJsonSerialize(): void */ public function testWithEmptySupportedValues(): void { - $option = new SupportedOption('empty_option', []); + $option = new SupportedOption(OptionEnum::stopSequences(), []); - $this->assertEquals('empty_option', $option->getName()); + $this->assertSame(OptionEnum::stopSequences(), $option->getName()); $this->assertEquals([], $option->getSupportedValues()); $this->assertFalse($option->isSupportedValue('anything')); $this->assertFalse($option->isSupportedValue(null)); @@ -296,7 +297,7 @@ public function testWithEmptySupportedValues(): void */ public function testWithDuplicateValues(): void { - $option = new SupportedOption('duplicates', ['a', 'b', 'a', 'c', 'b']); + $option = new SupportedOption(OptionEnum::stopSequences(), ['a', 'b', 'a', 'c', 'b']); $this->assertEquals(['a', 'b', 'a', 'c', 'b'], $option->getSupportedValues()); $this->assertTrue($option->isSupportedValue('a')); @@ -311,11 +312,11 @@ public function testWithDuplicateValues(): void */ public function testWithSpecialCharactersInName(): void { - $option = new SupportedOption('option-with_special.chars:test', ['value1', 'value2']); + $option = new SupportedOption(OptionEnum::customOptions(), ['value1', 'value2']); - $this->assertEquals('option-with_special.chars:test', $option->getName()); + $this->assertSame(OptionEnum::customOptions(), $option->getName()); $array = $option->toArray(); - $this->assertEquals('option-with_special.chars:test', $array[SupportedOption::KEY_NAME]); + $this->assertEquals(OptionEnum::customOptions()->value, $array[SupportedOption::KEY_NAME]); } /** @@ -325,7 +326,7 @@ public function testWithSpecialCharactersInName(): void */ public function testStrictTypeCheckingInIsSupportedValue(): void { - $option = new SupportedOption('mixed', [0, '0', false, '', null]); + $option = new SupportedOption(OptionEnum::customOptions(), [0, '0', false, '', null]); // Each value should only match itself exactly $this->assertTrue($option->isSupportedValue(0)); @@ -347,7 +348,7 @@ public function testStrictTypeCheckingInIsSupportedValue(): void */ public function testArrayValuesProperlyIndexed(): void { - $option = new SupportedOption('indexed', ['first', 'second', 'third']); + $option = new SupportedOption(OptionEnum::stopSequences(), ['first', 'second', 'third']); $array = $option->toArray(); // Ensure supportedValues array has numeric keys starting from 0 @@ -378,7 +379,7 @@ public function testWithDeeplyNestedStructures(): void ] ]; - $option = new SupportedOption('nested_configs', $deeplyNested); + $option = new SupportedOption(OptionEnum::outputSchema(), $deeplyNested); $this->assertTrue($option->isSupportedValue($deeplyNested[0])); $this->assertTrue($option->isSupportedValue($deeplyNested[1])); @@ -396,7 +397,7 @@ public function testWithDeeplyNestedStructures(): void */ public function testImplementsCorrectInterfaces(): void { - $option = new SupportedOption('test', ['value']); + $option = new SupportedOption(OptionEnum::maxTokens(), ['value']); $this->assertInstanceOf( \WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface::class, @@ -419,9 +420,9 @@ public function testImplementsCorrectInterfaces(): void */ public function testWithNullSupportedValues(): void { - $option = new SupportedOption('any_value_option'); + $option = new SupportedOption(OptionEnum::temperature()); - $this->assertEquals('any_value_option', $option->getName()); + $this->assertSame(OptionEnum::temperature(), $option->getName()); $this->assertNull($option->getSupportedValues()); // Any value should be supported when supportedValues is null @@ -443,11 +444,11 @@ public function testWithNullSupportedValues(): void */ public function testToArrayWithNullSupportedValues(): void { - $option = new SupportedOption('flexible_option'); + $option = new SupportedOption(OptionEnum::topP()); $array = $option->toArray(); $this->assertIsArray($array); - $this->assertEquals('flexible_option', $array[SupportedOption::KEY_NAME]); + $this->assertEquals(OptionEnum::topP()->value, $array[SupportedOption::KEY_NAME]); $this->assertArrayNotHasKey(SupportedOption::KEY_SUPPORTED_VALUES, $array); $this->assertCount(1, $array); } @@ -460,13 +461,13 @@ public function testToArrayWithNullSupportedValues(): void public function testFromArrayWithMissingSupportedValues(): void { $data = [ - SupportedOption::KEY_NAME => 'open_option' + SupportedOption::KEY_NAME => OptionEnum::temperature()->value ]; $option = SupportedOption::fromArray($data); $this->assertInstanceOf(SupportedOption::class, $option); - $this->assertEquals('open_option', $option->getName()); + $this->assertEquals(OptionEnum::temperature()->value, $option->getName()->value); $this->assertNull($option->getSupportedValues()); $this->assertTrue($option->isSupportedValue('anything')); } @@ -478,12 +479,12 @@ public function testFromArrayWithMissingSupportedValues(): void */ public function testRoundTripWithNullSupportedValues(): void { - $original = new SupportedOption('unrestricted'); + $original = new SupportedOption(OptionEnum::topK()); $array = $original->toArray(); $restored = SupportedOption::fromArray($array); - $this->assertEquals($original->getName(), $restored->getName()); + $this->assertSame($original->getName(), $restored->getName()); $this->assertEquals($original->getSupportedValues(), $restored->getSupportedValues()); $this->assertNull($restored->getSupportedValues()); } @@ -495,14 +496,14 @@ public function testRoundTripWithNullSupportedValues(): void */ public function testJsonSerializationWithNullSupportedValues(): void { - $option = new SupportedOption('json_option'); + $option = new SupportedOption(OptionEnum::customOptions()); $json = json_encode($option); $decoded = json_decode($json, true); $this->assertIsString($json); $this->assertIsArray($decoded); - $this->assertEquals('json_option', $decoded[SupportedOption::KEY_NAME]); + $this->assertEquals(OptionEnum::customOptions()->value, $decoded[SupportedOption::KEY_NAME]); $this->assertArrayNotHasKey(SupportedOption::KEY_SUPPORTED_VALUES, $decoded); } diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index 06d40d73..dd82b746 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -33,16 +33,30 @@ protected function getEnumClass(): string protected function getExpectedValues(): array { return [ + // Explicitly defined constant (not in ModelConfig) 'INPUT_MODALITIES' => 'input_modalities', - 'OUTPUT_MODALITIES' => 'output_modalities', - 'SYSTEM_INSTRUCTION' => 'system_instruction', - 'CANDIDATE_COUNT' => 'candidate_count', - 'MAX_TOKENS' => 'max_tokens', + + // Dynamically added from ModelConfig KEY_* constants + 'OUTPUT_MODALITIES' => 'outputModalities', + 'SYSTEM_INSTRUCTION' => 'systemInstruction', + 'CANDIDATE_COUNT' => 'candidateCount', + 'MAX_TOKENS' => 'maxTokens', 'TEMPERATURE' => 'temperature', - 'TOP_K' => 'top_k', - 'TOP_P' => 'top_p', - 'OUTPUT_MIME_TYPE' => 'output_mime_type', - 'OUTPUT_SCHEMA' => 'output_schema', + 'TOP_P' => 'topP', + 'TOP_K' => 'topK', + 'STOP_SEQUENCES' => 'stopSequences', + 'PRESENCE_PENALTY' => 'presencePenalty', + 'FREQUENCY_PENALTY' => 'frequencyPenalty', + 'LOGPROBS' => 'logprobs', + 'TOP_LOGPROBS' => 'topLogprobs', + 'FUNCTION_DECLARATIONS' => 'functionDeclarations', + 'WEB_SEARCH' => 'webSearch', + 'OUTPUT_FILE_TYPE' => 'outputFileType', + 'OUTPUT_MIME_TYPE' => 'outputMimeType', + 'OUTPUT_SCHEMA' => 'outputSchema', + 'OUTPUT_MEDIA_ORIENTATION' => 'outputMediaOrientation', + 'OUTPUT_MEDIA_ASPECT_RATIO' => 'outputMediaAspectRatio', + 'CUSTOM_OPTIONS' => 'customOptions', ]; } @@ -65,4 +79,60 @@ public function testSpecificEnumMethods(): void $this->assertTrue($outputSchema->isOutputSchema()); $this->assertFalse($outputSchema->isOutputMimeType()); } + + /** + * Tests that dynamically loaded constants from ModelConfig work. + * + * @return void + */ + public function testDynamicallyLoadedConstants(): void + { + // Test a dynamically loaded constant + $stopSequences = OptionEnum::stopSequences(); + $this->assertInstanceOf(OptionEnum::class, $stopSequences); + $this->assertEquals('stopSequences', $stopSequences->value); + $this->assertTrue($stopSequences->isStopSequences()); + $this->assertFalse($stopSequences->isTemperature()); + + // Test another dynamically loaded constant + $presencePenalty = OptionEnum::presencePenalty(); + $this->assertInstanceOf(OptionEnum::class, $presencePenalty); + $this->assertEquals('presencePenalty', $presencePenalty->value); + $this->assertTrue($presencePenalty->isPresencePenalty()); + $this->assertFalse($presencePenalty->isFrequencyPenalty()); + + // Test that all expected dynamic constants are available + $this->assertInstanceOf(OptionEnum::class, OptionEnum::frequencyPenalty()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::logprobs()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::topLogprobs()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::functionDeclarations()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::webSearch()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputFileType()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaOrientation()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaAspectRatio()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::customOptions()); + } + + /** + * Tests that getValues includes all dynamically loaded constants. + * + * @return void + */ + public function testGetValuesIncludesDynamicConstants(): void + { + $values = OptionEnum::getValues(); + + // Check that dynamic values are included + $this->assertContains('stopSequences', $values); + $this->assertContains('presencePenalty', $values); + $this->assertContains('frequencyPenalty', $values); + $this->assertContains('logprobs', $values); + $this->assertContains('topLogprobs', $values); + $this->assertContains('functionDeclarations', $values); + $this->assertContains('webSearch', $values); + $this->assertContains('outputFileType', $values); + $this->assertContains('outputMediaOrientation', $values); + $this->assertContains('outputMediaAspectRatio', $values); + $this->assertContains('customOptions', $values); + } }