From 65f041e49433bd8b3885bed1b8372da12947c49a Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 22 Jul 2025 08:46:06 -0600 Subject: [PATCH 01/42] feat: adds implementor DTOs --- src/Common/Contracts/WithJsonSchema.php | 24 ++ src/Embeddings/DTO/Embedding.php | 54 ++++ src/Files/Contracts/FileInterface.php | 24 ++ src/Files/DTO/InlineFile.php | 88 +++++ src/Files/DTO/LocalFile.php | 88 +++++ src/Files/DTO/RemoteFile.php | 89 +++++ src/Messages/DTO/Message.php | 104 ++++++ src/Messages/DTO/MessagePart.php | 253 +++++++++++++++ src/Messages/DTO/ModelMessage.php | 41 +++ src/Messages/DTO/SystemMessage.php | 41 +++ src/Messages/DTO/UserMessage.php | 40 +++ .../Contracts/OperationInterface.php | 35 ++ src/Operations/DTO/EmbeddingOperation.php | 115 +++++++ src/Operations/DTO/GenerativeAiOperation.php | 115 +++++++ src/Results/Contracts/ResultInterface.php | 43 +++ src/Results/DTO/Candidate.php | 109 +++++++ src/Results/DTO/EmbeddingResult.php | 142 ++++++++ src/Results/DTO/GenerativeAiResult.php | 305 ++++++++++++++++++ src/Results/DTO/TokenUsage.php | 109 +++++++ src/Tools/DTO/FunctionCall.php | 110 +++++++ src/Tools/DTO/FunctionDeclaration.php | 109 +++++++ src/Tools/DTO/FunctionResponse.php | 108 +++++++ src/Tools/DTO/Tool.php | 139 ++++++++ src/Tools/DTO/WebSearch.php | 93 ++++++ 24 files changed, 2378 insertions(+) create mode 100644 src/Common/Contracts/WithJsonSchema.php create mode 100644 src/Embeddings/DTO/Embedding.php create mode 100644 src/Files/Contracts/FileInterface.php create mode 100644 src/Files/DTO/InlineFile.php create mode 100644 src/Files/DTO/LocalFile.php create mode 100644 src/Files/DTO/RemoteFile.php create mode 100644 src/Messages/DTO/Message.php create mode 100644 src/Messages/DTO/MessagePart.php create mode 100644 src/Messages/DTO/ModelMessage.php create mode 100644 src/Messages/DTO/SystemMessage.php create mode 100644 src/Messages/DTO/UserMessage.php create mode 100644 src/Operations/Contracts/OperationInterface.php create mode 100644 src/Operations/DTO/EmbeddingOperation.php create mode 100644 src/Operations/DTO/GenerativeAiOperation.php create mode 100644 src/Results/Contracts/ResultInterface.php create mode 100644 src/Results/DTO/Candidate.php create mode 100644 src/Results/DTO/EmbeddingResult.php create mode 100644 src/Results/DTO/GenerativeAiResult.php create mode 100644 src/Results/DTO/TokenUsage.php create mode 100644 src/Tools/DTO/FunctionCall.php create mode 100644 src/Tools/DTO/FunctionDeclaration.php create mode 100644 src/Tools/DTO/FunctionResponse.php create mode 100644 src/Tools/DTO/Tool.php create mode 100644 src/Tools/DTO/WebSearch.php diff --git a/src/Common/Contracts/WithJsonSchema.php b/src/Common/Contracts/WithJsonSchema.php new file mode 100644 index 00000000..24ae8310 --- /dev/null +++ b/src/Common/Contracts/WithJsonSchema.php @@ -0,0 +1,24 @@ + The JSON schema as an associative array + */ + public static function getJsonSchema(): array; +} \ No newline at end of file diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php new file mode 100644 index 00000000..6f60badb --- /dev/null +++ b/src/Embeddings/DTO/Embedding.php @@ -0,0 +1,54 @@ +vector = $vector; + } + + /** + * Get the embedding vector + * + * @since n.e.x.t + * @return float[] The vector + */ + public function getVector(): array + { + return $this->vector; + } + + /** + * Get the dimension of the embedding + * + * @since n.e.x.t + * @return int The number of dimensions + */ + public function getDimension(): int + { + return count($this->vector); + } +} \ No newline at end of file diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php new file mode 100644 index 00000000..55216c47 --- /dev/null +++ b/src/Files/Contracts/FileInterface.php @@ -0,0 +1,24 @@ +mimeType = $mimeType; + $this->base64Data = $base64Data; + } + + /** + * Get the MIME type of the file + * + * @since n.e.x.t + * @return string The MIME type + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * Get the base64-encoded data + * + * @since n.e.x.t + * @return string The base64-encoded data + */ + public function getBase64Data(): string + { + return $this->base64Data; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'mimeType' => [ + 'type' => 'string', + 'description' => 'The MIME type of the file', + ], + 'base64Data' => [ + 'type' => 'string', + 'description' => 'The base64-encoded file data', + ], + ], + 'required' => ['mimeType', 'base64Data'], + ]; + } +} \ No newline at end of file diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php new file mode 100644 index 00000000..31ddbcdd --- /dev/null +++ b/src/Files/DTO/LocalFile.php @@ -0,0 +1,88 @@ +mimeType = $mimeType; + $this->path = $path; + } + + /** + * Get the MIME type of the file + * + * @since n.e.x.t + * @return string The MIME type + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * Get the local filesystem path + * + * @since n.e.x.t + * @return string The local path + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'mimeType' => [ + 'type' => 'string', + 'description' => 'The MIME type of the file', + ], + 'path' => [ + 'type' => 'string', + 'description' => 'The local filesystem path to the file', + ], + ], + 'required' => ['mimeType', 'path'], + ]; + } +} \ No newline at end of file diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php new file mode 100644 index 00000000..9c381593 --- /dev/null +++ b/src/Files/DTO/RemoteFile.php @@ -0,0 +1,89 @@ +mimeType = $mimeType; + $this->url = $url; + } + + /** + * Get the MIME type of the file + * + * @since n.e.x.t + * @return string The MIME type + */ + public function getMimeType(): string + { + return $this->mimeType; + } + + /** + * Get the URL to the remote file + * + * @since n.e.x.t + * @return string The URL + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'mimeType' => [ + 'type' => 'string', + 'description' => 'The MIME type of the file', + ], + 'url' => [ + 'type' => 'string', + 'format' => 'uri', + 'description' => 'The URL to the remote file', + ], + ], + 'required' => ['mimeType', 'url'], + ]; + } +} \ No newline at end of file diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php new file mode 100644 index 00000000..3f370c91 --- /dev/null +++ b/src/Messages/DTO/Message.php @@ -0,0 +1,104 @@ +role = $role; + $this->parts = $parts; + } + + /** + * Create a message from a simple text string + * + * @since n.e.x.t + * @param MessageRoleEnum $role The role of the message sender + * @param string $text The text content + * @return self + */ + public static function fromText(MessageRoleEnum $role, string $text): self + { + return new self($role, [MessagePart::text($text)]); + } + + /** + * Get the role of the message sender + * + * @since n.e.x.t + * @return MessageRoleEnum The role + */ + public function getRole(): MessageRoleEnum + { + return $this->role; + } + + /** + * Get the message parts + * + * @since n.e.x.t + * @return MessagePart[] The message parts + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'role' => [ + 'type' => 'string', + 'enum' => ['user', 'model', 'system'], + 'description' => 'The role of the message sender', + ], + 'parts' => [ + 'type' => 'array', + 'items' => MessagePart::getJsonSchema(), + 'minItems' => 1, + 'description' => 'The parts that make up this message', + ], + ], + 'required' => ['role', 'parts'], + ]; + } +} \ No newline at end of file diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php new file mode 100644 index 00000000..afc3e04a --- /dev/null +++ b/src/Messages/DTO/MessagePart.php @@ -0,0 +1,253 @@ +type = $type; + } + + /** + * Create a text message part + * + * @since n.e.x.t + * @param string $text The text content + * @return self + */ + public static function text(string $text): self + { + $part = new self(MessagePartTypeEnum::text()); + $part->text = $text; + return $part; + } + + /** + * Create an inline file message part + * + * @since n.e.x.t + * @param InlineFile $file The inline file + * @return self + */ + public static function inlineFile(InlineFile $file): self + { + $part = new self(MessagePartTypeEnum::inlineFile()); + $part->inlineFile = $file; + return $part; + } + + /** + * Create a remote file message part + * + * @since n.e.x.t + * @param RemoteFile $file The remote file + * @return self + */ + public static function remoteFile(RemoteFile $file): self + { + $part = new self(MessagePartTypeEnum::remoteFile()); + $part->remoteFile = $file; + return $part; + } + + /** + * Create a function call message part + * + * @since n.e.x.t + * @param FunctionCall $functionCall The function call + * @return self + */ + public static function functionCall(FunctionCall $functionCall): self + { + $part = new self(MessagePartTypeEnum::functionCall()); + $part->functionCall = $functionCall; + return $part; + } + + /** + * Create a function response message part + * + * @since n.e.x.t + * @param FunctionResponse $functionResponse The function response + * @return self + */ + public static function functionResponse(FunctionResponse $functionResponse): self + { + $part = new self(MessagePartTypeEnum::functionResponse()); + $part->functionResponse = $functionResponse; + return $part; + } + + /** + * Get the type of this message part + * + * @since n.e.x.t + * @return MessagePartTypeEnum The type + */ + public function getType(): MessagePartTypeEnum + { + return $this->type; + } + + /** + * Get the text content + * + * @since n.e.x.t + * @return string|null The text content or null if not a text part + */ + public function getText(): ?string + { + return $this->text; + } + + /** + * Get the inline file + * + * @since n.e.x.t + * @return InlineFile|null The inline file or null if not an inline file part + */ + public function getInlineFile(): ?InlineFile + { + return $this->inlineFile; + } + + /** + * Get the remote file + * + * @since n.e.x.t + * @return RemoteFile|null The remote file or null if not a remote file part + */ + public function getRemoteFile(): ?RemoteFile + { + return $this->remoteFile; + } + + /** + * Get the function call + * + * @since n.e.x.t + * @return FunctionCall|null The function call or null if not a function call part + */ + public function getFunctionCall(): ?FunctionCall + { + return $this->functionCall; + } + + /** + * Get the function response + * + * @since n.e.x.t + * @return FunctionResponse|null The function response or null if not a function response part + */ + public function getFunctionResponse(): ?FunctionResponse + { + return $this->functionResponse; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'enum' => ['text', 'inline_file', 'remote_file', 'function_call', 'function_response'], + 'description' => 'The type of this message part', + ], + 'text' => [ + 'type' => ['string', 'null'], + 'description' => 'Text content (when type is text)', + ], + 'inlineFile' => [ + 'oneOf' => [ + ['type' => 'null'], + InlineFile::getJsonSchema(), + ], + 'description' => 'Inline file data (when type is inline_file)', + ], + 'remoteFile' => [ + 'oneOf' => [ + ['type' => 'null'], + RemoteFile::getJsonSchema(), + ], + 'description' => 'Remote file reference (when type is remote_file)', + ], + 'functionCall' => [ + 'oneOf' => [ + ['type' => 'null'], + FunctionCall::getJsonSchema(), + ], + 'description' => 'Function call request (when type is function_call)', + ], + 'functionResponse' => [ + 'oneOf' => [ + ['type' => 'null'], + FunctionResponse::getJsonSchema(), + ], + 'description' => 'Function response (when type is function_response)', + ], + ], + 'required' => ['type'], + ]; + } +} \ No newline at end of file diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php new file mode 100644 index 00000000..b1638c6e --- /dev/null +++ b/src/Messages/DTO/ModelMessage.php @@ -0,0 +1,41 @@ +id = $id; + $this->state = $state; + $this->result = $result; + } + + /** + * Get the operation ID + * + * @since n.e.x.t + * @return string The unique identifier + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the current state of the operation + * + * @since n.e.x.t + * @return OperationStateEnum The operation state + */ + public function getState(): OperationStateEnum + { + return $this->state; + } + + /** + * Get the operation result + * + * @since n.e.x.t + * @return EmbeddingResult|null The result or null if not yet complete + */ + public function getResult(): ?EmbeddingResult + { + return $this->result; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation', + ], + 'state' => [ + 'type' => 'string', + 'enum' => ['starting', 'processing', 'succeeded', 'failed', 'canceled'], + 'description' => 'The current state of the operation', + ], + 'result' => [ + 'oneOf' => [ + ['type' => 'null'], + EmbeddingResult::getJsonSchema(), + ], + 'description' => 'The result once the operation completes', + ], + ], + 'required' => ['id', 'state'], + ]; + } +} \ No newline at end of file diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php new file mode 100644 index 00000000..b7ad32d5 --- /dev/null +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -0,0 +1,115 @@ +id = $id; + $this->state = $state; + $this->result = $result; + } + + /** + * Get the operation ID + * + * @since n.e.x.t + * @return string The unique identifier + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the current state of the operation + * + * @since n.e.x.t + * @return OperationStateEnum The operation state + */ + public function getState(): OperationStateEnum + { + return $this->state; + } + + /** + * Get the operation result + * + * @since n.e.x.t + * @return GenerativeAiResult|null The result or null if not yet complete + */ + public function getResult(): ?GenerativeAiResult + { + return $this->result; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation', + ], + 'state' => [ + 'type' => 'string', + 'enum' => ['starting', 'processing', 'succeeded', 'failed', 'canceled'], + 'description' => 'The current state of the operation', + ], + 'result' => [ + 'oneOf' => [ + ['type' => 'null'], + GenerativeAiResult::getJsonSchema(), + ], + 'description' => 'The result once the operation completes', + ], + ], + 'required' => ['id', 'state'], + ]; + } +} \ No newline at end of file diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php new file mode 100644 index 00000000..cf51f052 --- /dev/null +++ b/src/Results/Contracts/ResultInterface.php @@ -0,0 +1,43 @@ + Provider metadata + */ + public function getProviderMetadata(): array; +} \ No newline at end of file diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php new file mode 100644 index 00000000..ce9996b1 --- /dev/null +++ b/src/Results/DTO/Candidate.php @@ -0,0 +1,109 @@ +message = $message; + $this->finishReason = $finishReason; + $this->tokenCount = $tokenCount; + } + + /** + * Get the generated message + * + * @since n.e.x.t + * @return Message The message + */ + public function getMessage(): Message + { + return $this->message; + } + + /** + * Get the finish reason + * + * @since n.e.x.t + * @return FinishReasonEnum The finish reason + */ + public function getFinishReason(): FinishReasonEnum + { + return $this->finishReason; + } + + /** + * Get the token count + * + * @since n.e.x.t + * @return int The token count + */ + public function getTokenCount(): int + { + return $this->tokenCount; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'message' => Message::getJsonSchema(), + 'finishReason' => [ + 'type' => 'string', + 'enum' => ['stop', 'length', 'content_filter', 'tool_calls', 'error'], + 'description' => 'The reason generation stopped', + ], + 'tokenCount' => [ + 'type' => 'integer', + 'description' => 'The number of tokens in this candidate', + ], + ], + 'required' => ['message', 'finishReason', 'tokenCount'], + ]; + } +} \ No newline at end of file diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php new file mode 100644 index 00000000..1b8b41a1 --- /dev/null +++ b/src/Results/DTO/EmbeddingResult.php @@ -0,0 +1,142 @@ + Provider-specific metadata + */ + private array $providerMetadata; + + /** + * Constructor + * + * @since n.e.x.t + * @param string $id Unique identifier for this result + * @param Embedding[] $embeddings The generated embeddings + * @param TokenUsage $tokenUsage Token usage statistics + * @param array $providerMetadata Provider-specific metadata + */ + public function __construct(string $id, array $embeddings, TokenUsage $tokenUsage, array $providerMetadata = []) + { + $this->id = $id; + $this->embeddings = $embeddings; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + } + + /** + * Get the result ID + * + * @since n.e.x.t + * @return string The unique identifier + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the generated embeddings + * + * @since n.e.x.t + * @return Embedding[] The embeddings + */ + public function getEmbeddings(): array + { + return $this->embeddings; + } + + /** + * Get token usage information + * + * @since n.e.x.t + * @return TokenUsage Token usage statistics + */ + public function getTokenUsage(): TokenUsage + { + return $this->tokenUsage; + } + + /** + * Get provider-specific metadata + * + * @since n.e.x.t + * @return array Provider metadata + */ + public function getProviderMetadata(): array + { + return $this->providerMetadata; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this result', + ], + 'embeddings' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'vector' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'number', + ], + ], + ], + 'required' => ['vector'], + ], + 'description' => 'The generated embeddings', + ], + 'tokenUsage' => TokenUsage::getJsonSchema(), + 'providerMetadata' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Provider-specific metadata', + ], + ], + 'required' => ['id', 'embeddings', 'tokenUsage'], + ]; + } +} \ No newline at end of file diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php new file mode 100644 index 00000000..63aebdfe --- /dev/null +++ b/src/Results/DTO/GenerativeAiResult.php @@ -0,0 +1,305 @@ + Provider-specific metadata + */ + private array $providerMetadata; + + /** + * Constructor + * + * @since n.e.x.t + * @param string $id Unique identifier for this result + * @param Candidate[] $candidates The generated candidates + * @param TokenUsage $tokenUsage Token usage statistics + * @param array $providerMetadata Provider-specific metadata + */ + public function __construct(string $id, array $candidates, TokenUsage $tokenUsage, array $providerMetadata = []) + { + $this->id = $id; + $this->candidates = $candidates; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + } + + /** + * Get the result ID + * + * @since n.e.x.t + * @return string The unique identifier + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the generated candidates + * + * @since n.e.x.t + * @return Candidate[] The candidates + */ + public function getCandidates(): array + { + return $this->candidates; + } + + /** + * Get token usage information + * + * @since n.e.x.t + * @return TokenUsage Token usage statistics + */ + public function getTokenUsage(): TokenUsage + { + return $this->tokenUsage; + } + + /** + * Get provider-specific metadata + * + * @since n.e.x.t + * @return array Provider metadata + */ + public function getProviderMetadata(): array + { + return $this->providerMetadata; + } + + /** + * Convert the first candidate to text + * + * @since n.e.x.t + * @return string The text content + * @throws \RuntimeException If no candidates or no text content + */ + public function toText(): string + { + if (empty($this->candidates)) { + throw new \RuntimeException('No candidates available'); + } + + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + if ($part->getType()->equals(MessagePartTypeEnum::text()) && $part->getText() !== null) { + return $part->getText(); + } + } + + throw new \RuntimeException('No text content found in first candidate'); + } + + /** + * Convert the first candidate to an image file + * + * @since n.e.x.t + * @return FileInterface The image file + * @throws \RuntimeException If no candidates or no image content + */ + public function toImageFile(): FileInterface + { + if (empty($this->candidates)) { + throw new \RuntimeException('No candidates available'); + } + + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + if ($part->getType()->equals(MessagePartTypeEnum::inlineFile()) && $part->getInlineFile() !== null) { + return $part->getInlineFile(); + } + if ($part->getType()->equals(MessagePartTypeEnum::remoteFile()) && $part->getRemoteFile() !== null) { + return $part->getRemoteFile(); + } + } + + throw new \RuntimeException('No image content found in first candidate'); + } + + /** + * Convert the first candidate to an audio file + * + * @since n.e.x.t + * @return FileInterface The audio file + * @throws \RuntimeException If no candidates or no audio content + */ + public function toAudioFile(): FileInterface + { + // Similar implementation to toImageFile, but checking for audio MIME types + return $this->toImageFile(); // Simplified for now + } + + /** + * Convert the first candidate to a video file + * + * @since n.e.x.t + * @return FileInterface The video file + * @throws \RuntimeException If no candidates or no video content + */ + public function toVideoFile(): FileInterface + { + // Similar implementation to toImageFile, but checking for video MIME types + return $this->toImageFile(); // Simplified for now + } + + /** + * Convert the first candidate to a message + * + * @since n.e.x.t + * @return Message The message + * @throws \RuntimeException If no candidates available + */ + public function toMessage(): Message + { + if (empty($this->candidates)) { + throw new \RuntimeException('No candidates available'); + } + + return $this->candidates[0]->getMessage(); + } + + /** + * Convert all candidates to text array + * + * @since n.e.x.t + * @return string[] Array of text content + */ + public function toTexts(): array + { + $texts = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + if ($part->getType()->equals(MessagePartTypeEnum::text()) && $part->getText() !== null) { + $texts[] = $part->getText(); + break; + } + } + } + return $texts; + } + + /** + * Convert all candidates to image files + * + * @since n.e.x.t + * @return FileInterface[] Array of image files + */ + public function toImageFiles(): array + { + $files = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + if ($part->getType()->equals(MessagePartTypeEnum::inlineFile()) && $part->getInlineFile() !== null) { + $files[] = $part->getInlineFile(); + break; + } + if ($part->getType()->equals(MessagePartTypeEnum::remoteFile()) && $part->getRemoteFile() !== null) { + $files[] = $part->getRemoteFile(); + break; + } + } + } + return $files; + } + + /** + * Convert all candidates to audio files + * + * @since n.e.x.t + * @return FileInterface[] Array of audio files + */ + public function toAudioFiles(): array + { + // Similar implementation to toImageFiles, but checking for audio MIME types + return $this->toImageFiles(); // Simplified for now + } + + /** + * Convert all candidates to video files + * + * @since n.e.x.t + * @return FileInterface[] Array of video files + */ + public function toVideoFiles(): array + { + // Similar implementation to toImageFiles, but checking for video MIME types + return $this->toImageFiles(); // Simplified for now + } + + /** + * Convert all candidates to messages + * + * @since n.e.x.t + * @return Message[] Array of messages + */ + public function toMessages(): array + { + return array_map(fn(Candidate $candidate) => $candidate->getMessage(), $this->candidates); + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this result', + ], + 'candidates' => [ + 'type' => 'array', + 'items' => Candidate::getJsonSchema(), + 'description' => 'The generated candidates', + ], + 'tokenUsage' => TokenUsage::getJsonSchema(), + 'providerMetadata' => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Provider-specific metadata', + ], + ], + 'required' => ['id', 'candidates', 'tokenUsage'], + ]; + } +} diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php new file mode 100644 index 00000000..f277526a --- /dev/null +++ b/src/Results/DTO/TokenUsage.php @@ -0,0 +1,109 @@ +promptTokens = $promptTokens; + $this->completionTokens = $completionTokens; + $this->totalTokens = $totalTokens; + } + + /** + * Get the number of prompt tokens + * + * @since n.e.x.t + * @return int The prompt token count + */ + public function getPromptTokens(): int + { + return $this->promptTokens; + } + + /** + * Get the number of completion tokens + * + * @since n.e.x.t + * @return int The completion token count + */ + public function getCompletionTokens(): int + { + return $this->completionTokens; + } + + /** + * Get the total number of tokens + * + * @since n.e.x.t + * @return int The total token count + */ + public function getTotalTokens(): int + { + return $this->totalTokens; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'promptTokens' => [ + 'type' => 'integer', + 'description' => 'Number of tokens in the prompt', + ], + 'completionTokens' => [ + 'type' => 'integer', + 'description' => 'Number of tokens in the completion', + ], + 'totalTokens' => [ + 'type' => 'integer', + 'description' => 'Total number of tokens used', + ], + ], + 'required' => ['promptTokens', 'completionTokens', 'totalTokens'], + ]; + } +} \ No newline at end of file diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php new file mode 100644 index 00000000..e5e83eb7 --- /dev/null +++ b/src/Tools/DTO/FunctionCall.php @@ -0,0 +1,110 @@ + The arguments to pass to the function + */ + private array $args; + + /** + * Constructor + * + * @since n.e.x.t + * @param string $id Unique identifier for this function call + * @param string $name The name of the function to call + * @param array $args The arguments to pass to the function + */ + public function __construct(string $id, string $name, array $args) + { + $this->id = $id; + $this->name = $name; + $this->args = $args; + } + + /** + * Get the function call ID + * + * @since n.e.x.t + * @return string The unique identifier + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the function name + * + * @since n.e.x.t + * @return string The function name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the function arguments + * + * @since n.e.x.t + * @return array The function arguments + */ + public function getArgs(): array + { + return $this->args; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this function call', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function to call', + ], + 'args' => [ + 'type' => 'object', + 'description' => 'The arguments to pass to the function', + 'additionalProperties' => true, + ], + ], + 'required' => ['id', 'name', 'args'], + ]; + } +} \ No newline at end of file diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php new file mode 100644 index 00000000..148ae1dd --- /dev/null +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -0,0 +1,109 @@ +name = $name; + $this->description = $description; + $this->parameters = $parameters; + } + + /** + * Get the function name + * + * @since n.e.x.t + * @return string The function name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the function description + * + * @since n.e.x.t + * @return string The function description + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * Get the function parameters schema + * + * @since n.e.x.t + * @return mixed The parameters schema + */ + public function getParameters() + { + return $this->parameters; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function', + ], + 'description' => [ + 'type' => 'string', + 'description' => 'A description of what the function does', + ], + 'parameters' => [ + 'type' => 'object', + 'description' => 'The JSON schema for the function parameters', + ], + ], + 'required' => ['name', 'description', 'parameters'], + ]; + } +} \ No newline at end of file diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php new file mode 100644 index 00000000..0266ad02 --- /dev/null +++ b/src/Tools/DTO/FunctionResponse.php @@ -0,0 +1,108 @@ +id = $id; + $this->name = $name; + $this->response = $response; + } + + /** + * Get the function call ID + * + * @since n.e.x.t + * @return string The function call ID + */ + public function getId(): string + { + return $this->id; + } + + /** + * Get the function name + * + * @since n.e.x.t + * @return string The function name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the function response + * + * @since n.e.x.t + * @return mixed The response data + */ + public function getResponse() + { + return $this->response; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'The ID of the function call this is responding to', + ], + 'name' => [ + 'type' => 'string', + 'description' => 'The name of the function that was called', + ], + 'response' => [ + 'description' => 'The response data from the function', + ], + ], + 'required' => ['id', 'name', 'response'], + ]; + } +} \ No newline at end of file diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php new file mode 100644 index 00000000..d4e48123 --- /dev/null +++ b/src/Tools/DTO/Tool.php @@ -0,0 +1,139 @@ +type = $type; + } + + /** + * Create a function declarations tool + * + * @since n.e.x.t + * @param FunctionDeclaration[] $declarations The function declarations + * @return self + */ + public static function functionDeclarations(array $declarations): self + { + $tool = new self(ToolTypeEnum::functionDeclarations()); + $tool->functionDeclarations = $declarations; + return $tool; + } + + /** + * Create a web search tool + * + * @since n.e.x.t + * @param WebSearch $webSearch The web search configuration + * @return self + */ + public static function webSearch(WebSearch $webSearch): self + { + $tool = new self(ToolTypeEnum::webSearch()); + $tool->webSearch = $webSearch; + return $tool; + } + + /** + * Get the tool type + * + * @since n.e.x.t + * @return ToolTypeEnum The tool type + */ + public function getType(): ToolTypeEnum + { + return $this->type; + } + + /** + * Get the function declarations + * + * @since n.e.x.t + * @return FunctionDeclaration[]|null The function declarations or null if not a function tool + */ + public function getFunctionDeclarations(): ?array + { + return $this->functionDeclarations; + } + + /** + * Get the web search configuration + * + * @since n.e.x.t + * @return WebSearch|null The web search configuration or null if not a web search tool + */ + public function getWebSearch(): ?WebSearch + { + return $this->webSearch; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'enum' => ['function_declarations', 'web_search'], + 'description' => 'The type of tool', + ], + 'functionDeclarations' => [ + 'type' => ['array', 'null'], + 'items' => FunctionDeclaration::getJsonSchema(), + 'description' => 'Function declarations (when type is function_declarations)', + ], + 'webSearch' => [ + 'oneOf' => [ + ['type' => 'null'], + WebSearch::getJsonSchema(), + ], + 'description' => 'Web search configuration (when type is web_search)', + ], + ], + 'required' => ['type'], + ]; + } +} \ No newline at end of file diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php new file mode 100644 index 00000000..a11c4c58 --- /dev/null +++ b/src/Tools/DTO/WebSearch.php @@ -0,0 +1,93 @@ +allowedDomains = $allowedDomains; + $this->disallowedDomains = $disallowedDomains; + } + + /** + * Get the allowed domains + * + * @since n.e.x.t + * @return string[] The allowed domains + */ + public function getAllowedDomains(): array + { + return $this->allowedDomains; + } + + /** + * Get the disallowed domains + * + * @since n.e.x.t + * @return string[] The disallowed domains + */ + public function getDisallowedDomains(): array + { + return $this->disallowedDomains; + } + + /** + * Get the JSON schema for this DTO + * + * @since n.e.x.t + * @return array The JSON schema + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'allowedDomains' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'description' => 'List of domains that are allowed for web search', + ], + 'disallowedDomains' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'description' => 'List of domains that are disallowed for web search', + ], + ], + 'required' => [], + ]; + } +} \ No newline at end of file From 550acc2799bab72604eb942992f825367e62f6de Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 22 Jul 2025 08:51:17 -0600 Subject: [PATCH 02/42] refactor: resolves linting errors --- src/Common/Contracts/WithJsonSchema.php | 2 +- src/Embeddings/DTO/Embedding.php | 2 +- src/Files/Contracts/FileInterface.php | 2 +- src/Files/DTO/InlineFile.php | 2 +- src/Files/DTO/LocalFile.php | 2 +- src/Files/DTO/RemoteFile.php | 2 +- src/Messages/DTO/Message.php | 2 +- src/Messages/DTO/MessagePart.php | 2 +- src/Messages/DTO/ModelMessage.php | 4 ++-- src/Messages/DTO/SystemMessage.php | 4 ++-- src/Messages/DTO/UserMessage.php | 4 ++-- src/Operations/Contracts/OperationInterface.php | 2 +- src/Operations/DTO/EmbeddingOperation.php | 2 +- src/Operations/DTO/GenerativeAiOperation.php | 2 +- src/Results/Contracts/ResultInterface.php | 2 +- src/Results/DTO/Candidate.php | 2 +- src/Results/DTO/EmbeddingResult.php | 2 +- src/Results/DTO/TokenUsage.php | 2 +- src/Tools/DTO/FunctionCall.php | 2 +- src/Tools/DTO/FunctionDeclaration.php | 2 +- src/Tools/DTO/FunctionResponse.php | 2 +- src/Tools/DTO/Tool.php | 2 +- src/Tools/DTO/WebSearch.php | 2 +- 23 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Common/Contracts/WithJsonSchema.php b/src/Common/Contracts/WithJsonSchema.php index 24ae8310..9feb14c4 100644 --- a/src/Common/Contracts/WithJsonSchema.php +++ b/src/Common/Contracts/WithJsonSchema.php @@ -21,4 +21,4 @@ interface WithJsonSchema * @return array The JSON schema as an associative array */ public static function getJsonSchema(): array; -} \ No newline at end of file +} diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php index 6f60badb..4601f394 100644 --- a/src/Embeddings/DTO/Embedding.php +++ b/src/Embeddings/DTO/Embedding.php @@ -51,4 +51,4 @@ public function getDimension(): int { return count($this->vector); } -} \ No newline at end of file +} diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php index 55216c47..28747646 100644 --- a/src/Files/Contracts/FileInterface.php +++ b/src/Files/Contracts/FileInterface.php @@ -21,4 +21,4 @@ interface FileInterface * @return string The MIME type (e.g., 'image/png', 'audio/mp3') */ public function getMimeType(): string; -} \ No newline at end of file +} diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index f2fc3fbf..aa5eac11 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -85,4 +85,4 @@ public static function getJsonSchema(): array 'required' => ['mimeType', 'base64Data'], ]; } -} \ No newline at end of file +} diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index 31ddbcdd..3c6d20aa 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -85,4 +85,4 @@ public static function getJsonSchema(): array 'required' => ['mimeType', 'path'], ]; } -} \ No newline at end of file +} diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index 9c381593..4e17c84e 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -86,4 +86,4 @@ public static function getJsonSchema(): array 'required' => ['mimeType', 'url'], ]; } -} \ No newline at end of file +} diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 3f370c91..906bb15f 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -101,4 +101,4 @@ public static function getJsonSchema(): array 'required' => ['role', 'parts'], ]; } -} \ No newline at end of file +} diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index afc3e04a..097e94fe 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -250,4 +250,4 @@ public static function getJsonSchema(): array 'required' => ['type'], ]; } -} \ No newline at end of file +} diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php index b1638c6e..b90857e2 100644 --- a/src/Messages/DTO/ModelMessage.php +++ b/src/Messages/DTO/ModelMessage.php @@ -34,8 +34,8 @@ public function __construct(array $parts) * @param string $text The text content * @return self */ - public static function fromText(string $text): self + public static function text(string $text): self { return new self([MessagePart::text($text)]); } -} \ No newline at end of file +} diff --git a/src/Messages/DTO/SystemMessage.php b/src/Messages/DTO/SystemMessage.php index 700d9c51..d0eea598 100644 --- a/src/Messages/DTO/SystemMessage.php +++ b/src/Messages/DTO/SystemMessage.php @@ -34,8 +34,8 @@ public function __construct(array $parts) * @param string $text The text content * @return self */ - public static function fromText(string $text): self + public static function text(string $text): self { return new self([MessagePart::text($text)]); } -} \ No newline at end of file +} diff --git a/src/Messages/DTO/UserMessage.php b/src/Messages/DTO/UserMessage.php index 933d4905..bb70a0e1 100644 --- a/src/Messages/DTO/UserMessage.php +++ b/src/Messages/DTO/UserMessage.php @@ -33,8 +33,8 @@ public function __construct(array $parts) * @param string $text The text content * @return self */ - public static function fromText(string $text): self + public static function text(string $text): self { return new self([MessagePart::text($text)]); } -} \ No newline at end of file +} diff --git a/src/Operations/Contracts/OperationInterface.php b/src/Operations/Contracts/OperationInterface.php index d113270e..5865ead2 100644 --- a/src/Operations/Contracts/OperationInterface.php +++ b/src/Operations/Contracts/OperationInterface.php @@ -32,4 +32,4 @@ public function getId(): string; * @return OperationStateEnum The operation state */ public function getState(): OperationStateEnum; -} \ No newline at end of file +} diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php index 0bf98a11..ae568b6f 100644 --- a/src/Operations/DTO/EmbeddingOperation.php +++ b/src/Operations/DTO/EmbeddingOperation.php @@ -112,4 +112,4 @@ public static function getJsonSchema(): array 'required' => ['id', 'state'], ]; } -} \ No newline at end of file +} diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index b7ad32d5..797e5f4b 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -112,4 +112,4 @@ public static function getJsonSchema(): array 'required' => ['id', 'state'], ]; } -} \ No newline at end of file +} diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php index cf51f052..42ac6de4 100644 --- a/src/Results/Contracts/ResultInterface.php +++ b/src/Results/Contracts/ResultInterface.php @@ -40,4 +40,4 @@ public function getTokenUsage(): TokenUsage; * @return array Provider metadata */ public function getProviderMetadata(): array; -} \ No newline at end of file +} diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index ce9996b1..ee6a565c 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -106,4 +106,4 @@ public static function getJsonSchema(): array 'required' => ['message', 'finishReason', 'tokenCount'], ]; } -} \ No newline at end of file +} diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php index 1b8b41a1..476a7bff 100644 --- a/src/Results/DTO/EmbeddingResult.php +++ b/src/Results/DTO/EmbeddingResult.php @@ -139,4 +139,4 @@ public static function getJsonSchema(): array 'required' => ['id', 'embeddings', 'tokenUsage'], ]; } -} \ No newline at end of file +} diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index f277526a..31b2c22f 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -106,4 +106,4 @@ public static function getJsonSchema(): array 'required' => ['promptTokens', 'completionTokens', 'totalTokens'], ]; } -} \ No newline at end of file +} diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index e5e83eb7..923b2308 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -107,4 +107,4 @@ public static function getJsonSchema(): array 'required' => ['id', 'name', 'args'], ]; } -} \ No newline at end of file +} diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 148ae1dd..955e05ce 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -106,4 +106,4 @@ public static function getJsonSchema(): array 'required' => ['name', 'description', 'parameters'], ]; } -} \ No newline at end of file +} diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 0266ad02..0b436bb8 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -105,4 +105,4 @@ public static function getJsonSchema(): array 'required' => ['id', 'name', 'response'], ]; } -} \ No newline at end of file +} diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index d4e48123..52ad99d0 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -136,4 +136,4 @@ public static function getJsonSchema(): array 'required' => ['type'], ]; } -} \ No newline at end of file +} diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php index a11c4c58..abb87d9a 100644 --- a/src/Tools/DTO/WebSearch.php +++ b/src/Tools/DTO/WebSearch.php @@ -90,4 +90,4 @@ public static function getJsonSchema(): array 'required' => [], ]; } -} \ No newline at end of file +} From 9875f84467015132af3c945dea33279918849ed8 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 22 Jul 2025 08:57:32 -0600 Subject: [PATCH 03/42] refactor: uses inheritDoct to reduce redundancy --- src/Files/DTO/InlineFile.php | 3 +-- src/Files/DTO/LocalFile.php | 3 +-- src/Files/DTO/RemoteFile.php | 3 +-- src/Messages/DTO/Message.php | 3 +-- src/Messages/DTO/MessagePart.php | 3 +-- src/Operations/DTO/EmbeddingOperation.php | 9 +++------ src/Operations/DTO/GenerativeAiOperation.php | 9 +++------ src/Results/DTO/Candidate.php | 3 +-- src/Results/DTO/EmbeddingResult.php | 12 ++++-------- src/Results/DTO/GenerativeAiResult.php | 12 ++++-------- src/Results/DTO/TokenUsage.php | 3 +-- src/Tools/DTO/FunctionCall.php | 3 +-- src/Tools/DTO/FunctionDeclaration.php | 3 +-- src/Tools/DTO/FunctionResponse.php | 3 +-- src/Tools/DTO/Tool.php | 3 +-- src/Tools/DTO/WebSearch.php | 3 +-- 16 files changed, 26 insertions(+), 52 deletions(-) diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index aa5eac11..00c9cbc8 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -63,10 +63,9 @@ public function getBase64Data(): string } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index 3c6d20aa..3e8dc491 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -63,10 +63,9 @@ public function getPath(): string } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index 4e17c84e..e236f939 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -63,10 +63,9 @@ public function getUrl(): string } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 906bb15f..4f5fff7a 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -76,10 +76,9 @@ public function getParts(): array } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 097e94fe..76b4ea15 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -199,10 +199,9 @@ public function getFunctionResponse(): ?FunctionResponse } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php index ae568b6f..5860d7b6 100644 --- a/src/Operations/DTO/EmbeddingOperation.php +++ b/src/Operations/DTO/EmbeddingOperation.php @@ -49,10 +49,9 @@ public function __construct(string $id, OperationStateEnum $state, ?EmbeddingRes } /** - * Get the operation ID + * {@inheritDoc} * * @since n.e.x.t - * @return string The unique identifier */ public function getId(): string { @@ -60,10 +59,9 @@ public function getId(): string } /** - * Get the current state of the operation + * {@inheritDoc} * * @since n.e.x.t - * @return OperationStateEnum The operation state */ public function getState(): OperationStateEnum { @@ -82,10 +80,9 @@ public function getResult(): ?EmbeddingResult } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index 797e5f4b..bdd94c4b 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -49,10 +49,9 @@ public function __construct(string $id, OperationStateEnum $state, ?GenerativeAi } /** - * Get the operation ID + * {@inheritDoc} * * @since n.e.x.t - * @return string The unique identifier */ public function getId(): string { @@ -60,10 +59,9 @@ public function getId(): string } /** - * Get the current state of the operation + * {@inheritDoc} * * @since n.e.x.t - * @return OperationStateEnum The operation state */ public function getState(): OperationStateEnum { @@ -82,10 +80,9 @@ public function getResult(): ?GenerativeAiResult } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index ee6a565c..448aab30 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -82,10 +82,9 @@ public function getTokenCount(): int } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php index 476a7bff..69157778 100644 --- a/src/Results/DTO/EmbeddingResult.php +++ b/src/Results/DTO/EmbeddingResult.php @@ -55,10 +55,9 @@ public function __construct(string $id, array $embeddings, TokenUsage $tokenUsag } /** - * Get the result ID + * {@inheritDoc} * * @since n.e.x.t - * @return string The unique identifier */ public function getId(): string { @@ -77,10 +76,9 @@ public function getEmbeddings(): array } /** - * Get token usage information + * {@inheritDoc} * * @since n.e.x.t - * @return TokenUsage Token usage statistics */ public function getTokenUsage(): TokenUsage { @@ -88,10 +86,9 @@ public function getTokenUsage(): TokenUsage } /** - * Get provider-specific metadata + * {@inheritDoc} * * @since n.e.x.t - * @return array Provider metadata */ public function getProviderMetadata(): array { @@ -99,10 +96,9 @@ public function getProviderMetadata(): array } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 63aebdfe..628cb605 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -57,10 +57,9 @@ public function __construct(string $id, array $candidates, TokenUsage $tokenUsag } /** - * Get the result ID + * {@inheritDoc} * * @since n.e.x.t - * @return string The unique identifier */ public function getId(): string { @@ -79,10 +78,9 @@ public function getCandidates(): array } /** - * Get token usage information + * {@inheritDoc} * * @since n.e.x.t - * @return TokenUsage Token usage statistics */ public function getTokenUsage(): TokenUsage { @@ -90,10 +88,9 @@ public function getTokenUsage(): TokenUsage } /** - * Get provider-specific metadata + * {@inheritDoc} * * @since n.e.x.t - * @return array Provider metadata */ public function getProviderMetadata(): array { @@ -273,10 +270,9 @@ public function toMessages(): array } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index 31b2c22f..124e89fd 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -80,10 +80,9 @@ public function getTotalTokens(): int } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index 923b2308..b3c29038 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -80,10 +80,9 @@ public function getArgs(): array } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 955e05ce..978e6847 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -80,10 +80,9 @@ public function getParameters() } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 0b436bb8..b2a61cd7 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -80,10 +80,9 @@ public function getResponse() } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index 52ad99d0..f47a13e0 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -105,10 +105,9 @@ public function getWebSearch(): ?WebSearch } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php index abb87d9a..af40f456 100644 --- a/src/Tools/DTO/WebSearch.php +++ b/src/Tools/DTO/WebSearch.php @@ -62,10 +62,9 @@ public function getDisallowedDomains(): array } /** - * Get the JSON schema for this DTO + * {@inheritDoc} * * @since n.e.x.t - * @return array The JSON schema */ public static function getJsonSchema(): array { From 5414454c121e60827dc8a447ee2764bcebeca50d Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 23 Jul 2025 11:08:53 -0600 Subject: [PATCH 04/42] refactor: removes embedding DTOs for now --- src/Embeddings/DTO/Embedding.php | 54 --------- src/Operations/DTO/EmbeddingOperation.php | 112 ------------------ src/Results/DTO/EmbeddingResult.php | 138 ---------------------- 3 files changed, 304 deletions(-) delete mode 100644 src/Embeddings/DTO/Embedding.php delete mode 100644 src/Operations/DTO/EmbeddingOperation.php delete mode 100644 src/Results/DTO/EmbeddingResult.php diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php deleted file mode 100644 index 4601f394..00000000 --- a/src/Embeddings/DTO/Embedding.php +++ /dev/null @@ -1,54 +0,0 @@ -vector = $vector; - } - - /** - * Get the embedding vector - * - * @since n.e.x.t - * @return float[] The vector - */ - public function getVector(): array - { - return $this->vector; - } - - /** - * Get the dimension of the embedding - * - * @since n.e.x.t - * @return int The number of dimensions - */ - public function getDimension(): int - { - return count($this->vector); - } -} diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php deleted file mode 100644 index 5860d7b6..00000000 --- a/src/Operations/DTO/EmbeddingOperation.php +++ /dev/null @@ -1,112 +0,0 @@ -id = $id; - $this->state = $state; - $this->result = $result; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getId(): string - { - return $this->id; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getState(): OperationStateEnum - { - return $this->state; - } - - /** - * Get the operation result - * - * @since n.e.x.t - * @return EmbeddingResult|null The result or null if not yet complete - */ - public function getResult(): ?EmbeddingResult - { - return $this->result; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'string', - 'description' => 'Unique identifier for this operation', - ], - 'state' => [ - 'type' => 'string', - 'enum' => ['starting', 'processing', 'succeeded', 'failed', 'canceled'], - 'description' => 'The current state of the operation', - ], - 'result' => [ - 'oneOf' => [ - ['type' => 'null'], - EmbeddingResult::getJsonSchema(), - ], - 'description' => 'The result once the operation completes', - ], - ], - 'required' => ['id', 'state'], - ]; - } -} diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php deleted file mode 100644 index 69157778..00000000 --- a/src/Results/DTO/EmbeddingResult.php +++ /dev/null @@ -1,138 +0,0 @@ - Provider-specific metadata - */ - private array $providerMetadata; - - /** - * Constructor - * - * @since n.e.x.t - * @param string $id Unique identifier for this result - * @param Embedding[] $embeddings The generated embeddings - * @param TokenUsage $tokenUsage Token usage statistics - * @param array $providerMetadata Provider-specific metadata - */ - public function __construct(string $id, array $embeddings, TokenUsage $tokenUsage, array $providerMetadata = []) - { - $this->id = $id; - $this->embeddings = $embeddings; - $this->tokenUsage = $tokenUsage; - $this->providerMetadata = $providerMetadata; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getId(): string - { - return $this->id; - } - - /** - * Get the generated embeddings - * - * @since n.e.x.t - * @return Embedding[] The embeddings - */ - public function getEmbeddings(): array - { - return $this->embeddings; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getTokenUsage(): TokenUsage - { - return $this->tokenUsage; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getProviderMetadata(): array - { - return $this->providerMetadata; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'string', - 'description' => 'Unique identifier for this result', - ], - 'embeddings' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'vector' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'number', - ], - ], - ], - 'required' => ['vector'], - ], - 'description' => 'The generated embeddings', - ], - 'tokenUsage' => TokenUsage::getJsonSchema(), - 'providerMetadata' => [ - 'type' => 'object', - 'additionalProperties' => true, - 'description' => 'Provider-specific metadata', - ], - ], - 'required' => ['id', 'embeddings', 'tokenUsage'], - ]; - } -} From 5821bb298b3fd1c57a31c191374b3333e8f53a0e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 23 Jul 2025 13:04:49 -0600 Subject: [PATCH 05/42] chore: fixes doc formatting --- src/Common/Contracts/WithJsonSchema.php | 7 +- src/Files/Contracts/FileInterface.php | 6 +- src/Files/DTO/InlineFile.php | 27 +++--- src/Files/DTO/LocalFile.php | 27 +++--- src/Files/DTO/RemoteFile.php | 27 +++--- src/Messages/DTO/Message.php | 34 ++++--- src/Messages/DTO/MessagePart.php | 86 +++++++++-------- src/Messages/DTO/ModelMessage.php | 12 ++- src/Messages/DTO/SystemMessage.php | 12 ++- src/Messages/DTO/UserMessage.php | 12 ++- .../Contracts/OperationInterface.php | 12 ++- src/Operations/DTO/GenerativeAiOperation.php | 28 +++--- src/Results/Contracts/ResultInterface.php | 17 ++-- src/Results/DTO/Candidate.php | 36 ++++---- src/Results/DTO/GenerativeAiResult.php | 92 +++++++++++-------- src/Results/DTO/TokenUsage.php | 38 ++++---- src/Tools/DTO/FunctionCall.php | 38 ++++---- src/Tools/DTO/FunctionDeclaration.php | 38 ++++---- src/Tools/DTO/FunctionResponse.php | 38 ++++---- src/Tools/DTO/Tool.php | 44 +++++---- src/Tools/DTO/WebSearch.php | 27 +++--- 21 files changed, 369 insertions(+), 289 deletions(-) diff --git a/src/Common/Contracts/WithJsonSchema.php b/src/Common/Contracts/WithJsonSchema.php index 9feb14c4..3e64a754 100644 --- a/src/Common/Contracts/WithJsonSchema.php +++ b/src/Common/Contracts/WithJsonSchema.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Common\Contracts; /** - * Interface for objects that can provide their JSON schema representation + * Interface for objects that can provide their JSON schema representation. * * This interface is implemented by DTOs to provide a consistent way to retrieve * their JSON schema for validation and serialization purposes. @@ -15,10 +15,11 @@ interface WithJsonSchema { /** - * Get the JSON schema representation of the object + * Gets the JSON schema representation of the object. * * @since n.e.x.t - * @return array The JSON schema as an associative array + * + * @return array The JSON schema as an associative array. */ public static function getJsonSchema(): array; } diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php index 28747646..5a26c1fc 100644 --- a/src/Files/Contracts/FileInterface.php +++ b/src/Files/Contracts/FileInterface.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Files\Contracts; /** - * Interface for file representations in the AI client + * Interface for file representations in the AI client. * * This interface defines the common contract for various file types that can be * used as input or output in AI operations. @@ -15,10 +15,10 @@ interface FileInterface { /** - * Get the MIME type of the file + * Gets the MIME type of the file. * * @since n.e.x.t - * @return string The MIME type (e.g., 'image/png', 'audio/mp3') + * @return string The MIME type (e.g., 'image/png', 'audio/mp3'). */ public function getMimeType(): string; } diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index 00c9cbc8..22911ff1 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Files\Contracts\FileInterface; /** - * Represents a file with inline base64-encoded data + * Represents a file with inline base64-encoded data. * * This DTO is used for files that are embedded directly in the request as base64 data, * commonly used for small files or when direct data transfer is preferred. @@ -18,21 +18,22 @@ class InlineFile implements FileInterface, WithJsonSchema { /** - * @var string The MIME type of the file + * @var string The MIME type of the file. */ private string $mimeType; /** - * @var string The base64-encoded file data + * @var string The base64-encoded file data. */ private string $base64Data; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $mimeType The MIME type of the file - * @param string $base64Data The base64-encoded file data + * + * @param string $mimeType The MIME type of the file. + * @param string $base64Data The base64-encoded file data. */ public function __construct(string $mimeType, string $base64Data) { @@ -41,10 +42,11 @@ public function __construct(string $mimeType, string $base64Data) } /** - * Get the MIME type of the file + * Gets the MIME type of the file. * * @since n.e.x.t - * @return string The MIME type + * + * @return string The MIME type. */ public function getMimeType(): string { @@ -52,10 +54,11 @@ public function getMimeType(): string } /** - * Get the base64-encoded data + * Gets the base64-encoded data. * * @since n.e.x.t - * @return string The base64-encoded data + * + * @return string The base64-encoded data. */ public function getBase64Data(): string { @@ -74,11 +77,11 @@ public static function getJsonSchema(): array 'properties' => [ 'mimeType' => [ 'type' => 'string', - 'description' => 'The MIME type of the file', + 'description' => 'The MIME type of the file.', ], 'base64Data' => [ 'type' => 'string', - 'description' => 'The base64-encoded file data', + 'description' => 'The base64-encoded file data.', ], ], 'required' => ['mimeType', 'base64Data'], diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index 3e8dc491..88cb4396 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Files\Contracts\FileInterface; /** - * Represents a file stored locally on the filesystem + * Represents a file stored locally on the filesystem. * * This DTO is used for files that are referenced by their local path, * typically used when working with files already present on the server. @@ -18,21 +18,22 @@ class LocalFile implements FileInterface, WithJsonSchema { /** - * @var string The MIME type of the file + * @var string The MIME type of the file. */ private string $mimeType; /** - * @var string The local filesystem path to the file + * @var string The local filesystem path to the file. */ private string $path; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $mimeType The MIME type of the file - * @param string $path The local filesystem path to the file + * + * @param string $mimeType The MIME type of the file. + * @param string $path The local filesystem path to the file. */ public function __construct(string $mimeType, string $path) { @@ -41,10 +42,11 @@ public function __construct(string $mimeType, string $path) } /** - * Get the MIME type of the file + * Gets the MIME type of the file. * * @since n.e.x.t - * @return string The MIME type + * + * @return string The MIME type. */ public function getMimeType(): string { @@ -52,10 +54,11 @@ public function getMimeType(): string } /** - * Get the local filesystem path + * Gets the local filesystem path. * * @since n.e.x.t - * @return string The local path + * + * @return string The local path. */ public function getPath(): string { @@ -74,11 +77,11 @@ public static function getJsonSchema(): array 'properties' => [ 'mimeType' => [ 'type' => 'string', - 'description' => 'The MIME type of the file', + 'description' => 'The MIME type of the file.', ], 'path' => [ 'type' => 'string', - 'description' => 'The local filesystem path to the file', + 'description' => 'The local filesystem path to the file.', ], ], 'required' => ['mimeType', 'path'], diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index e236f939..d500ec39 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Files\Contracts\FileInterface; /** - * Represents a file accessible via a remote URL + * Represents a file accessible via a remote URL. * * This DTO is used for files that are hosted remotely and accessed via HTTP/HTTPS, * commonly used for media files stored on CDNs or external services. @@ -18,21 +18,22 @@ class RemoteFile implements FileInterface, WithJsonSchema { /** - * @var string The MIME type of the file + * @var string The MIME type of the file. */ private string $mimeType; /** - * @var string The URL to the remote file + * @var string The URL to the remote file. */ private string $url; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $mimeType The MIME type of the file - * @param string $url The URL to the remote file + * + * @param string $mimeType The MIME type of the file. + * @param string $url The URL to the remote file. */ public function __construct(string $mimeType, string $url) { @@ -41,10 +42,11 @@ public function __construct(string $mimeType, string $url) } /** - * Get the MIME type of the file + * Gets the MIME type of the file. * * @since n.e.x.t - * @return string The MIME type + * + * @return string The MIME type. */ public function getMimeType(): string { @@ -52,10 +54,11 @@ public function getMimeType(): string } /** - * Get the URL to the remote file + * Gets the URL to the remote file. * * @since n.e.x.t - * @return string The URL + * + * @return string The URL. */ public function getUrl(): string { @@ -74,12 +77,12 @@ public static function getJsonSchema(): array 'properties' => [ 'mimeType' => [ 'type' => 'string', - 'description' => 'The MIME type of the file', + 'description' => 'The MIME type of the file.', ], 'url' => [ 'type' => 'string', 'format' => 'uri', - 'description' => 'The URL to the remote file', + 'description' => 'The URL to the remote file.', ], ], 'required' => ['mimeType', 'url'], diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 4f5fff7a..913a3541 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** - * Represents a message in an AI conversation + * Represents a message in an AI conversation. * * Messages are the fundamental unit of communication with AI models, * containing a role and one or more parts with different content types. @@ -18,21 +18,22 @@ class Message implements WithJsonSchema { /** - * @var MessageRoleEnum The role of the message sender + * @var MessageRoleEnum The role of the message sender. */ protected MessageRoleEnum $role; /** - * @var MessagePart[] The parts that make up this message + * @var MessagePart[] The parts that make up this message. */ protected array $parts; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param MessageRoleEnum $role The role of the message sender - * @param MessagePart[] $parts The parts that make up this message + * + * @param MessageRoleEnum $role The role of the message sender. + * @param MessagePart[] $parts The parts that make up this message. */ public function __construct(MessageRoleEnum $role, array $parts) { @@ -41,11 +42,12 @@ public function __construct(MessageRoleEnum $role, array $parts) } /** - * Create a message from a simple text string + * Creates a message from a simple text string. * * @since n.e.x.t - * @param MessageRoleEnum $role The role of the message sender - * @param string $text The text content + * + * @param MessageRoleEnum $role The role of the message sender. + * @param string $text The text content. * @return self */ public static function fromText(MessageRoleEnum $role, string $text): self @@ -54,10 +56,11 @@ public static function fromText(MessageRoleEnum $role, string $text): self } /** - * Get the role of the message sender + * Gets the role of the message sender. * * @since n.e.x.t - * @return MessageRoleEnum The role + * + * @return MessageRoleEnum The role. */ public function getRole(): MessageRoleEnum { @@ -65,10 +68,11 @@ public function getRole(): MessageRoleEnum } /** - * Get the message parts + * Gets the message parts. * * @since n.e.x.t - * @return MessagePart[] The message parts + * + * @return MessagePart[] The message parts. */ public function getParts(): array { @@ -88,13 +92,13 @@ public static function getJsonSchema(): array 'role' => [ 'type' => 'string', 'enum' => ['user', 'model', 'system'], - 'description' => 'The role of the message sender', + 'description' => 'The role of the message sender.', ], 'parts' => [ 'type' => 'array', 'items' => MessagePart::getJsonSchema(), 'minItems' => 1, - 'description' => 'The parts that make up this message', + 'description' => 'The parts that make up this message.', ], ], 'required' => ['role', 'parts'], diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 76b4ea15..6d79ee28 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -12,7 +12,7 @@ use WordPress\AiClient\Tools\DTO\FunctionResponse; /** - * Represents a part of a message + * Represents a part of a message. * * Messages can contain multiple parts of different types, such as text, files, * function calls, etc. This DTO encapsulates one such part. @@ -22,40 +22,41 @@ class MessagePart implements WithJsonSchema { /** - * @var MessagePartTypeEnum The type of this message part + * @var MessagePartTypeEnum The type of this message part. */ private MessagePartTypeEnum $type; /** - * @var string|null Text content (when type is TEXT) + * @var string|null Text content (when type is TEXT). */ private ?string $text = null; /** - * @var InlineFile|null Inline file data (when type is INLINE_FILE) + * @var InlineFile|null Inline file data (when type is INLINE_FILE). */ private ?InlineFile $inlineFile = null; /** - * @var RemoteFile|null Remote file reference (when type is REMOTE_FILE) + * @var RemoteFile|null Remote file reference (when type is REMOTE_FILE). */ private ?RemoteFile $remoteFile = null; /** - * @var FunctionCall|null Function call request (when type is FUNCTION_CALL) + * @var FunctionCall|null Function call request (when type is FUNCTION_CALL). */ private ?FunctionCall $functionCall = null; /** - * @var FunctionResponse|null Function response (when type is FUNCTION_RESPONSE) + * @var FunctionResponse|null Function response (when type is FUNCTION_RESPONSE). */ private ?FunctionResponse $functionResponse = null; /** - * Private constructor to enforce factory method usage + * Private constructor to enforce factory method usage. * * @since n.e.x.t - * @param MessagePartTypeEnum $type The type of this message part + * + * @param MessagePartTypeEnum $type The type of this message part. */ private function __construct(MessagePartTypeEnum $type) { @@ -63,10 +64,11 @@ private function __construct(MessagePartTypeEnum $type) } /** - * Create a text message part + * Creates a text message part. * * @since n.e.x.t - * @param string $text The text content + * + * @param string $text The text content. * @return self */ public static function text(string $text): self @@ -77,10 +79,11 @@ public static function text(string $text): self } /** - * Create an inline file message part + * Creates an inline file message part. * * @since n.e.x.t - * @param InlineFile $file The inline file + * + * @param InlineFile $file The inline file. * @return self */ public static function inlineFile(InlineFile $file): self @@ -91,10 +94,11 @@ public static function inlineFile(InlineFile $file): self } /** - * Create a remote file message part + * Creates a remote file message part. * * @since n.e.x.t - * @param RemoteFile $file The remote file + * + * @param RemoteFile $file The remote file. * @return self */ public static function remoteFile(RemoteFile $file): self @@ -105,10 +109,11 @@ public static function remoteFile(RemoteFile $file): self } /** - * Create a function call message part + * Creates a function call message part. * * @since n.e.x.t - * @param FunctionCall $functionCall The function call + * + * @param FunctionCall $functionCall The function call. * @return self */ public static function functionCall(FunctionCall $functionCall): self @@ -119,10 +124,11 @@ public static function functionCall(FunctionCall $functionCall): self } /** - * Create a function response message part + * Creates a function response message part. * * @since n.e.x.t - * @param FunctionResponse $functionResponse The function response + * + * @param FunctionResponse $functionResponse The function response. * @return self */ public static function functionResponse(FunctionResponse $functionResponse): self @@ -133,10 +139,11 @@ public static function functionResponse(FunctionResponse $functionResponse): sel } /** - * Get the type of this message part + * Gets the type of this message part. * * @since n.e.x.t - * @return MessagePartTypeEnum The type + * + * @return MessagePartTypeEnum The type. */ public function getType(): MessagePartTypeEnum { @@ -144,10 +151,11 @@ public function getType(): MessagePartTypeEnum } /** - * Get the text content + * Gets the text content. * * @since n.e.x.t - * @return string|null The text content or null if not a text part + * + * @return string|null The text content or null if not a text part. */ public function getText(): ?string { @@ -155,10 +163,11 @@ public function getText(): ?string } /** - * Get the inline file + * Gets the inline file. * * @since n.e.x.t - * @return InlineFile|null The inline file or null if not an inline file part + * + * @return InlineFile|null The inline file or null if not an inline file part. */ public function getInlineFile(): ?InlineFile { @@ -166,10 +175,11 @@ public function getInlineFile(): ?InlineFile } /** - * Get the remote file + * Gets the remote file. * * @since n.e.x.t - * @return RemoteFile|null The remote file or null if not a remote file part + * + * @return RemoteFile|null The remote file or null if not a remote file part. */ public function getRemoteFile(): ?RemoteFile { @@ -177,10 +187,11 @@ public function getRemoteFile(): ?RemoteFile } /** - * Get the function call + * Gets the function call. * * @since n.e.x.t - * @return FunctionCall|null The function call or null if not a function call part + * + * @return FunctionCall|null The function call or null if not a function call part. */ public function getFunctionCall(): ?FunctionCall { @@ -188,10 +199,11 @@ public function getFunctionCall(): ?FunctionCall } /** - * Get the function response + * Gets the function response. * * @since n.e.x.t - * @return FunctionResponse|null The function response or null if not a function response part + * + * @return FunctionResponse|null The function response or null if not a function response part. */ public function getFunctionResponse(): ?FunctionResponse { @@ -211,39 +223,39 @@ public static function getJsonSchema(): array 'type' => [ 'type' => 'string', 'enum' => ['text', 'inline_file', 'remote_file', 'function_call', 'function_response'], - 'description' => 'The type of this message part', + 'description' => 'The type of this message part.', ], 'text' => [ 'type' => ['string', 'null'], - 'description' => 'Text content (when type is text)', + 'description' => 'Text content (when type is text).', ], 'inlineFile' => [ 'oneOf' => [ ['type' => 'null'], InlineFile::getJsonSchema(), ], - 'description' => 'Inline file data (when type is inline_file)', + 'description' => 'Inline file data (when type is inline_file).', ], 'remoteFile' => [ 'oneOf' => [ ['type' => 'null'], RemoteFile::getJsonSchema(), ], - 'description' => 'Remote file reference (when type is remote_file)', + 'description' => 'Remote file reference (when type is remote_file).', ], 'functionCall' => [ 'oneOf' => [ ['type' => 'null'], FunctionCall::getJsonSchema(), ], - 'description' => 'Function call request (when type is function_call)', + 'description' => 'Function call request (when type is function_call).', ], 'functionResponse' => [ 'oneOf' => [ ['type' => 'null'], FunctionResponse::getJsonSchema(), ], - 'description' => 'Function response (when type is function_response)', + 'description' => 'Function response (when type is function_response).', ], ], 'required' => ['type'], diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php index b90857e2..6e44c324 100644 --- a/src/Messages/DTO/ModelMessage.php +++ b/src/Messages/DTO/ModelMessage.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** - * Represents a message from the AI model + * Represents a message from the AI model. * * This is a convenience class that automatically sets the role to MODEL. * Model messages contain the AI's responses. @@ -17,10 +17,11 @@ class ModelMessage extends Message { /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param MessagePart[] $parts The parts that make up this message + * + * @param MessagePart[] $parts The parts that make up this message. */ public function __construct(array $parts) { @@ -28,10 +29,11 @@ public function __construct(array $parts) } /** - * Create a model message from a simple text string + * Creates a model message from a simple text string. * * @since n.e.x.t - * @param string $text The text content + * + * @param string $text The text content. * @return self */ public static function text(string $text): self diff --git a/src/Messages/DTO/SystemMessage.php b/src/Messages/DTO/SystemMessage.php index d0eea598..a2b64173 100644 --- a/src/Messages/DTO/SystemMessage.php +++ b/src/Messages/DTO/SystemMessage.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** - * Represents a system instruction message + * Represents a system instruction message. * * This is a convenience class that automatically sets the role to SYSTEM. * System messages are typically used to provide instructions or context to the AI model. @@ -17,10 +17,11 @@ class SystemMessage extends Message { /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param MessagePart[] $parts The parts that make up this message + * + * @param MessagePart[] $parts The parts that make up this message. */ public function __construct(array $parts) { @@ -28,10 +29,11 @@ public function __construct(array $parts) } /** - * Create a system message from a simple text string + * Creates a system message from a simple text string. * * @since n.e.x.t - * @param string $text The text content + * + * @param string $text The text content. * @return self */ public static function text(string $text): self diff --git a/src/Messages/DTO/UserMessage.php b/src/Messages/DTO/UserMessage.php index bb70a0e1..650ac038 100644 --- a/src/Messages/DTO/UserMessage.php +++ b/src/Messages/DTO/UserMessage.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** - * Represents a message from a user + * Represents a message from a user. * * This is a convenience class that automatically sets the role to USER. * @@ -16,10 +16,11 @@ class UserMessage extends Message { /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param MessagePart[] $parts The parts that make up this message + * + * @param MessagePart[] $parts The parts that make up this message. */ public function __construct(array $parts) { @@ -27,10 +28,11 @@ public function __construct(array $parts) } /** - * Create a user message from a simple text string + * Creates a user message from a simple text string. * * @since n.e.x.t - * @param string $text The text content + * + * @param string $text The text content. * @return self */ public static function text(string $text): self diff --git a/src/Operations/Contracts/OperationInterface.php b/src/Operations/Contracts/OperationInterface.php index 5865ead2..d146eb24 100644 --- a/src/Operations/Contracts/OperationInterface.php +++ b/src/Operations/Contracts/OperationInterface.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Operations\Enums\OperationStateEnum; /** - * Interface for AI operations + * Interface for AI operations. * * Operations represent long-running AI tasks that may not complete immediately. * They provide a way to track the progress and retrieve results asynchronously. @@ -18,18 +18,20 @@ interface OperationInterface extends WithJsonSchema { /** - * Get the operation ID + * Gets the operation ID. * * @since n.e.x.t - * @return string The unique operation identifier + * + * @return string The unique operation identifier. */ public function getId(): string; /** - * Get the current state of the operation + * Gets the current state of the operation. * * @since n.e.x.t - * @return OperationStateEnum The operation state + * + * @return OperationStateEnum The operation state. */ public function getState(): OperationStateEnum; } diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index bdd94c4b..0a130148 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -9,7 +9,7 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; /** - * Represents a long-running generative AI operation + * Represents a long-running generative AI operation. * * This DTO tracks the progress of generative AI tasks that may not complete * immediately, providing access to the result once available. @@ -19,27 +19,28 @@ class GenerativeAiOperation implements OperationInterface { /** - * @var string Unique identifier for this operation + * @var string Unique identifier for this operation. */ private string $id; /** - * @var OperationStateEnum The current state of the operation + * @var OperationStateEnum The current state of the operation. */ private OperationStateEnum $state; /** - * @var GenerativeAiResult|null The result once the operation completes + * @var GenerativeAiResult|null The result once the operation completes. */ private ?GenerativeAiResult $result; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $id Unique identifier for this operation - * @param OperationStateEnum $state The current state of the operation - * @param GenerativeAiResult|null $result The result once the operation completes + * + * @param string $id Unique identifier for this operation. + * @param OperationStateEnum $state The current state of the operation. + * @param GenerativeAiResult|null $result The result once the operation completes. */ public function __construct(string $id, OperationStateEnum $state, ?GenerativeAiResult $result = null) { @@ -69,10 +70,11 @@ public function getState(): OperationStateEnum } /** - * Get the operation result + * Gets the operation result. * * @since n.e.x.t - * @return GenerativeAiResult|null The result or null if not yet complete + * + * @return GenerativeAiResult|null The result or null if not yet complete. */ public function getResult(): ?GenerativeAiResult { @@ -91,19 +93,19 @@ public static function getJsonSchema(): array 'properties' => [ 'id' => [ 'type' => 'string', - 'description' => 'Unique identifier for this operation', + 'description' => 'Unique identifier for this operation.', ], 'state' => [ 'type' => 'string', 'enum' => ['starting', 'processing', 'succeeded', 'failed', 'canceled'], - 'description' => 'The current state of the operation', + 'description' => 'The current state of the operation.', ], 'result' => [ 'oneOf' => [ ['type' => 'null'], GenerativeAiResult::getJsonSchema(), ], - 'description' => 'The result once the operation completes', + 'description' => 'The result once the operation completes.', ], ], 'required' => ['id', 'state'], diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php index 42ac6de4..ea2aae69 100644 --- a/src/Results/Contracts/ResultInterface.php +++ b/src/Results/Contracts/ResultInterface.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Results\DTO\TokenUsage; /** - * Interface for AI operation results + * Interface for AI operation results. * * Results contain the output from AI operations along with metadata * such as token usage and provider-specific information. @@ -18,26 +18,29 @@ interface ResultInterface extends WithJsonSchema { /** - * Get the result ID + * Gets the result ID. * * @since n.e.x.t - * @return string The unique result identifier + * + * @return string The unique result identifier. */ public function getId(): string; /** - * Get token usage information + * Gets token usage information. * * @since n.e.x.t - * @return TokenUsage Token usage statistics + * + * @return TokenUsage Token usage statistics. */ public function getTokenUsage(): TokenUsage; /** - * Get provider-specific metadata + * Gets provider-specific metadata. * * @since n.e.x.t - * @return array Provider metadata + * + * @return array Provider metadata. */ public function getProviderMetadata(): array; } diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 448aab30..7073193e 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -9,7 +9,7 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** - * Represents a candidate response from an AI model + * Represents a candidate response from an AI model. * * When generating content, AI models can produce multiple candidates. * Each candidate contains a message and metadata about why generation stopped. @@ -19,27 +19,28 @@ class Candidate implements WithJsonSchema { /** - * @var Message The generated message + * @var Message The generated message. */ private Message $message; /** - * @var FinishReasonEnum The reason generation stopped + * @var FinishReasonEnum The reason generation stopped. */ private FinishReasonEnum $finishReason; /** - * @var int The number of tokens in this candidate + * @var int The number of tokens in this candidate. */ private int $tokenCount; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param Message $message The generated message - * @param FinishReasonEnum $finishReason The reason generation stopped - * @param int $tokenCount The number of tokens in this candidate + * + * @param Message $message The generated message. + * @param FinishReasonEnum $finishReason The reason generation stopped. + * @param int $tokenCount The number of tokens in this candidate. */ public function __construct(Message $message, FinishReasonEnum $finishReason, int $tokenCount) { @@ -49,10 +50,11 @@ public function __construct(Message $message, FinishReasonEnum $finishReason, in } /** - * Get the generated message + * Gets the generated message. * * @since n.e.x.t - * @return Message The message + * + * @return Message The message. */ public function getMessage(): Message { @@ -60,10 +62,11 @@ public function getMessage(): Message } /** - * Get the finish reason + * Gets the finish reason. * * @since n.e.x.t - * @return FinishReasonEnum The finish reason + * + * @return FinishReasonEnum The finish reason. */ public function getFinishReason(): FinishReasonEnum { @@ -71,10 +74,11 @@ public function getFinishReason(): FinishReasonEnum } /** - * Get the token count + * Gets the token count. * * @since n.e.x.t - * @return int The token count + * + * @return int The token count. */ public function getTokenCount(): int { @@ -95,11 +99,11 @@ public static function getJsonSchema(): array 'finishReason' => [ 'type' => 'string', 'enum' => ['stop', 'length', 'content_filter', 'tool_calls', 'error'], - 'description' => 'The reason generation stopped', + 'description' => 'The reason generation stopped.', ], 'tokenCount' => [ 'type' => 'integer', - 'description' => 'The number of tokens in this candidate', + 'description' => 'The number of tokens in this candidate.', ], ], 'required' => ['message', 'finishReason', 'tokenCount'], diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 628cb605..257cdca3 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -10,7 +10,7 @@ use WordPress\AiClient\Results\Contracts\ResultInterface; /** - * Represents the result of a generative AI operation + * Represents the result of a generative AI operation. * * This DTO contains the generated candidates along with usage statistics * and metadata from the AI provider. @@ -20,33 +20,34 @@ class GenerativeAiResult implements ResultInterface { /** - * @var string Unique identifier for this result + * @var string Unique identifier for this result. */ private string $id; /** - * @var Candidate[] The generated candidates + * @var Candidate[] The generated candidates. */ private array $candidates; /** - * @var TokenUsage Token usage statistics + * @var TokenUsage Token usage statistics. */ private TokenUsage $tokenUsage; /** - * @var array Provider-specific metadata + * @var array Provider-specific metadata. */ private array $providerMetadata; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $id Unique identifier for this result - * @param Candidate[] $candidates The generated candidates - * @param TokenUsage $tokenUsage Token usage statistics - * @param array $providerMetadata Provider-specific metadata + * + * @param string $id Unique identifier for this result. + * @param Candidate[] $candidates The generated candidates. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param array $providerMetadata Provider-specific metadata. */ public function __construct(string $id, array $candidates, TokenUsage $tokenUsage, array $providerMetadata = []) { @@ -67,10 +68,11 @@ public function getId(): string } /** - * Get the generated candidates + * Gets the generated candidates. * * @since n.e.x.t - * @return Candidate[] The candidates + * + * @return Candidate[] The candidates. */ public function getCandidates(): array { @@ -98,11 +100,12 @@ public function getProviderMetadata(): array } /** - * Convert the first candidate to text + * Converts the first candidate to text. * * @since n.e.x.t - * @return string The text content - * @throws \RuntimeException If no candidates or no text content + * + * @return string The text content. + * @throws \RuntimeException If no candidates or no text content. */ public function toText(): string { @@ -121,11 +124,12 @@ public function toText(): string } /** - * Convert the first candidate to an image file + * Converts the first candidate to an image file. * * @since n.e.x.t - * @return FileInterface The image file - * @throws \RuntimeException If no candidates or no image content + * + * @return FileInterface The image file. + * @throws \RuntimeException If no candidates or no image content. */ public function toImageFile(): FileInterface { @@ -147,11 +151,12 @@ public function toImageFile(): FileInterface } /** - * Convert the first candidate to an audio file + * Converts the first candidate to an audio file. * * @since n.e.x.t - * @return FileInterface The audio file - * @throws \RuntimeException If no candidates or no audio content + * + * @return FileInterface The audio file. + * @throws \RuntimeException If no candidates or no audio content. */ public function toAudioFile(): FileInterface { @@ -160,11 +165,12 @@ public function toAudioFile(): FileInterface } /** - * Convert the first candidate to a video file + * Converts the first candidate to a video file. * * @since n.e.x.t - * @return FileInterface The video file - * @throws \RuntimeException If no candidates or no video content + * + * @return FileInterface The video file. + * @throws \RuntimeException If no candidates or no video content. */ public function toVideoFile(): FileInterface { @@ -173,11 +179,12 @@ public function toVideoFile(): FileInterface } /** - * Convert the first candidate to a message + * Converts the first candidate to a message. * * @since n.e.x.t - * @return Message The message - * @throws \RuntimeException If no candidates available + * + * @return Message The message. + * @throws \RuntimeException If no candidates available. */ public function toMessage(): Message { @@ -189,10 +196,11 @@ public function toMessage(): Message } /** - * Convert all candidates to text array + * Converts all candidates to text array. * * @since n.e.x.t - * @return string[] Array of text content + * + * @return string[] Array of text content. */ public function toTexts(): array { @@ -210,10 +218,11 @@ public function toTexts(): array } /** - * Convert all candidates to image files + * Converts all candidates to image files. * * @since n.e.x.t - * @return FileInterface[] Array of image files + * + * @return FileInterface[] Array of image files. */ public function toImageFiles(): array { @@ -235,10 +244,11 @@ public function toImageFiles(): array } /** - * Convert all candidates to audio files + * Converts all candidates to audio files. * * @since n.e.x.t - * @return FileInterface[] Array of audio files + * + * @return FileInterface[] Array of audio files. */ public function toAudioFiles(): array { @@ -247,10 +257,11 @@ public function toAudioFiles(): array } /** - * Convert all candidates to video files + * Converts all candidates to video files. * * @since n.e.x.t - * @return FileInterface[] Array of video files + * + * @return FileInterface[] Array of video files. */ public function toVideoFiles(): array { @@ -259,10 +270,11 @@ public function toVideoFiles(): array } /** - * Convert all candidates to messages + * Converts all candidates to messages. * * @since n.e.x.t - * @return Message[] Array of messages + * + * @return Message[] Array of messages. */ public function toMessages(): array { @@ -281,18 +293,18 @@ public static function getJsonSchema(): array 'properties' => [ 'id' => [ 'type' => 'string', - 'description' => 'Unique identifier for this result', + 'description' => 'Unique identifier for this result.', ], 'candidates' => [ 'type' => 'array', 'items' => Candidate::getJsonSchema(), - 'description' => 'The generated candidates', + 'description' => 'The generated candidates.', ], 'tokenUsage' => TokenUsage::getJsonSchema(), 'providerMetadata' => [ 'type' => 'object', 'additionalProperties' => true, - 'description' => 'Provider-specific metadata', + 'description' => 'Provider-specific metadata.', ], ], 'required' => ['id', 'candidates', 'tokenUsage'], diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index 124e89fd..4e576a7c 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchema; /** - * Represents token usage statistics for an AI operation + * Represents token usage statistics for an AI operation. * * This DTO tracks the number of tokens used in prompts and completions, * which is important for monitoring usage and costs. @@ -17,27 +17,28 @@ class TokenUsage implements WithJsonSchema { /** - * @var int Number of tokens in the prompt + * @var int Number of tokens in the prompt. */ private int $promptTokens; /** - * @var int Number of tokens in the completion + * @var int Number of tokens in the completion. */ private int $completionTokens; /** - * @var int Total number of tokens used + * @var int Total number of tokens used. */ private int $totalTokens; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param int $promptTokens Number of tokens in the prompt - * @param int $completionTokens Number of tokens in the completion - * @param int $totalTokens Total number of tokens used + * + * @param int $promptTokens Number of tokens in the prompt. + * @param int $completionTokens Number of tokens in the completion. + * @param int $totalTokens Total number of tokens used. */ public function __construct(int $promptTokens, int $completionTokens, int $totalTokens) { @@ -47,10 +48,11 @@ public function __construct(int $promptTokens, int $completionTokens, int $total } /** - * Get the number of prompt tokens + * Gets the number of prompt tokens. * * @since n.e.x.t - * @return int The prompt token count + * + * @return int The prompt token count. */ public function getPromptTokens(): int { @@ -58,10 +60,11 @@ public function getPromptTokens(): int } /** - * Get the number of completion tokens + * Gets the number of completion tokens. * * @since n.e.x.t - * @return int The completion token count + * + * @return int The completion token count. */ public function getCompletionTokens(): int { @@ -69,10 +72,11 @@ public function getCompletionTokens(): int } /** - * Get the total number of tokens + * Gets the total number of tokens. * * @since n.e.x.t - * @return int The total token count + * + * @return int The total token count. */ public function getTotalTokens(): int { @@ -91,15 +95,15 @@ public static function getJsonSchema(): array 'properties' => [ 'promptTokens' => [ 'type' => 'integer', - 'description' => 'Number of tokens in the prompt', + 'description' => 'Number of tokens in the prompt.', ], 'completionTokens' => [ 'type' => 'integer', - 'description' => 'Number of tokens in the completion', + 'description' => 'Number of tokens in the completion.', ], 'totalTokens' => [ 'type' => 'integer', - 'description' => 'Total number of tokens used', + 'description' => 'Total number of tokens used.', ], ], 'required' => ['promptTokens', 'completionTokens', 'totalTokens'], diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index b3c29038..8550ef04 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchema; /** - * Represents a function call request from an AI model + * Represents a function call request from an AI model. * * This DTO encapsulates information about a function that the AI model * wants to invoke, including the function name and its arguments. @@ -17,27 +17,28 @@ class FunctionCall implements WithJsonSchema { /** - * @var string Unique identifier for this function call + * @var string Unique identifier for this function call. */ private string $id; /** - * @var string The name of the function to call + * @var string The name of the function to call. */ private string $name; /** - * @var array The arguments to pass to the function + * @var array The arguments to pass to the function. */ private array $args; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $id Unique identifier for this function call - * @param string $name The name of the function to call - * @param array $args The arguments to pass to the function + * + * @param string $id Unique identifier for this function call. + * @param string $name The name of the function to call. + * @param array $args The arguments to pass to the function. */ public function __construct(string $id, string $name, array $args) { @@ -47,10 +48,11 @@ public function __construct(string $id, string $name, array $args) } /** - * Get the function call ID + * Gets the function call ID. * * @since n.e.x.t - * @return string The unique identifier + * + * @return string The unique identifier. */ public function getId(): string { @@ -58,10 +60,11 @@ public function getId(): string } /** - * Get the function name + * Gets the function name. * * @since n.e.x.t - * @return string The function name + * + * @return string The function name. */ public function getName(): string { @@ -69,10 +72,11 @@ public function getName(): string } /** - * Get the function arguments + * Gets the function arguments. * * @since n.e.x.t - * @return array The function arguments + * + * @return array The function arguments. */ public function getArgs(): array { @@ -91,15 +95,15 @@ public static function getJsonSchema(): array 'properties' => [ 'id' => [ 'type' => 'string', - 'description' => 'Unique identifier for this function call', + 'description' => 'Unique identifier for this function call.', ], 'name' => [ 'type' => 'string', - 'description' => 'The name of the function to call', + 'description' => 'The name of the function to call.', ], 'args' => [ 'type' => 'object', - 'description' => 'The arguments to pass to the function', + 'description' => 'The arguments to pass to the function.', 'additionalProperties' => true, ], ], diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 978e6847..ec1ec3b2 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchema; /** - * Represents a function declaration for AI models + * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, * including its name, description, and parameter schema. @@ -17,27 +17,28 @@ class FunctionDeclaration implements WithJsonSchema { /** - * @var string The name of the function + * @var string The name of the function. */ private string $name; /** - * @var string A description of what the function does + * @var string A description of what the function does. */ private string $description; /** - * @var mixed The JSON schema for the function parameters + * @var mixed The JSON schema for the function parameters. */ private $parameters; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $name The name of the function - * @param string $description A description of what the function does - * @param mixed $parameters The JSON schema for the function parameters + * + * @param string $name The name of the function. + * @param string $description A description of what the function does. + * @param mixed $parameters The JSON schema for the function parameters. */ public function __construct(string $name, string $description, $parameters) { @@ -47,10 +48,11 @@ public function __construct(string $name, string $description, $parameters) } /** - * Get the function name + * Gets the function name. * * @since n.e.x.t - * @return string The function name + * + * @return string The function name. */ public function getName(): string { @@ -58,10 +60,11 @@ public function getName(): string } /** - * Get the function description + * Gets the function description. * * @since n.e.x.t - * @return string The function description + * + * @return string The function description. */ public function getDescription(): string { @@ -69,10 +72,11 @@ public function getDescription(): string } /** - * Get the function parameters schema + * Gets the function parameters schema. * * @since n.e.x.t - * @return mixed The parameters schema + * + * @return mixed The parameters schema. */ public function getParameters() { @@ -91,15 +95,15 @@ public static function getJsonSchema(): array 'properties' => [ 'name' => [ 'type' => 'string', - 'description' => 'The name of the function', + 'description' => 'The name of the function.', ], 'description' => [ 'type' => 'string', - 'description' => 'A description of what the function does', + 'description' => 'A description of what the function does.', ], 'parameters' => [ 'type' => 'object', - 'description' => 'The JSON schema for the function parameters', + 'description' => 'The JSON schema for the function parameters.', ], ], 'required' => ['name', 'description', 'parameters'], diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index b2a61cd7..f9f55399 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchema; /** - * Represents a response to a function call + * Represents a response to a function call. * * This DTO encapsulates the result of executing a function that was * requested by the AI model through a FunctionCall. @@ -17,27 +17,28 @@ class FunctionResponse implements WithJsonSchema { /** - * @var string The ID of the function call this is responding to + * @var string The ID of the function call this is responding to. */ private string $id; /** - * @var string The name of the function that was called + * @var string The name of the function that was called. */ private string $name; /** - * @var mixed The response data from the function + * @var mixed The response data from the function. */ private $response; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string $id The ID of the function call this is responding to - * @param string $name The name of the function that was called - * @param mixed $response The response data from the function + * + * @param string $id The ID of the function call this is responding to. + * @param string $name The name of the function that was called. + * @param mixed $response The response data from the function. */ public function __construct(string $id, string $name, $response) { @@ -47,10 +48,11 @@ public function __construct(string $id, string $name, $response) } /** - * Get the function call ID + * Gets the function call ID. * * @since n.e.x.t - * @return string The function call ID + * + * @return string The function call ID. */ public function getId(): string { @@ -58,10 +60,11 @@ public function getId(): string } /** - * Get the function name + * Gets the function name. * * @since n.e.x.t - * @return string The function name + * + * @return string The function name. */ public function getName(): string { @@ -69,10 +72,11 @@ public function getName(): string } /** - * Get the function response + * Gets the function response. * * @since n.e.x.t - * @return mixed The response data + * + * @return mixed The response data. */ public function getResponse() { @@ -91,14 +95,14 @@ public static function getJsonSchema(): array 'properties' => [ 'id' => [ 'type' => 'string', - 'description' => 'The ID of the function call this is responding to', + 'description' => 'The ID of the function call this is responding to.', ], 'name' => [ 'type' => 'string', - 'description' => 'The name of the function that was called', + 'description' => 'The name of the function that was called.', ], 'response' => [ - 'description' => 'The response data from the function', + 'description' => 'The response data from the function.', ], ], 'required' => ['id', 'name', 'response'], diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index f47a13e0..11b20389 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -8,7 +8,7 @@ use WordPress\AiClient\Providers\Enums\ToolTypeEnum; /** - * Represents a tool configuration for AI models + * Represents a tool configuration for AI models. * * Tools allow AI models to perform actions beyond text generation, * such as calling functions or performing web searches. @@ -18,25 +18,26 @@ class Tool implements WithJsonSchema { /** - * @var ToolTypeEnum The type of tool + * @var ToolTypeEnum The type of tool. */ private ToolTypeEnum $type; /** - * @var FunctionDeclaration[]|null Function declarations (when type is FUNCTION_DECLARATIONS) + * @var FunctionDeclaration[]|null Function declarations (when type is FUNCTION_DECLARATIONS). */ private ?array $functionDeclarations = null; /** - * @var WebSearch|null Web search configuration (when type is WEB_SEARCH) + * @var WebSearch|null Web search configuration (when type is WEB_SEARCH). */ private ?WebSearch $webSearch = null; /** - * Private constructor to enforce factory method usage + * Private constructor to enforce factory method usage. * * @since n.e.x.t - * @param ToolTypeEnum $type The type of tool + * + * @param ToolTypeEnum $type The type of tool. */ private function __construct(ToolTypeEnum $type) { @@ -44,10 +45,11 @@ private function __construct(ToolTypeEnum $type) } /** - * Create a function declarations tool + * Creates a function declarations tool. * * @since n.e.x.t - * @param FunctionDeclaration[] $declarations The function declarations + * + * @param FunctionDeclaration[] $declarations The function declarations. * @return self */ public static function functionDeclarations(array $declarations): self @@ -58,10 +60,11 @@ public static function functionDeclarations(array $declarations): self } /** - * Create a web search tool + * Creates a web search tool. * * @since n.e.x.t - * @param WebSearch $webSearch The web search configuration + * + * @param WebSearch $webSearch The web search configuration. * @return self */ public static function webSearch(WebSearch $webSearch): self @@ -72,10 +75,11 @@ public static function webSearch(WebSearch $webSearch): self } /** - * Get the tool type + * Gets the tool type. * * @since n.e.x.t - * @return ToolTypeEnum The tool type + * + * @return ToolTypeEnum The tool type. */ public function getType(): ToolTypeEnum { @@ -83,10 +87,11 @@ public function getType(): ToolTypeEnum } /** - * Get the function declarations + * Gets the function declarations. * * @since n.e.x.t - * @return FunctionDeclaration[]|null The function declarations or null if not a function tool + * + * @return FunctionDeclaration[]|null The function declarations or null if not a function tool. */ public function getFunctionDeclarations(): ?array { @@ -94,10 +99,11 @@ public function getFunctionDeclarations(): ?array } /** - * Get the web search configuration + * Gets the web search configuration. * * @since n.e.x.t - * @return WebSearch|null The web search configuration or null if not a web search tool + * + * @return WebSearch|null The web search configuration or null if not a web search tool. */ public function getWebSearch(): ?WebSearch { @@ -117,19 +123,19 @@ public static function getJsonSchema(): array 'type' => [ 'type' => 'string', 'enum' => ['function_declarations', 'web_search'], - 'description' => 'The type of tool', + 'description' => 'The type of tool.', ], 'functionDeclarations' => [ 'type' => ['array', 'null'], 'items' => FunctionDeclaration::getJsonSchema(), - 'description' => 'Function declarations (when type is function_declarations)', + 'description' => 'Function declarations (when type is function_declarations).', ], 'webSearch' => [ 'oneOf' => [ ['type' => 'null'], WebSearch::getJsonSchema(), ], - 'description' => 'Web search configuration (when type is web_search)', + 'description' => 'Web search configuration (when type is web_search).', ], ], 'required' => ['type'], diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php index af40f456..53e746d8 100644 --- a/src/Tools/DTO/WebSearch.php +++ b/src/Tools/DTO/WebSearch.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchema; /** - * Represents web search configuration for AI models + * Represents web search configuration for AI models. * * This DTO defines constraints for web searches that AI models can perform, * including allowed and disallowed domains. @@ -17,21 +17,22 @@ class WebSearch implements WithJsonSchema { /** - * @var string[] List of domains that are allowed for web search + * @var string[] List of domains that are allowed for web search. */ private array $allowedDomains; /** - * @var string[] List of domains that are disallowed for web search + * @var string[] List of domains that are disallowed for web search. */ private array $disallowedDomains; /** - * Constructor + * Constructor. * * @since n.e.x.t - * @param string[] $allowedDomains List of domains that are allowed for web search - * @param string[] $disallowedDomains List of domains that are disallowed for web search + * + * @param string[] $allowedDomains List of domains that are allowed for web search. + * @param string[] $disallowedDomains List of domains that are disallowed for web search. */ public function __construct(array $allowedDomains = [], array $disallowedDomains = []) { @@ -40,10 +41,11 @@ public function __construct(array $allowedDomains = [], array $disallowedDomains } /** - * Get the allowed domains + * Gets the allowed domains. * * @since n.e.x.t - * @return string[] The allowed domains + * + * @return string[] The allowed domains. */ public function getAllowedDomains(): array { @@ -51,10 +53,11 @@ public function getAllowedDomains(): array } /** - * Get the disallowed domains + * Gets the disallowed domains. * * @since n.e.x.t - * @return string[] The disallowed domains + * + * @return string[] The disallowed domains. */ public function getDisallowedDomains(): array { @@ -76,14 +79,14 @@ public static function getJsonSchema(): array 'items' => [ 'type' => 'string', ], - 'description' => 'List of domains that are allowed for web search', + 'description' => 'List of domains that are allowed for web search.', ], 'disallowedDomains' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], - 'description' => 'List of domains that are disallowed for web search', + 'description' => 'List of domains that are disallowed for web search.', ], ], 'required' => [], From f1ace577b3993d3edc89fc96b347a3b80b43881e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 08:43:41 -0600 Subject: [PATCH 06/42] refactor: cleans up mime type and json schema --- ...Schema.php => WithJsonSchemaInterface.php} | 2 +- src/Files/DTO/InlineFile.php | 22 +++--------- src/Files/DTO/LocalFile.php | 22 +++--------- src/Files/DTO/RemoteFile.php | 22 +++--------- src/Files/Traits/HasMimeType.php | 35 +++++++++++++++++++ src/Messages/DTO/Message.php | 4 +-- src/Messages/DTO/MessagePart.php | 4 +-- .../Contracts/OperationInterface.php | 4 +-- src/Results/Contracts/ResultInterface.php | 4 +-- src/Results/DTO/Candidate.php | 4 +-- src/Results/DTO/TokenUsage.php | 4 +-- src/Tools/DTO/FunctionCall.php | 4 +-- src/Tools/DTO/FunctionDeclaration.php | 4 +-- src/Tools/DTO/FunctionResponse.php | 4 +-- src/Tools/DTO/Tool.php | 4 +-- src/Tools/DTO/WebSearch.php | 4 +-- 16 files changed, 70 insertions(+), 77 deletions(-) rename src/Common/Contracts/{WithJsonSchema.php => WithJsonSchemaInterface.php} (94%) create mode 100644 src/Files/Traits/HasMimeType.php diff --git a/src/Common/Contracts/WithJsonSchema.php b/src/Common/Contracts/WithJsonSchemaInterface.php similarity index 94% rename from src/Common/Contracts/WithJsonSchema.php rename to src/Common/Contracts/WithJsonSchemaInterface.php index 3e64a754..889a69ff 100644 --- a/src/Common/Contracts/WithJsonSchema.php +++ b/src/Common/Contracts/WithJsonSchemaInterface.php @@ -12,7 +12,7 @@ * * @since n.e.x.t */ -interface WithJsonSchema +interface WithJsonSchemaInterface { /** * Gets the JSON schema representation of the object. diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index 22911ff1..07298cc6 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -4,8 +4,9 @@ namespace WordPress\AiClient\Files\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; +use WordPress\AiClient\Files\Traits\HasMimeType; /** * Represents a file with inline base64-encoded data. @@ -15,12 +16,9 @@ * * @since n.e.x.t */ -class InlineFile implements FileInterface, WithJsonSchema +class InlineFile implements FileInterface, WithJsonSchemaInterface { - /** - * @var string The MIME type of the file. - */ - private string $mimeType; + use HasMimeType; /** * @var string The base64-encoded file data. @@ -41,18 +39,6 @@ public function __construct(string $mimeType, string $base64Data) $this->base64Data = $base64Data; } - /** - * Gets the MIME type of the file. - * - * @since n.e.x.t - * - * @return string The MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - /** * Gets the base64-encoded data. * diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index 88cb4396..b7d1dad8 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -4,8 +4,9 @@ namespace WordPress\AiClient\Files\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; +use WordPress\AiClient\Files\Traits\HasMimeType; /** * Represents a file stored locally on the filesystem. @@ -15,12 +16,9 @@ * * @since n.e.x.t */ -class LocalFile implements FileInterface, WithJsonSchema +class LocalFile implements FileInterface, WithJsonSchemaInterface { - /** - * @var string The MIME type of the file. - */ - private string $mimeType; + use HasMimeType; /** * @var string The local filesystem path to the file. @@ -41,18 +39,6 @@ public function __construct(string $mimeType, string $path) $this->path = $path; } - /** - * Gets the MIME type of the file. - * - * @since n.e.x.t - * - * @return string The MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - /** * Gets the local filesystem path. * diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index d500ec39..12675482 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -4,8 +4,9 @@ namespace WordPress\AiClient\Files\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; +use WordPress\AiClient\Files\Traits\HasMimeType; /** * Represents a file accessible via a remote URL. @@ -15,12 +16,9 @@ * * @since n.e.x.t */ -class RemoteFile implements FileInterface, WithJsonSchema +class RemoteFile implements FileInterface, WithJsonSchemaInterface { - /** - * @var string The MIME type of the file. - */ - private string $mimeType; + use HasMimeType; /** * @var string The URL to the remote file. @@ -41,18 +39,6 @@ public function __construct(string $mimeType, string $url) $this->url = $url; } - /** - * Gets the MIME type of the file. - * - * @since n.e.x.t - * - * @return string The MIME type. - */ - public function getMimeType(): string - { - return $this->mimeType; - } - /** * Gets the URL to the remote file. * diff --git a/src/Files/Traits/HasMimeType.php b/src/Files/Traits/HasMimeType.php new file mode 100644 index 00000000..9e8aa7b0 --- /dev/null +++ b/src/Files/Traits/HasMimeType.php @@ -0,0 +1,35 @@ +mimeType; + } +} diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 913a3541..9b5e3386 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Messages\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** @@ -15,7 +15,7 @@ * * @since n.e.x.t */ -class Message implements WithJsonSchema +class Message implements WithJsonSchemaInterface { /** * @var MessageRoleEnum The role of the message sender. diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 6d79ee28..f230fdd2 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Messages\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\DTO\InlineFile; use WordPress\AiClient\Files\DTO\RemoteFile; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; @@ -19,7 +19,7 @@ * * @since n.e.x.t */ -class MessagePart implements WithJsonSchema +class MessagePart implements WithJsonSchemaInterface { /** * @var MessagePartTypeEnum The type of this message part. diff --git a/src/Operations/Contracts/OperationInterface.php b/src/Operations/Contracts/OperationInterface.php index d146eb24..d9bfa6b5 100644 --- a/src/Operations/Contracts/OperationInterface.php +++ b/src/Operations/Contracts/OperationInterface.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Operations\Contracts; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Operations\Enums\OperationStateEnum; /** @@ -15,7 +15,7 @@ * * @since n.e.x.t */ -interface OperationInterface extends WithJsonSchema +interface OperationInterface extends WithJsonSchemaInterface { /** * Gets the operation ID. diff --git a/src/Results/Contracts/ResultInterface.php b/src/Results/Contracts/ResultInterface.php index ea2aae69..4da83eab 100644 --- a/src/Results/Contracts/ResultInterface.php +++ b/src/Results/Contracts/ResultInterface.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Results\Contracts; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Results\DTO\TokenUsage; /** @@ -15,7 +15,7 @@ * * @since n.e.x.t */ -interface ResultInterface extends WithJsonSchema +interface ResultInterface extends WithJsonSchemaInterface { /** * Gets the result ID. diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 7073193e..1c342867 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Results\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Results\Enums\FinishReasonEnum; @@ -16,7 +16,7 @@ * * @since n.e.x.t */ -class Candidate implements WithJsonSchema +class Candidate implements WithJsonSchemaInterface { /** * @var Message The generated message. diff --git a/src/Results/DTO/TokenUsage.php b/src/Results/DTO/TokenUsage.php index 4e576a7c..da7d3714 100644 --- a/src/Results/DTO/TokenUsage.php +++ b/src/Results/DTO/TokenUsage.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Results\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Represents token usage statistics for an AI operation. @@ -14,7 +14,7 @@ * * @since n.e.x.t */ -class TokenUsage implements WithJsonSchema +class TokenUsage implements WithJsonSchemaInterface { /** * @var int Number of tokens in the prompt. diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index 8550ef04..d61ec094 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Represents a function call request from an AI model. @@ -14,7 +14,7 @@ * * @since n.e.x.t */ -class FunctionCall implements WithJsonSchema +class FunctionCall implements WithJsonSchemaInterface { /** * @var string Unique identifier for this function call. diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index ec1ec3b2..4dd3f958 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Represents a function declaration for AI models. @@ -14,7 +14,7 @@ * * @since n.e.x.t */ -class FunctionDeclaration implements WithJsonSchema +class FunctionDeclaration implements WithJsonSchemaInterface { /** * @var string The name of the function. diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index f9f55399..8e7fe0f9 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Represents a response to a function call. @@ -14,7 +14,7 @@ * * @since n.e.x.t */ -class FunctionResponse implements WithJsonSchema +class FunctionResponse implements WithJsonSchemaInterface { /** * @var string The ID of the function call this is responding to. diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index 11b20389..b408f402 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Providers\Enums\ToolTypeEnum; /** @@ -15,7 +15,7 @@ * * @since n.e.x.t */ -class Tool implements WithJsonSchema +class Tool implements WithJsonSchemaInterface { /** * @var ToolTypeEnum The type of tool. diff --git a/src/Tools/DTO/WebSearch.php b/src/Tools/DTO/WebSearch.php index 53e746d8..ecc1526f 100644 --- a/src/Tools/DTO/WebSearch.php +++ b/src/Tools/DTO/WebSearch.php @@ -4,7 +4,7 @@ namespace WordPress\AiClient\Tools\DTO; -use WordPress\AiClient\Common\Contracts\WithJsonSchema; +use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; /** * Represents web search configuration for AI models. @@ -14,7 +14,7 @@ * * @since n.e.x.t */ -class WebSearch implements WithJsonSchema +class WebSearch implements WithJsonSchemaInterface { /** * @var string[] List of domains that are allowed for web search. From cb20ce1f9f301f165878b680932121c3da9e7f97 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 08:44:59 -0600 Subject: [PATCH 07/42] refactor: swaps file parameter order --- src/Files/DTO/InlineFile.php | 6 +++--- src/Files/DTO/LocalFile.php | 6 +++--- src/Files/DTO/RemoteFile.php | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index 07298cc6..799d5299 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -30,13 +30,13 @@ class InlineFile implements FileInterface, WithJsonSchemaInterface * * @since n.e.x.t * - * @param string $mimeType The MIME type of the file. * @param string $base64Data The base64-encoded file data. + * @param string $mimeType The MIME type of the file. */ - public function __construct(string $mimeType, string $base64Data) + public function __construct(string $base64Data, string $mimeType) { - $this->mimeType = $mimeType; $this->base64Data = $base64Data; + $this->mimeType = $mimeType; } /** diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index b7d1dad8..c7907ed5 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -30,13 +30,13 @@ class LocalFile implements FileInterface, WithJsonSchemaInterface * * @since n.e.x.t * - * @param string $mimeType The MIME type of the file. * @param string $path The local filesystem path to the file. + * @param string $mimeType The MIME type of the file. */ - public function __construct(string $mimeType, string $path) + public function __construct(string $path, string $mimeType) { - $this->mimeType = $mimeType; $this->path = $path; + $this->mimeType = $mimeType; } /** diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index 12675482..7b079ea4 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -30,13 +30,13 @@ class RemoteFile implements FileInterface, WithJsonSchemaInterface * * @since n.e.x.t * - * @param string $mimeType The MIME type of the file. * @param string $url The URL to the remote file. + * @param string $mimeType The MIME type of the file. */ - public function __construct(string $mimeType, string $url) + public function __construct(string $url, string $mimeType) { - $this->mimeType = $mimeType; $this->url = $url; + $this->mimeType = $mimeType; } /** From ac3c9e7e87427f82f9f5054625d7d80a57b2804f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:02:56 -0600 Subject: [PATCH 08/42] feat: parses mime type from base64 data --- src/Files/DTO/InlineFile.php | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index 799d5299..a33329ac 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -31,12 +31,36 @@ class InlineFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $base64Data The base64-encoded file data. - * @param string $mimeType The MIME type of the file. + * @param string|null $mimeType The MIME type of the file. */ - public function __construct(string $base64Data, string $mimeType) + public function __construct(string $base64Data, string $mimeType = null) { + // RFC 2397: dataurl := "data:" [ mediatype ] ";base64," data + // mediatype is optional; if omitted, defaults to text/plain;charset=US-ASCII + // We'll be more permissive and accept data URLs with or without MIME type + $pattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' + . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/'; + + if (!preg_match($pattern, $base64Data, $matches)) { + throw new \InvalidArgumentException( + 'Invalid base64 data provided. Expected format: data:[mimeType];base64,[data]' + ); + } + $this->base64Data = $base64Data; - $this->mimeType = $mimeType; + + if ($mimeType === null) { + // Extract MIME type from data URL if present + if (!empty($matches[1])) { + // MIME type was provided in the data URL + $this->mimeType = $matches[1]; + } else { + // No MIME type provided; default to text/plain per RFC 2397 + $this->mimeType = 'text/plain'; + } + } else { + $this->mimeType = $mimeType; + } } /** From ec93ff421c3898e6edf3c3a640d921564a0ced66 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:11:29 -0600 Subject: [PATCH 09/42] feat: parses mime type from extension --- src/Files/DTO/LocalFile.php | 20 +++++- src/Files/Utilities/MimeTypeUtil.php | 104 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/Files/Utilities/MimeTypeUtil.php diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index c7907ed5..47eed1ab 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -7,6 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; use WordPress\AiClient\Files\Traits\HasMimeType; +use WordPress\AiClient\Files\Utilities\MimeTypeUtil; /** * Represents a file stored locally on the filesystem. @@ -31,12 +32,25 @@ class LocalFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $path The local filesystem path to the file. - * @param string $mimeType The MIME type of the file. + * @param string|null $mimeType The MIME type of the file. */ - public function __construct(string $path, string $mimeType) + public function __construct(string $path, string $mimeType = null) { $this->path = $path; - $this->mimeType = $mimeType; + + if ($mimeType !== null) { + $this->mimeType = $mimeType; + } else { + // Extract extension from path + $extension = pathinfo($path, PATHINFO_EXTENSION); + + if (!empty($extension)) { + $this->mimeType = MimeTypeUtil::getMimeTypeForExtension($extension); + } else { + // No extension found, default to text/plain + $this->mimeType = 'text/plain'; + } + } } /** diff --git a/src/Files/Utilities/MimeTypeUtil.php b/src/Files/Utilities/MimeTypeUtil.php new file mode 100644 index 00000000..3ee59a4e --- /dev/null +++ b/src/Files/Utilities/MimeTypeUtil.php @@ -0,0 +1,104 @@ + + */ + private static array $mimeTypes = [ + // Text + 'txt' => 'text/plain', + 'html' => 'text/html', + 'htm' => 'text/html', + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'csv' => 'text/csv', + 'md' => 'text/markdown', + + // Images + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + + // Documents + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'odt' => 'application/vnd.oasis.opendocument.text', + 'ods' => 'application/vnd.oasis.opendocument.spreadsheet', + + // Archives + 'zip' => 'application/zip', + 'tar' => 'application/x-tar', + 'gz' => 'application/gzip', + 'rar' => 'application/x-rar-compressed', + '7z' => 'application/x-7z-compressed', + + // Audio + 'mp3' => 'audio/mpeg', + 'wav' => 'audio/wav', + 'ogg' => 'audio/ogg', + 'flac' => 'audio/flac', + 'm4a' => 'audio/m4a', + + // Video + 'mp4' => 'video/mp4', + 'avi' => 'video/x-msvideo', + 'mov' => 'video/quicktime', + 'wmv' => 'video/x-ms-wmv', + 'flv' => 'video/x-flv', + 'webm' => 'video/webm', + 'mkv' => 'video/x-matroska', + + // Fonts + 'ttf' => 'font/ttf', + 'otf' => 'font/otf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + + // Other + 'php' => 'application/x-httpd-php', + 'sh' => 'application/x-sh', + 'exe' => 'application/x-msdownload', + ]; + + /** + * Gets the MIME type for a given file extension. + * + * @since n.e.x.t + * + * @param string $extension The file extension (without the dot). + * @return string The MIME type, or 'text/plain' if unknown. + */ + public static function getMimeTypeForExtension(string $extension): string + { + $extension = strtolower($extension); + + return self::$mimeTypes[$extension] ?? 'text/plain'; + } +} From f9d19af41b2a205ab0e9505b7d411ff80e52d4f0 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:14:10 -0600 Subject: [PATCH 10/42] feat: parses mime type from url extension --- src/Files/DTO/RemoteFile.php | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index 7b079ea4..f0167001 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -7,6 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; use WordPress\AiClient\Files\Traits\HasMimeType; +use WordPress\AiClient\Files\Utilities\MimeTypeUtil; /** * Represents a file accessible via a remote URL. @@ -31,12 +32,36 @@ class RemoteFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $url The URL to the remote file. - * @param string $mimeType The MIME type of the file. + * @param string|null $mimeType The MIME type of the file. */ - public function __construct(string $url, string $mimeType) + public function __construct(string $url, string $mimeType = null) { $this->url = $url; - $this->mimeType = $mimeType; + + if ($mimeType !== null) { + $this->mimeType = $mimeType; + } else { + // Parse URL to extract filename and extension + $parsedUrl = parse_url($url); + $path = $parsedUrl['path'] ?? ''; + + // Remove query string and fragment if present in the path + $cleanPath = strtok($path, '?#'); + + if ($cleanPath === false) { + $cleanPath = $path; + } + + // Extract extension from the path + $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); + + if (!empty($extension)) { + $this->mimeType = MimeTypeUtil::getMimeTypeForExtension($extension); + } else { + // No extension found, default to text/plain + $this->mimeType = 'text/plain'; + } + } } /** From 11c3c03528b68753e76fc7a394a8977b1ffe513c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:20:06 -0600 Subject: [PATCH 11/42] refactor: getValues returns only values --- src/Common/AbstractEnum.php | 4 ++-- tests/unit/Common/AbstractEnumTest.php | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 11f10134..82b523b7 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -195,11 +195,11 @@ final public function is(self $other): bool * * @since n.e.x.t * - * @return array Map of constant names to values. + * @return string[] List of all enum values. */ final public static function getValues(): array { - return self::getConstants(); + return array_values(self::getConstants()); } /** diff --git a/tests/unit/Common/AbstractEnumTest.php b/tests/unit/Common/AbstractEnumTest.php index 3aa82a1d..4902c4b0 100644 --- a/tests/unit/Common/AbstractEnumTest.php +++ b/tests/unit/Common/AbstractEnumTest.php @@ -173,10 +173,7 @@ public function testGetValuesReturnsAllValidValues(): void { $values = ValidTestEnum::getValues(); - $this->assertSame([ - 'FIRST_NAME' => 'first', - 'LAST_NAME' => 'last', - ], $values); + $this->assertSame(['first', 'last'], $values); } /** From ab0cc93f217b5f0a15c2197bc7b38a0c17209ecb Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:23:46 -0600 Subject: [PATCH 12/42] refactor: cleans up Message DTO --- src/Messages/DTO/Message.php | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/Messages/DTO/Message.php b/src/Messages/DTO/Message.php index 9b5e3386..cf73e30a 100644 --- a/src/Messages/DTO/Message.php +++ b/src/Messages/DTO/Message.php @@ -41,20 +41,6 @@ public function __construct(MessageRoleEnum $role, array $parts) $this->parts = $parts; } - /** - * Creates a message from a simple text string. - * - * @since n.e.x.t - * - * @param MessageRoleEnum $role The role of the message sender. - * @param string $text The text content. - * @return self - */ - public static function fromText(MessageRoleEnum $role, string $text): self - { - return new self($role, [MessagePart::text($text)]); - } - /** * Gets the role of the message sender. * @@ -91,7 +77,7 @@ public static function getJsonSchema(): array 'properties' => [ 'role' => [ 'type' => 'string', - 'enum' => ['user', 'model', 'system'], + 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.', ], 'parts' => [ From 109c17f9d9cc7b1a714d23d91284cfcf05b4a174 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:24:58 -0600 Subject: [PATCH 13/42] refactor: cleans up MessagePart static methods --- src/Messages/DTO/MessagePart.php | 75 -------------------------------- 1 file changed, 75 deletions(-) diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index f230fdd2..433f36d3 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -63,81 +63,6 @@ private function __construct(MessagePartTypeEnum $type) $this->type = $type; } - /** - * Creates a text message part. - * - * @since n.e.x.t - * - * @param string $text The text content. - * @return self - */ - public static function text(string $text): self - { - $part = new self(MessagePartTypeEnum::text()); - $part->text = $text; - return $part; - } - - /** - * Creates an inline file message part. - * - * @since n.e.x.t - * - * @param InlineFile $file The inline file. - * @return self - */ - public static function inlineFile(InlineFile $file): self - { - $part = new self(MessagePartTypeEnum::inlineFile()); - $part->inlineFile = $file; - return $part; - } - - /** - * Creates a remote file message part. - * - * @since n.e.x.t - * - * @param RemoteFile $file The remote file. - * @return self - */ - public static function remoteFile(RemoteFile $file): self - { - $part = new self(MessagePartTypeEnum::remoteFile()); - $part->remoteFile = $file; - return $part; - } - - /** - * Creates a function call message part. - * - * @since n.e.x.t - * - * @param FunctionCall $functionCall The function call. - * @return self - */ - public static function functionCall(FunctionCall $functionCall): self - { - $part = new self(MessagePartTypeEnum::functionCall()); - $part->functionCall = $functionCall; - return $part; - } - - /** - * Creates a function response message part. - * - * @since n.e.x.t - * - * @param FunctionResponse $functionResponse The function response. - * @return self - */ - public static function functionResponse(FunctionResponse $functionResponse): self - { - $part = new self(MessagePartTypeEnum::functionResponse()); - $part->functionResponse = $functionResponse; - return $part; - } - /** * Gets the type of this message part. * From 7ca0389372d04f211d29ff64a6c5fee88601ccaf Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 09:29:21 -0600 Subject: [PATCH 14/42] test: fixes getValues enum tests --- tests/unit/EnumTestTrait.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/EnumTestTrait.php b/tests/unit/EnumTestTrait.php index 3d282fdf..b73c16cb 100644 --- a/tests/unit/EnumTestTrait.php +++ b/tests/unit/EnumTestTrait.php @@ -35,7 +35,10 @@ public function testEnumHasExpectedValues(): void $actualValues = $enumClass::getValues(); - $this->assertEquals($expectedValues, $actualValues); + // Since getValues() now returns just the values, we need to extract values from expected + $expectedValuesList = array_values($expectedValues); + + $this->assertEquals($expectedValuesList, $actualValues); } /** From eb386cf5c75b5d7f8bec973ee3aefe4495a22574 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 10:07:48 -0600 Subject: [PATCH 15/42] refactor: improves type inferrence and json schema --- src/Messages/DTO/MessagePart.php | 119 ++++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 35 deletions(-) diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 433f36d3..38ac9f0c 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -52,15 +52,40 @@ class MessagePart implements WithJsonSchemaInterface private ?FunctionResponse $functionResponse = null; /** - * Private constructor to enforce factory method usage. + * Constructor that accepts various content types and infers the message part type. * * @since n.e.x.t * - * @param MessagePartTypeEnum $type The type of this message part. + * @param mixed $content The content of this message part. + * @throws \InvalidArgumentException If an unsupported content type is provided. */ - private function __construct(MessagePartTypeEnum $type) + public function __construct($content) { - $this->type = $type; + if (is_string($content)) { + $this->type = MessagePartTypeEnum::text(); + $this->text = $content; + } elseif ($content instanceof InlineFile) { + $this->type = MessagePartTypeEnum::inlineFile(); + $this->inlineFile = $content; + } elseif ($content instanceof RemoteFile) { + $this->type = MessagePartTypeEnum::remoteFile(); + $this->remoteFile = $content; + } elseif ($content instanceof FunctionCall) { + $this->type = MessagePartTypeEnum::functionCall(); + $this->functionCall = $content; + } elseif ($content instanceof FunctionResponse) { + $this->type = MessagePartTypeEnum::functionResponse(); + $this->functionResponse = $content; + } else { + $type = is_object($content) ? get_class($content) : gettype($content); + throw new \InvalidArgumentException( + sprintf( + 'Unsupported content type %s. Expected string, InlineFile, RemoteFile, ' + . 'FunctionCall, or FunctionResponse.', + $type + ) + ); + } } /** @@ -143,47 +168,71 @@ public function getFunctionResponse(): ?FunctionResponse public static function getJsonSchema(): array { return [ - 'type' => 'object', - 'properties' => [ - 'type' => [ - 'type' => 'string', - 'enum' => ['text', 'inline_file', 'remote_file', 'function_call', 'function_response'], - 'description' => 'The type of this message part.', - ], - 'text' => [ - 'type' => ['string', 'null'], - 'description' => 'Text content (when type is text).', + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::text()->value, + ], + 'text' => [ + 'type' => 'string', + 'description' => 'Text content.', + ], + ], + 'required' => ['type', 'text'], + 'additionalProperties' => false, ], - 'inlineFile' => [ - 'oneOf' => [ - ['type' => 'null'], - InlineFile::getJsonSchema(), + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::inlineFile()->value, + ], + 'inlineFile' => InlineFile::getJsonSchema(), ], - 'description' => 'Inline file data (when type is inline_file).', + 'required' => ['type', 'inlineFile'], + 'additionalProperties' => false, ], - 'remoteFile' => [ - 'oneOf' => [ - ['type' => 'null'], - RemoteFile::getJsonSchema(), + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::remoteFile()->value, + ], + 'remoteFile' => RemoteFile::getJsonSchema(), ], - 'description' => 'Remote file reference (when type is remote_file).', + 'required' => ['type', 'remoteFile'], + 'additionalProperties' => false, ], - 'functionCall' => [ - 'oneOf' => [ - ['type' => 'null'], - FunctionCall::getJsonSchema(), + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::functionCall()->value, + ], + 'functionCall' => FunctionCall::getJsonSchema(), ], - 'description' => 'Function call request (when type is function_call).', + 'required' => ['type', 'functionCall'], + 'additionalProperties' => false, ], - 'functionResponse' => [ - 'oneOf' => [ - ['type' => 'null'], - FunctionResponse::getJsonSchema(), + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::functionResponse()->value, + ], + 'functionResponse' => FunctionResponse::getJsonSchema(), ], - 'description' => 'Function response (when type is function_response).', + 'required' => ['type', 'functionResponse'], + 'additionalProperties' => false, ], ], - 'required' => ['type'], ]; } } From 6f51c806d8487038938ba55f5f0140aa7feeadf3 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 10:08:09 -0600 Subject: [PATCH 16/42] refactor: removes role-message text methods --- src/Messages/DTO/ModelMessage.php | 13 ------------- src/Messages/DTO/SystemMessage.php | 13 ------------- src/Messages/DTO/UserMessage.php | 13 ------------- 3 files changed, 39 deletions(-) diff --git a/src/Messages/DTO/ModelMessage.php b/src/Messages/DTO/ModelMessage.php index 6e44c324..cf67b79c 100644 --- a/src/Messages/DTO/ModelMessage.php +++ b/src/Messages/DTO/ModelMessage.php @@ -27,17 +27,4 @@ public function __construct(array $parts) { parent::__construct(MessageRoleEnum::model(), $parts); } - - /** - * Creates a model message from a simple text string. - * - * @since n.e.x.t - * - * @param string $text The text content. - * @return self - */ - public static function text(string $text): self - { - return new self([MessagePart::text($text)]); - } } diff --git a/src/Messages/DTO/SystemMessage.php b/src/Messages/DTO/SystemMessage.php index a2b64173..e2adebf5 100644 --- a/src/Messages/DTO/SystemMessage.php +++ b/src/Messages/DTO/SystemMessage.php @@ -27,17 +27,4 @@ public function __construct(array $parts) { parent::__construct(MessageRoleEnum::system(), $parts); } - - /** - * Creates a system message from a simple text string. - * - * @since n.e.x.t - * - * @param string $text The text content. - * @return self - */ - public static function text(string $text): self - { - return new self([MessagePart::text($text)]); - } } diff --git a/src/Messages/DTO/UserMessage.php b/src/Messages/DTO/UserMessage.php index 650ac038..c0cb931f 100644 --- a/src/Messages/DTO/UserMessage.php +++ b/src/Messages/DTO/UserMessage.php @@ -26,17 +26,4 @@ public function __construct(array $parts) { parent::__construct(MessageRoleEnum::user(), $parts); } - - /** - * Creates a user message from a simple text string. - * - * @since n.e.x.t - * - * @param string $text The text content. - * @return self - */ - public static function text(string $text): self - { - return new self([MessagePart::text($text)]); - } } From fefab6ed3945d3c873230a67bd4ac3a700dc59c2 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 10:24:10 -0600 Subject: [PATCH 17/42] chore: adds property-read for nameand value --- src/Common/AbstractEnum.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 82b523b7..df9524fa 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -30,6 +30,9 @@ * $enum->is(PersonEnum::firstName()); // Returns true * PersonEnum::cases(); // Returns array of all enum instances * + * @property-read string $value The value of the enum instance. + * @property-read string $name The name of the enum constant. + * * @since n.e.x.t */ abstract class AbstractEnum From c213c49c0ca6402e5d4c18367b278118d5875299 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 10:48:55 -0600 Subject: [PATCH 18/42] feat: validates message role --- src/Results/DTO/Candidate.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 1c342867..0ab8dc72 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -44,6 +44,12 @@ class Candidate implements WithJsonSchemaInterface */ public function __construct(Message $message, FinishReasonEnum $finishReason, int $tokenCount) { + if (!$message->getRole()->isModel()) { + throw new \InvalidArgumentException( + 'Message must be a model message.' + ); + } + $this->message = $message; $this->finishReason = $finishReason; $this->tokenCount = $tokenCount; From 4e04fb5f69998626c03dcbd9b7352b00da7f529e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 10:58:40 -0600 Subject: [PATCH 19/42] refactor: improves schema accuracy --- src/Operations/DTO/GenerativeAiOperation.php | 53 ++++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/src/Operations/DTO/GenerativeAiOperation.php b/src/Operations/DTO/GenerativeAiOperation.php index 0a130148..9ab109b2 100644 --- a/src/Operations/DTO/GenerativeAiOperation.php +++ b/src/Operations/DTO/GenerativeAiOperation.php @@ -89,26 +89,47 @@ public function getResult(): ?GenerativeAiResult public static function getJsonSchema(): array { return [ - 'type' => 'object', - 'properties' => [ - 'id' => [ - 'type' => 'string', - 'description' => 'Unique identifier for this operation.', - ], - 'state' => [ - 'type' => 'string', - 'enum' => ['starting', 'processing', 'succeeded', 'failed', 'canceled'], - 'description' => 'The current state of the operation.', + 'oneOf' => [ + // Succeeded state - has result + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + 'state' => [ + 'type' => 'string', + 'const' => OperationStateEnum::succeeded()->value, + ], + 'result' => GenerativeAiResult::getJsonSchema(), + ], + 'required' => ['id', 'state', 'result'], + 'additionalProperties' => false, ], - 'result' => [ - 'oneOf' => [ - ['type' => 'null'], - GenerativeAiResult::getJsonSchema(), + // All other states - no result + [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + 'state' => [ + 'type' => 'string', + 'enum' => [ + OperationStateEnum::starting()->value, + OperationStateEnum::processing()->value, + OperationStateEnum::failed()->value, + OperationStateEnum::canceled()->value, + ], + 'description' => 'The current state of the operation.', + ], ], - 'description' => 'The result once the operation completes.', + 'required' => ['id', 'state'], + 'additionalProperties' => false, ], ], - 'required' => ['id', 'state'], ]; } } From d51a35fe7e3bf8be3e52652f7f281eaac2680a59 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 11:41:36 -0600 Subject: [PATCH 20/42] refactor: improves Result structure --- src/Files/Utilities/MimeTypeUtil.php | 77 +++++++++++ src/Results/DTO/GenerativeAiResult.php | 173 +++++++++++++++++++------ 2 files changed, 210 insertions(+), 40 deletions(-) diff --git a/src/Files/Utilities/MimeTypeUtil.php b/src/Files/Utilities/MimeTypeUtil.php index 3ee59a4e..dc16e3ec 100644 --- a/src/Files/Utilities/MimeTypeUtil.php +++ b/src/Files/Utilities/MimeTypeUtil.php @@ -101,4 +101,81 @@ public static function getMimeTypeForExtension(string $extension): string return self::$mimeTypes[$extension] ?? 'text/plain'; } + + /** + * Checks if a MIME type is an image type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to check. + * @return bool True if the MIME type is an image type. + */ + public static function isImageType(string $mimeType): bool + { + return strpos($mimeType, 'image/') === 0; + } + + /** + * Checks if a MIME type is an audio type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to check. + * @return bool True if the MIME type is an audio type. + */ + public static function isAudioType(string $mimeType): bool + { + return strpos($mimeType, 'audio/') === 0; + } + + /** + * Checks if a MIME type is a video type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to check. + * @return bool True if the MIME type is a video type. + */ + public static function isVideoType(string $mimeType): bool + { + return strpos($mimeType, 'video/') === 0; + } + + /** + * Checks if a MIME type is a text type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to check. + * @return bool True if the MIME type is a text type. + */ + public static function isTextType(string $mimeType): bool + { + return strpos($mimeType, 'text/') === 0; + } + + /** + * Checks if a MIME type is a document type. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to check. + * @return bool True if the MIME type is a document type. + */ + public static function isDocumentType(string $mimeType): bool + { + $documentTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + ]; + + return in_array($mimeType, $documentTypes, true); + } } diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 257cdca3..a97d8025 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Results\DTO; use WordPress\AiClient\Files\Contracts\FileInterface; +use WordPress\AiClient\Files\Utilities\MimeTypeUtil; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Results\Contracts\ResultInterface; @@ -48,9 +49,14 @@ class GenerativeAiResult implements ResultInterface * @param Candidate[] $candidates The generated candidates. * @param TokenUsage $tokenUsage Token usage statistics. * @param array $providerMetadata Provider-specific metadata. + * @throws \InvalidArgumentException If no candidates provided. */ public function __construct(string $id, array $candidates, TokenUsage $tokenUsage, array $providerMetadata = []) { + if (empty($candidates)) { + throw new \InvalidArgumentException('At least one candidate must be provided'); + } + $this->id = $id; $this->candidates = $candidates; $this->tokenUsage = $tokenUsage; @@ -99,28 +105,75 @@ public function getProviderMetadata(): array return $this->providerMetadata; } + /** + * Gets the total number of candidates. + * + * @since n.e.x.t + * + * @return int The total number of candidates. + */ + public function getTotalCandidates(): int + { + return count($this->candidates); + } + + /** + * Checks if the result has multiple candidates. + * + * @since n.e.x.t + * + * @return bool True if there are multiple candidates, false otherwise. + */ + public function hasMultipleCandidates(): bool + { + return count($this->candidates) > 1; + } + /** * Converts the first candidate to text. * * @since n.e.x.t * * @return string The text content. - * @throws \RuntimeException If no candidates or no text content. + * @throws \RuntimeException If no text content. */ public function toText(): string { - if (empty($this->candidates)) { - throw new \RuntimeException('No candidates available'); + $message = $this->candidates[0]->getMessage(); + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + return $text; + } } + throw new \RuntimeException('No text content found in first candidate'); + } + + /** + * Converts the first candidate to a file. + * + * @since n.e.x.t + * + * @return FileInterface The file. + * @throws \RuntimeException If no file content. + */ + public function toFile(): FileInterface + { $message = $this->candidates[0]->getMessage(); foreach ($message->getParts() as $part) { - if ($part->getType()->equals(MessagePartTypeEnum::text()) && $part->getText() !== null) { - return $part->getText(); + $inlineFile = $part->getInlineFile(); + if ($inlineFile !== null) { + return $inlineFile; + } + + $remoteFile = $part->getRemoteFile(); + if ($remoteFile !== null) { + return $remoteFile; } } - throw new \RuntimeException('No text content found in first candidate'); + throw new \RuntimeException('No file content found in first candidate'); } /** @@ -129,25 +182,19 @@ public function toText(): string * @since n.e.x.t * * @return FileInterface The image file. - * @throws \RuntimeException If no candidates or no image content. + * @throws \RuntimeException If no image content. */ public function toImageFile(): FileInterface { - if (empty($this->candidates)) { - throw new \RuntimeException('No candidates available'); - } + $file = $this->toFile(); - $message = $this->candidates[0]->getMessage(); - foreach ($message->getParts() as $part) { - if ($part->getType()->equals(MessagePartTypeEnum::inlineFile()) && $part->getInlineFile() !== null) { - return $part->getInlineFile(); - } - if ($part->getType()->equals(MessagePartTypeEnum::remoteFile()) && $part->getRemoteFile() !== null) { - return $part->getRemoteFile(); - } + if (!MimeTypeUtil::isImageType($file->getMimeType())) { + throw new \RuntimeException( + sprintf('File is not an image. MIME type: %s', $file->getMimeType()) + ); } - throw new \RuntimeException('No image content found in first candidate'); + return $file; } /** @@ -156,12 +203,19 @@ public function toImageFile(): FileInterface * @since n.e.x.t * * @return FileInterface The audio file. - * @throws \RuntimeException If no candidates or no audio content. + * @throws \RuntimeException If no audio content. */ public function toAudioFile(): FileInterface { - // Similar implementation to toImageFile, but checking for audio MIME types - return $this->toImageFile(); // Simplified for now + $file = $this->toFile(); + + if (!MimeTypeUtil::isAudioType($file->getMimeType())) { + throw new \RuntimeException( + sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()) + ); + } + + return $file; } /** @@ -170,12 +224,19 @@ public function toAudioFile(): FileInterface * @since n.e.x.t * * @return FileInterface The video file. - * @throws \RuntimeException If no candidates or no video content. + * @throws \RuntimeException If no video content. */ public function toVideoFile(): FileInterface { - // Similar implementation to toImageFile, but checking for video MIME types - return $this->toImageFile(); // Simplified for now + $file = $this->toFile(); + + if (!MimeTypeUtil::isVideoType($file->getMimeType())) { + throw new \RuntimeException( + sprintf('File is not a video file. MIME type: %s', $file->getMimeType()) + ); + } + + return $file; } /** @@ -184,14 +245,9 @@ public function toVideoFile(): FileInterface * @since n.e.x.t * * @return Message The message. - * @throws \RuntimeException If no candidates available. */ public function toMessage(): Message { - if (empty($this->candidates)) { - throw new \RuntimeException('No candidates available'); - } - return $this->candidates[0]->getMessage(); } @@ -208,8 +264,9 @@ public function toTexts(): array foreach ($this->candidates as $candidate) { $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { - if ($part->getType()->equals(MessagePartTypeEnum::text()) && $part->getText() !== null) { - $texts[] = $part->getText(); + $text = $part->getText(); + if ($text !== null) { + $texts[] = $text; break; } } @@ -230,12 +287,15 @@ public function toImageFiles(): array foreach ($this->candidates as $candidate) { $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { - if ($part->getType()->equals(MessagePartTypeEnum::inlineFile()) && $part->getInlineFile() !== null) { - $files[] = $part->getInlineFile(); + $inlineFile = $part->getInlineFile(); + if ($inlineFile !== null && MimeTypeUtil::isImageType($inlineFile->getMimeType())) { + $files[] = $inlineFile; break; } - if ($part->getType()->equals(MessagePartTypeEnum::remoteFile()) && $part->getRemoteFile() !== null) { - $files[] = $part->getRemoteFile(); + + $remoteFile = $part->getRemoteFile(); + if ($remoteFile !== null && MimeTypeUtil::isImageType($remoteFile->getMimeType())) { + $files[] = $remoteFile; break; } } @@ -252,8 +312,24 @@ public function toImageFiles(): array */ public function toAudioFiles(): array { - // Similar implementation to toImageFiles, but checking for audio MIME types - return $this->toImageFiles(); // Simplified for now + $files = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $inlineFile = $part->getInlineFile(); + if ($inlineFile !== null && MimeTypeUtil::isAudioType($inlineFile->getMimeType())) { + $files[] = $inlineFile; + break; + } + + $remoteFile = $part->getRemoteFile(); + if ($remoteFile !== null && MimeTypeUtil::isAudioType($remoteFile->getMimeType())) { + $files[] = $remoteFile; + break; + } + } + } + return $files; } /** @@ -265,8 +341,24 @@ public function toAudioFiles(): array */ public function toVideoFiles(): array { - // Similar implementation to toImageFiles, but checking for video MIME types - return $this->toImageFiles(); // Simplified for now + $files = []; + foreach ($this->candidates as $candidate) { + $message = $candidate->getMessage(); + foreach ($message->getParts() as $part) { + $inlineFile = $part->getInlineFile(); + if ($inlineFile !== null && MimeTypeUtil::isVideoType($inlineFile->getMimeType())) { + $files[] = $inlineFile; + break; + } + + $remoteFile = $part->getRemoteFile(); + if ($remoteFile !== null && MimeTypeUtil::isVideoType($remoteFile->getMimeType())) { + $files[] = $remoteFile; + break; + } + } + } + return $files; } /** @@ -298,6 +390,7 @@ public static function getJsonSchema(): array 'candidates' => [ 'type' => 'array', 'items' => Candidate::getJsonSchema(), + 'minItems' => 1, 'description' => 'The generated candidates.', ], 'tokenUsage' => TokenUsage::getJsonSchema(), From 05b57c6e1eb48bd9beb0933f56fd286b9e8444e0 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 12:29:30 -0600 Subject: [PATCH 21/42] refactor: switches to mime type VO pattern --- src/Files/Contracts/FileInterface.php | 6 +- src/Files/DTO/InlineFile.php | 19 +-- src/Files/DTO/LocalFile.php | 46 ++++-- src/Files/DTO/RemoteFile.php | 69 ++++++--- src/Files/Traits/HasMimeType.php | 10 +- .../MimeType.php} | 142 +++++++++++++----- src/Results/DTO/GenerativeAiResult.php | 19 ++- 7 files changed, 211 insertions(+), 100 deletions(-) rename src/Files/{Utilities/MimeTypeUtil.php => ValueObjects/MimeType.php} (50%) diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php index 5a26c1fc..c39f9af3 100644 --- a/src/Files/Contracts/FileInterface.php +++ b/src/Files/Contracts/FileInterface.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Files\Contracts; +use WordPress\AiClient\Files\ValueObjects\MimeType; + /** * Interface for file representations in the AI client. * @@ -18,7 +20,7 @@ interface FileInterface * Gets the MIME type of the file. * * @since n.e.x.t - * @return string The MIME type (e.g., 'image/png', 'audio/mp3'). + * @return MimeType The MIME type (e.g., 'image/png', 'audio/mp3'). */ - public function getMimeType(): string; + public function getMimeType(): MimeType; } diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index a33329ac..231e52de 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -7,6 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; use WordPress\AiClient\Files\Traits\HasMimeType; +use WordPress\AiClient\Files\ValueObjects\MimeType; /** * Represents a file with inline base64-encoded data. @@ -31,9 +32,9 @@ class InlineFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $base64Data The base64-encoded file data. - * @param string|null $mimeType The MIME type of the file. + * @param MimeType|string $mimeType The MIME type of the file. */ - public function __construct(string $base64Data, string $mimeType = null) + public function __construct(string $base64Data, $mimeType) { // RFC 2397: dataurl := "data:" [ mediatype ] ";base64," data // mediatype is optional; if omitted, defaults to text/plain;charset=US-ASCII @@ -49,17 +50,10 @@ public function __construct(string $base64Data, string $mimeType = null) $this->base64Data = $base64Data; - if ($mimeType === null) { - // Extract MIME type from data URL if present - if (!empty($matches[1])) { - // MIME type was provided in the data URL - $this->mimeType = $matches[1]; - } else { - // No MIME type provided; default to text/plain per RFC 2397 - $this->mimeType = 'text/plain'; - } - } else { + if ($mimeType instanceof MimeType) { $this->mimeType = $mimeType; + } else { + $this->mimeType = new MimeType($mimeType); } } @@ -88,6 +82,7 @@ public static function getJsonSchema(): array 'mimeType' => [ 'type' => 'string', 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', ], 'base64Data' => [ 'type' => 'string', diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index 47eed1ab..c89c353c 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; use WordPress\AiClient\Files\Traits\HasMimeType; -use WordPress\AiClient\Files\Utilities\MimeTypeUtil; +use WordPress\AiClient\Files\ValueObjects\MimeType; /** * Represents a file stored locally on the filesystem. @@ -32,24 +32,18 @@ class LocalFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $path The local filesystem path to the file. - * @param string|null $mimeType The MIME type of the file. + * @param MimeType|string|null $mimeType The MIME type of the file. */ - public function __construct(string $path, string $mimeType = null) + public function __construct(string $path, $mimeType = null) { $this->path = $path; - if ($mimeType !== null) { + if ($mimeType instanceof MimeType) { $this->mimeType = $mimeType; + } elseif (is_string($mimeType)) { + $this->mimeType = new MimeType($mimeType); } else { - // Extract extension from path - $extension = pathinfo($path, PATHINFO_EXTENSION); - - if (!empty($extension)) { - $this->mimeType = MimeTypeUtil::getMimeTypeForExtension($extension); - } else { - // No extension found, default to text/plain - $this->mimeType = 'text/plain'; - } + $this->mimeType = $this->getMimeTypeFromExtension($path); } } @@ -65,6 +59,31 @@ public function getPath(): string return $this->path; } + /** + * Extracts MIME type from file extension. + * + * @since n.e.x.t + * + * @param string $path The file path. + * @return MimeType The MIME type. + */ + private function getMimeTypeFromExtension(string $path): MimeType + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + if (!empty($extension)) { + try { + return MimeType::fromExtension($extension); + } catch (\InvalidArgumentException $e) { + // Unknown extension, default to text/plain + return new MimeType('text/plain'); + } + } + + // No extension found, default to text/plain + return new MimeType('text/plain'); + } + /** * {@inheritDoc} * @@ -78,6 +97,7 @@ public static function getJsonSchema(): array 'mimeType' => [ 'type' => 'string', 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', ], 'path' => [ 'type' => 'string', diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index f0167001..02dc5c37 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; use WordPress\AiClient\Files\Contracts\FileInterface; use WordPress\AiClient\Files\Traits\HasMimeType; -use WordPress\AiClient\Files\Utilities\MimeTypeUtil; +use WordPress\AiClient\Files\ValueObjects\MimeType; /** * Represents a file accessible via a remote URL. @@ -32,35 +32,18 @@ class RemoteFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $url The URL to the remote file. - * @param string|null $mimeType The MIME type of the file. + * @param MimeType|string|null $mimeType The MIME type of the file. */ - public function __construct(string $url, string $mimeType = null) + public function __construct(string $url, $mimeType = null) { $this->url = $url; - if ($mimeType !== null) { + if ($mimeType instanceof MimeType) { $this->mimeType = $mimeType; + } elseif (is_string($mimeType)) { + $this->mimeType = new MimeType($mimeType); } else { - // Parse URL to extract filename and extension - $parsedUrl = parse_url($url); - $path = $parsedUrl['path'] ?? ''; - - // Remove query string and fragment if present in the path - $cleanPath = strtok($path, '?#'); - - if ($cleanPath === false) { - $cleanPath = $path; - } - - // Extract extension from the path - $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); - - if (!empty($extension)) { - $this->mimeType = MimeTypeUtil::getMimeTypeForExtension($extension); - } else { - // No extension found, default to text/plain - $this->mimeType = 'text/plain'; - } + $this->mimeType = $this->getMimeTypeFromExtension($url); } } @@ -76,6 +59,43 @@ public function getUrl(): string return $this->url; } + /** + * Extracts MIME type from URL extension. + * + * @since n.e.x.t + * + * @param string $url The file URL. + * @return MimeType The MIME type. + */ + private function getMimeTypeFromExtension(string $url): MimeType + { + // Parse URL to extract filename and extension + $parsedUrl = parse_url($url); + $path = $parsedUrl['path'] ?? ''; + + // Remove query string and fragment if present in the path + $cleanPath = strtok($path, '?#'); + + if ($cleanPath === false) { + $cleanPath = $path; + } + + // Extract extension from the path + $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); + + if (!empty($extension)) { + try { + return MimeType::fromExtension($extension); + } catch (\InvalidArgumentException $e) { + // Unknown extension, default to text/plain + return new MimeType('text/plain'); + } + } + + // No extension found, default to text/plain + return new MimeType('text/plain'); + } + /** * {@inheritDoc} * @@ -89,6 +109,7 @@ public static function getJsonSchema(): array 'mimeType' => [ 'type' => 'string', 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', ], 'url' => [ 'type' => 'string', diff --git a/src/Files/Traits/HasMimeType.php b/src/Files/Traits/HasMimeType.php index 9e8aa7b0..857444fb 100644 --- a/src/Files/Traits/HasMimeType.php +++ b/src/Files/Traits/HasMimeType.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Files\Traits; +use WordPress\AiClient\Files\ValueObjects\MimeType; + /** * Provides MIME type functionality for file objects. * @@ -17,18 +19,18 @@ trait HasMimeType /** * The MIME type of the file. * - * @var string + * @var MimeType */ - protected string $mimeType; + protected MimeType $mimeType; /** * Gets the MIME type of the file. * - * @return string The MIME type. + * @return MimeType The MIME type. * * @since 1.0.0 */ - public function getMimeType(): string + public function getMimeType(): MimeType { return $this->mimeType; } diff --git a/src/Files/Utilities/MimeTypeUtil.php b/src/Files/ValueObjects/MimeType.php similarity index 50% rename from src/Files/Utilities/MimeTypeUtil.php rename to src/Files/ValueObjects/MimeType.php index dc16e3ec..3978b857 100644 --- a/src/Files/Utilities/MimeTypeUtil.php +++ b/src/Files/ValueObjects/MimeType.php @@ -2,24 +2,29 @@ declare(strict_types=1); -namespace WordPress\AiClient\Files\Utilities; +namespace WordPress\AiClient\Files\ValueObjects; /** - * Utility class for MIME type operations. + * Value object representing a MIME type. * - * Provides static methods for working with MIME types, including - * determining MIME types from file extensions. + * This immutable value object encapsulates MIME type validation and + * provides convenient methods for checking MIME type categories. * * @since n.e.x.t */ -class MimeTypeUtil +final class MimeType { + /** + * @var string The MIME type value. + */ + private string $value; + /** * Common MIME type mappings for file extensions. * * @var array */ - private static array $mimeTypes = [ + private static array $extensionMap = [ // Text 'txt' => 'text/plain', 'html' => 'text/html', @@ -88,81 +93,119 @@ class MimeTypeUtil ]; /** - * Gets the MIME type for a given file extension. + * Constructor. + * + * @since n.e.x.t + * + * @param string $value The MIME type value. + * @throws \InvalidArgumentException If the MIME type is invalid. + */ + public function __construct(string $value) + { + if (!self::isValid($value)) { + throw new \InvalidArgumentException( + sprintf('Invalid MIME type: %s', $value) + ); + } + + $this->value = $value; + } + + /** + * Creates a MimeType from a file extension. * * @since n.e.x.t * * @param string $extension The file extension (without the dot). - * @return string The MIME type, or 'text/plain' if unknown. + * @return self The MimeType instance. + * @throws \InvalidArgumentException If the extension is not recognized. */ - public static function getMimeTypeForExtension(string $extension): string + public static function fromExtension(string $extension): self { $extension = strtolower($extension); - return self::$mimeTypes[$extension] ?? 'text/plain'; + if (!isset(self::$extensionMap[$extension])) { + throw new \InvalidArgumentException( + sprintf('Unknown file extension: %s', $extension) + ); + } + + return new self(self::$extensionMap[$extension]); + } + + /** + * Checks if a MIME type string is valid. + * + * @since n.e.x.t + * + * @param string $mimeType The MIME type to validate. + * @return bool True if valid. + */ + public static function isValid(string $mimeType): bool + { + // Basic MIME type validation: type/subtype + return (bool) preg_match( + '/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$/', + $mimeType + ); } /** - * Checks if a MIME type is an image type. + * Checks if this is an image MIME type. * * @since n.e.x.t * - * @param string $mimeType The MIME type to check. - * @return bool True if the MIME type is an image type. + * @return bool True if this is an image type. */ - public static function isImageType(string $mimeType): bool + public function isImage(): bool { - return strpos($mimeType, 'image/') === 0; + return strpos($this->value, 'image/') === 0; } /** - * Checks if a MIME type is an audio type. + * Checks if this is an audio MIME type. * * @since n.e.x.t * - * @param string $mimeType The MIME type to check. - * @return bool True if the MIME type is an audio type. + * @return bool True if this is an audio type. */ - public static function isAudioType(string $mimeType): bool + public function isAudio(): bool { - return strpos($mimeType, 'audio/') === 0; + return strpos($this->value, 'audio/') === 0; } /** - * Checks if a MIME type is a video type. + * Checks if this is a video MIME type. * * @since n.e.x.t * - * @param string $mimeType The MIME type to check. - * @return bool True if the MIME type is a video type. + * @return bool True if this is a video type. */ - public static function isVideoType(string $mimeType): bool + public function isVideo(): bool { - return strpos($mimeType, 'video/') === 0; + return strpos($this->value, 'video/') === 0; } /** - * Checks if a MIME type is a text type. + * Checks if this is a text MIME type. * * @since n.e.x.t * - * @param string $mimeType The MIME type to check. - * @return bool True if the MIME type is a text type. + * @return bool True if this is a text type. */ - public static function isTextType(string $mimeType): bool + public function isText(): bool { - return strpos($mimeType, 'text/') === 0; + return strpos($this->value, 'text/') === 0; } /** - * Checks if a MIME type is a document type. + * Checks if this is a document MIME type. * * @since n.e.x.t * - * @param string $mimeType The MIME type to check. - * @return bool True if the MIME type is a document type. + * @return bool True if this is a document type. */ - public static function isDocumentType(string $mimeType): bool + public function isDocument(): bool { $documentTypes = [ 'application/pdf', @@ -176,6 +219,35 @@ public static function isDocumentType(string $mimeType): bool 'application/vnd.oasis.opendocument.spreadsheet', ]; - return in_array($mimeType, $documentTypes, true); + return in_array($this->value, $documentTypes, true); + } + + /** + * Checks if this MIME type equals another. + * + * @since n.e.x.t + * + * @param self|string $other The other MIME type to compare. + * @return bool True if equal. + */ + public function equals($other): bool + { + if ($other instanceof self) { + return $this->value === $other->value; + } + + return $this->value === $other; + } + + /** + * Gets the string representation of the MIME type. + * + * @since n.e.x.t + * + * @return string The MIME type value. + */ + public function __toString(): string + { + return $this->value; } } diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index a97d8025..55bf4a40 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\Results\DTO; use WordPress\AiClient\Files\Contracts\FileInterface; -use WordPress\AiClient\Files\Utilities\MimeTypeUtil; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Results\Contracts\ResultInterface; @@ -188,7 +187,7 @@ public function toImageFile(): FileInterface { $file = $this->toFile(); - if (!MimeTypeUtil::isImageType($file->getMimeType())) { + if (!$file->getMimeType()->isImage()) { throw new \RuntimeException( sprintf('File is not an image. MIME type: %s', $file->getMimeType()) ); @@ -209,7 +208,7 @@ public function toAudioFile(): FileInterface { $file = $this->toFile(); - if (!MimeTypeUtil::isAudioType($file->getMimeType())) { + if (!$file->getMimeType()->isAudio()) { throw new \RuntimeException( sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()) ); @@ -230,7 +229,7 @@ public function toVideoFile(): FileInterface { $file = $this->toFile(); - if (!MimeTypeUtil::isVideoType($file->getMimeType())) { + if (!$file->getMimeType()->isVideo()) { throw new \RuntimeException( sprintf('File is not a video file. MIME type: %s', $file->getMimeType()) ); @@ -288,13 +287,13 @@ public function toImageFiles(): array $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && MimeTypeUtil::isImageType($inlineFile->getMimeType())) { + if ($inlineFile !== null && $inlineFile->getMimeType()->isImage()) { $files[] = $inlineFile; break; } $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && MimeTypeUtil::isImageType($remoteFile->getMimeType())) { + if ($remoteFile !== null && $remoteFile->getMimeType()->isImage()) { $files[] = $remoteFile; break; } @@ -317,13 +316,13 @@ public function toAudioFiles(): array $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && MimeTypeUtil::isAudioType($inlineFile->getMimeType())) { + if ($inlineFile !== null && $inlineFile->getMimeType()->isAudio()) { $files[] = $inlineFile; break; } $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && MimeTypeUtil::isAudioType($remoteFile->getMimeType())) { + if ($remoteFile !== null && $remoteFile->getMimeType()->isAudio()) { $files[] = $remoteFile; break; } @@ -346,13 +345,13 @@ public function toVideoFiles(): array $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && MimeTypeUtil::isVideoType($inlineFile->getMimeType())) { + if ($inlineFile !== null && $inlineFile->getMimeType()->isVideo()) { $files[] = $inlineFile; break; } $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && MimeTypeUtil::isVideoType($remoteFile->getMimeType())) { + if ($remoteFile !== null && $remoteFile->getMimeType()->isVideo()) { $files[] = $remoteFile; break; } From f99535ee2c75b491b7a986dda306a5055fda6dd3 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 12:36:01 -0600 Subject: [PATCH 22/42] refactor: consolidates mime type schema --- src/Files/DTO/InlineFile.php | 6 +----- src/Files/DTO/LocalFile.php | 6 +----- src/Files/DTO/RemoteFile.php | 6 +----- src/Files/Traits/HasMimeType.php | 16 ++++++++++++++++ 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index 231e52de..b521ffff 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -79,11 +79,7 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'mimeType' => [ - 'type' => 'string', - 'description' => 'The MIME type of the file.', - 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', - ], + 'mimeType' => self::getMimeTypePropertySchema(), 'base64Data' => [ 'type' => 'string', 'description' => 'The base64-encoded file data.', diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php index c89c353c..e2b43cb5 100644 --- a/src/Files/DTO/LocalFile.php +++ b/src/Files/DTO/LocalFile.php @@ -94,11 +94,7 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'mimeType' => [ - 'type' => 'string', - 'description' => 'The MIME type of the file.', - 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', - ], + 'mimeType' => self::getMimeTypePropertySchema(), 'path' => [ 'type' => 'string', 'description' => 'The local filesystem path to the file.', diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php index 02dc5c37..5ea05936 100644 --- a/src/Files/DTO/RemoteFile.php +++ b/src/Files/DTO/RemoteFile.php @@ -106,11 +106,7 @@ public static function getJsonSchema(): array return [ 'type' => 'object', 'properties' => [ - 'mimeType' => [ - 'type' => 'string', - 'description' => 'The MIME type of the file.', - 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*$', - ], + 'mimeType' => self::getMimeTypePropertySchema(), 'url' => [ 'type' => 'string', 'format' => 'uri', diff --git a/src/Files/Traits/HasMimeType.php b/src/Files/Traits/HasMimeType.php index 857444fb..f39683a2 100644 --- a/src/Files/Traits/HasMimeType.php +++ b/src/Files/Traits/HasMimeType.php @@ -34,4 +34,20 @@ public function getMimeType(): MimeType { return $this->mimeType; } + + /** + * Gets the JSON schema for the MIME type property. + * + * @return array{type: string, description: string, pattern: string} The JSON schema for the mimeType property. + * + * @since n.e.x.t + */ + protected static function getMimeTypePropertySchema(): array + { + return [ + 'type' => 'string', + 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*$', + ]; + } } From c5b632e4154aa6e3e3e53a33f466d37e04bc2d2e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:38:58 -0600 Subject: [PATCH 23/42] refactor: renames candidate count method --- src/Results/DTO/GenerativeAiResult.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 55bf4a40..767bbd7c 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -111,7 +111,7 @@ public function getProviderMetadata(): array * * @return int The total number of candidates. */ - public function getTotalCandidates(): int + public function getCandidateCount(): int { return count($this->candidates); } @@ -125,7 +125,7 @@ public function getTotalCandidates(): int */ public function hasMultipleCandidates(): bool { - return count($this->candidates) > 1; + return $this->getCandidateCount() > 1; } /** From 0540ea84558913ffce500fb5f4e5179f0df3d964 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:43:57 -0600 Subject: [PATCH 24/42] feat: adds toFiles() method --- src/Results/DTO/GenerativeAiResult.php | 69 +++++++++++--------------- 1 file changed, 28 insertions(+), 41 deletions(-) diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 767bbd7c..2d5de482 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -274,26 +274,26 @@ public function toTexts(): array } /** - * Converts all candidates to image files. + * Converts all candidates to files. * * @since n.e.x.t * - * @return FileInterface[] Array of image files. + * @return FileInterface[] Array of files. */ - public function toImageFiles(): array + public function toFiles(): array { $files = []; foreach ($this->candidates as $candidate) { $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && $inlineFile->getMimeType()->isImage()) { + if ($inlineFile !== null) { $files[] = $inlineFile; break; } $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && $remoteFile->getMimeType()->isImage()) { + if ($remoteFile !== null) { $files[] = $remoteFile; break; } @@ -302,6 +302,21 @@ public function toImageFiles(): array return $files; } + /** + * Converts all candidates to image files. + * + * @since n.e.x.t + * + * @return FileInterface[] Array of image files. + */ + public function toImageFiles(): array + { + return array_filter( + $this->toFiles(), + fn(FileInterface $file) => $file->getMimeType()->isImage() + ); + } + /** * Converts all candidates to audio files. * @@ -311,24 +326,10 @@ public function toImageFiles(): array */ public function toAudioFiles(): array { - $files = []; - foreach ($this->candidates as $candidate) { - $message = $candidate->getMessage(); - foreach ($message->getParts() as $part) { - $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && $inlineFile->getMimeType()->isAudio()) { - $files[] = $inlineFile; - break; - } - - $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && $remoteFile->getMimeType()->isAudio()) { - $files[] = $remoteFile; - break; - } - } - } - return $files; + return array_filter( + $this->toFiles(), + fn(FileInterface $file) => $file->getMimeType()->isAudio() + ); } /** @@ -340,24 +341,10 @@ public function toAudioFiles(): array */ public function toVideoFiles(): array { - $files = []; - foreach ($this->candidates as $candidate) { - $message = $candidate->getMessage(); - foreach ($message->getParts() as $part) { - $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null && $inlineFile->getMimeType()->isVideo()) { - $files[] = $inlineFile; - break; - } - - $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null && $remoteFile->getMimeType()->isVideo()) { - $files[] = $remoteFile; - break; - } - } - } - return $files; + return array_filter( + $this->toFiles(), + fn(FileInterface $file) => $file->getMimeType()->isVideo() + ); } /** From a80601e2c6f403d0064ec47256a716aa9a8f954e Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:48:08 -0600 Subject: [PATCH 25/42] refactor: cleans up Tool to work like MessagePart --- src/Tools/DTO/Tool.php | 91 +++++++++++++++++++----------------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/src/Tools/DTO/Tool.php b/src/Tools/DTO/Tool.php index b408f402..ccc2d108 100644 --- a/src/Tools/DTO/Tool.php +++ b/src/Tools/DTO/Tool.php @@ -33,46 +33,28 @@ class Tool implements WithJsonSchemaInterface private ?WebSearch $webSearch = null; /** - * Private constructor to enforce factory method usage. + * Constructor. * * @since n.e.x.t * - * @param ToolTypeEnum $type The type of tool. + * @param FunctionDeclaration[]|WebSearch $content The tool content. + * @throws \InvalidArgumentException If content type is not supported. */ - private function __construct(ToolTypeEnum $type) + public function __construct($content) { - $this->type = $type; + if (is_array($content)) { + $this->type = ToolTypeEnum::functionDeclarations(); + $this->functionDeclarations = $content; + } elseif ($content instanceof WebSearch) { + $this->type = ToolTypeEnum::webSearch(); + $this->webSearch = $content; + } else { + throw new \InvalidArgumentException( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + } } - /** - * Creates a function declarations tool. - * - * @since n.e.x.t - * - * @param FunctionDeclaration[] $declarations The function declarations. - * @return self - */ - public static function functionDeclarations(array $declarations): self - { - $tool = new self(ToolTypeEnum::functionDeclarations()); - $tool->functionDeclarations = $declarations; - return $tool; - } - - /** - * Creates a web search tool. - * - * @since n.e.x.t - * - * @param WebSearch $webSearch The web search configuration. - * @return self - */ - public static function webSearch(WebSearch $webSearch): self - { - $tool = new self(ToolTypeEnum::webSearch()); - $tool->webSearch = $webSearch; - return $tool; - } /** * Gets the tool type. @@ -118,27 +100,36 @@ public function getWebSearch(): ?WebSearch public static function getJsonSchema(): array { return [ - 'type' => 'object', - 'properties' => [ - 'type' => [ - 'type' => 'string', - 'enum' => ['function_declarations', 'web_search'], - 'description' => 'The type of tool.', - ], - 'functionDeclarations' => [ - 'type' => ['array', 'null'], - 'items' => FunctionDeclaration::getJsonSchema(), - 'description' => 'Function declarations (when type is function_declarations).', + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => ToolTypeEnum::functionDeclarations()->value, + 'description' => 'The type of tool.', + ], + 'functionDeclarations' => [ + 'type' => 'array', + 'items' => FunctionDeclaration::getJsonSchema(), + 'description' => 'Function declarations.', + ], + ], + 'required' => ['type', 'functionDeclarations'], ], - 'webSearch' => [ - 'oneOf' => [ - ['type' => 'null'], - WebSearch::getJsonSchema(), + [ + 'type' => 'object', + 'properties' => [ + 'type' => [ + 'type' => 'string', + 'const' => ToolTypeEnum::webSearch()->value, + 'description' => 'The type of tool.', + ], + 'webSearch' => WebSearch::getJsonSchema(), ], - 'description' => 'Web search configuration (when type is web_search).', + 'required' => ['type', 'webSearch'], ], ], - 'required' => ['type'], ]; } } From b5bb8eee231bcc74fbc772f791046b9ae74898c2 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:51:12 -0600 Subject: [PATCH 26/42] feat: adds response type to FunctionResponse --- src/Tools/DTO/FunctionResponse.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Tools/DTO/FunctionResponse.php b/src/Tools/DTO/FunctionResponse.php index 8e7fe0f9..42a1e617 100644 --- a/src/Tools/DTO/FunctionResponse.php +++ b/src/Tools/DTO/FunctionResponse.php @@ -102,6 +102,7 @@ public static function getJsonSchema(): array 'description' => 'The name of the function that was called.', ], 'response' => [ + 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The response data from the function.', ], ], From 849558c52a6402202c25a64a864b09f9ba7c08fa Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:58:05 -0600 Subject: [PATCH 27/42] fix: adds missing parameter types --- src/Tools/DTO/FunctionDeclaration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 4dd3f958..b1755914 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -102,7 +102,7 @@ public static function getJsonSchema(): array 'description' => 'A description of what the function does.', ], 'parameters' => [ - 'type' => 'object', + 'type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The JSON schema for the function parameters.', ], ], From a1867dc49212d1e955e3f493cf85cf2758baff68 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 16:59:07 -0600 Subject: [PATCH 28/42] refactor: uses enum for json enum values --- src/Results/DTO/Candidate.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Results/DTO/Candidate.php b/src/Results/DTO/Candidate.php index 0ab8dc72..bdb9360d 100644 --- a/src/Results/DTO/Candidate.php +++ b/src/Results/DTO/Candidate.php @@ -104,7 +104,7 @@ public static function getJsonSchema(): array 'message' => Message::getJsonSchema(), 'finishReason' => [ 'type' => 'string', - 'enum' => ['stop', 'length', 'content_filter', 'tool_calls', 'error'], + 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.', ], 'tokenCount' => [ From af53eaf9e3e6df381d62076f199c6a6a76f64b42 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 17:18:03 -0600 Subject: [PATCH 29/42] feat: adds pure base64 support to InlineFile --- src/Files/DTO/InlineFile.php | 83 ++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 17 deletions(-) diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php index b521ffff..27b306b0 100644 --- a/src/Files/DTO/InlineFile.php +++ b/src/Files/DTO/InlineFile.php @@ -22,7 +22,7 @@ class InlineFile implements FileInterface, WithJsonSchemaInterface use HasMimeType; /** - * @var string The base64-encoded file data. + * @var string The plain base64-encoded file data (without data URI prefix). */ private string $base64Data; @@ -32,29 +32,37 @@ class InlineFile implements FileInterface, WithJsonSchemaInterface * @since n.e.x.t * * @param string $base64Data The base64-encoded file data. - * @param MimeType|string $mimeType The MIME type of the file. + * @param MimeType|string|null $mimeType The MIME type of the file. */ - public function __construct(string $base64Data, $mimeType) + public function __construct(string $base64Data, $mimeType = null) { // RFC 2397: dataurl := "data:" [ mediatype ] ";base64," data - // mediatype is optional; if omitted, defaults to text/plain;charset=US-ASCII - // We'll be more permissive and accept data URLs with or without MIME type - $pattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' + $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/'; - if (!preg_match($pattern, $base64Data, $matches)) { - throw new \InvalidArgumentException( - 'Invalid base64 data provided. Expected format: data:[mimeType];base64,[data]' - ); + // Check if this is a data URI + if (preg_match($dataUriPattern, $base64Data, $matches)) { + $this->base64Data = $matches[2]; + $this->mimeType = $this->parseMimeType($mimeType, empty($matches[1]) ? null : $matches[1]); + return; } - $this->base64Data = $base64Data; - - if ($mimeType instanceof MimeType) { - $this->mimeType = $mimeType; - } else { - $this->mimeType = new MimeType($mimeType); + // Check if this is plain base64 data + if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $base64Data)) { + if ($mimeType === null) { + throw new \InvalidArgumentException( + 'MIME type is required when providing plain base64 data without data URI format.' + ); + } + $this->base64Data = $base64Data; + $this->mimeType = $this->parseMimeType($mimeType); + return; } + + throw new \InvalidArgumentException( + 'Invalid base64 data provided. Expected either data URI format ' + . '(data:[mimeType];base64,[data]) or plain base64 string.' + ); } /** @@ -62,13 +70,25 @@ public function __construct(string $base64Data, $mimeType) * * @since n.e.x.t * - * @return string The base64-encoded data. + * @return string The plain base64-encoded data (without data URI prefix). */ public function getBase64Data(): string { return $this->base64Data; } + /** + * Gets the data as a data URL. + * + * @since n.e.x.t + * + * @return string The data URL in format: data:[mimeType];base64,[data]. + */ + public function getDataUrl(): string + { + return sprintf('data:%s;base64,%s', (string) $this->mimeType, $this->base64Data); + } + /** * {@inheritDoc} * @@ -88,4 +108,33 @@ public static function getJsonSchema(): array 'required' => ['mimeType', 'base64Data'], ]; } + + /** + * Parses and validates the MIME type. + * + * @since n.e.x.t + * + * @param MimeType|string|null $providedMimeType The explicitly provided MIME type. + * @param string|null $extractedMimeType The MIME type extracted from data URI. + * @return MimeType The parsed MIME type. + */ + private function parseMimeType($providedMimeType, ?string $extractedMimeType = null): MimeType + { + // Prefer explicitly provided MIME type + if ($providedMimeType instanceof MimeType) { + return $providedMimeType; + } + + if ($providedMimeType !== null) { + return new MimeType($providedMimeType); + } + + // Use extracted MIME type from data URI + if ($extractedMimeType !== null) { + return new MimeType($extractedMimeType); + } + + // RFC 2397: if mediatype is omitted in data URI, defaults to text/plain + return new MimeType('text/plain'); + } } From 76f6b7b436d9ae354a523344dbe096e722493bca Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 24 Jul 2025 17:45:26 -0600 Subject: [PATCH 30/42] fix: normalizes mime type value for comparison --- src/Files/ValueObjects/MimeType.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index 3978b857..d5477ca2 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -108,7 +108,7 @@ public function __construct(string $value) ); } - $this->value = $value; + $this->value = strtolower($value); } /** @@ -236,7 +236,13 @@ public function equals($other): bool return $this->value === $other->value; } - return $this->value === $other; + if (is_string($other)) { + return $this->value === strtolower($other); + } + + throw new \InvalidArgumentException( + sprintf('Invalid MIME type comparison: %s', gettype($other)) + ); } /** From cbd489d02e5c54a32c4b73911e1b29d5defce5a9 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 10:06:53 -0600 Subject: [PATCH 31/42] chore: adjusts doc type to appease PHPStan bug --- src/Files/ValueObjects/MimeType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index d5477ca2..1c947c98 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -227,7 +227,7 @@ public function isDocument(): bool * * @since n.e.x.t * - * @param self|string $other The other MIME type to compare. + * @param mixed $other The other MIME type to compare. * @return bool True if equal. */ public function equals($other): bool From 4c25a863c45452bbacf11e48afbd59c25e304d24 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 13:09:19 -0600 Subject: [PATCH 32/42] feat: adds file type checking --- src/Files/Contracts/FileInterface.php | 50 +++++++++++++++-- src/Files/Traits/HasMimeType.php | 74 +++++++++++++++++++++++--- src/Results/DTO/GenerativeAiResult.php | 12 ++--- 3 files changed, 120 insertions(+), 16 deletions(-) diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php index c39f9af3..fa6688eb 100644 --- a/src/Files/Contracts/FileInterface.php +++ b/src/Files/Contracts/FileInterface.php @@ -17,10 +17,54 @@ interface FileInterface { /** - * Gets the MIME type of the file. + * Gets the MIME type of the file as a string. * * @since n.e.x.t - * @return MimeType The MIME type (e.g., 'image/png', 'audio/mp3'). + * @return string The MIME type string (e.g., 'image/png', 'audio/mp3'). */ - public function getMimeType(): MimeType; + public function getMimeType(): string; + + /** + * Gets the MIME type object. + * + * @since n.e.x.t + * @return MimeType The MIME type object. + */ + public function getMimeTypeObject(): MimeType; + + /** + * Checks if the file is a video. + * + * @since n.e.x.t + * + * @return bool True if the file is a video. + */ + public function isVideo(): bool; + + /** + * Checks if the file is an image. + * + * @since n.e.x.t + * + * @return bool True if the file is an image. + */ + public function isImage(): bool; + + /** + * Checks if the file is audio. + * + * @since n.e.x.t + * + * @return bool True if the file is audio. + */ + public function isAudio(): bool; + + /** + * Checks if the file is text. + * + * @since n.e.x.t + * + * @return bool True if the file is text. + */ + public function isText(): bool; } diff --git a/src/Files/Traits/HasMimeType.php b/src/Files/Traits/HasMimeType.php index f39683a2..8a559663 100644 --- a/src/Files/Traits/HasMimeType.php +++ b/src/Files/Traits/HasMimeType.php @@ -12,7 +12,7 @@ * This trait can be used by any class that needs to store and retrieve * a MIME type property. * - * @since 1.0.0 + * @since n.e.x.t */ trait HasMimeType { @@ -24,23 +24,83 @@ trait HasMimeType protected MimeType $mimeType; /** - * Gets the MIME type of the file. + * Gets the MIME type of the file as a string. * - * @return MimeType The MIME type. + * @since n.e.x.t + * + * @return string The MIME type string value. + */ + public function getMimeType(): string + { + return (string) $this->mimeType; + } + + /** + * Gets the MIME type object. + * + * @since n.e.x.t * - * @since 1.0.0 + * @return MimeType The MIME type object. */ - public function getMimeType(): MimeType + public function getMimeTypeObject(): MimeType { return $this->mimeType; } /** - * Gets the JSON schema for the MIME type property. + * Checks if the file is a video. * - * @return array{type: string, description: string, pattern: string} The JSON schema for the mimeType property. + * @since n.e.x.t + * + * @return bool True if the file is a video. + */ + public function isVideo(): bool + { + return $this->mimeType->isVideo(); + } + + /** + * Checks if the file is an image. * * @since n.e.x.t + * + * @return bool True if the file is an image. + */ + public function isImage(): bool + { + return $this->mimeType->isImage(); + } + + /** + * Checks if the file is audio. + * + * @since n.e.x.t + * + * @return bool True if the file is audio. + */ + public function isAudio(): bool + { + return $this->mimeType->isAudio(); + } + + /** + * Checks if the file is text. + * + * @since n.e.x.t + * + * @return bool True if the file is text. + */ + public function isText(): bool + { + return $this->mimeType->isText(); + } + + /** + * Gets the JSON schema for the MIME type property. + * + * @since n.e.x.t + * + * @return array{type: string, description: string, pattern: string} The JSON schema for the mimeType property. */ protected static function getMimeTypePropertySchema(): array { diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 2d5de482..59d63e93 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -187,7 +187,7 @@ public function toImageFile(): FileInterface { $file = $this->toFile(); - if (!$file->getMimeType()->isImage()) { + if (!$file->isImage()) { throw new \RuntimeException( sprintf('File is not an image. MIME type: %s', $file->getMimeType()) ); @@ -208,7 +208,7 @@ public function toAudioFile(): FileInterface { $file = $this->toFile(); - if (!$file->getMimeType()->isAudio()) { + if (!$file->isAudio()) { throw new \RuntimeException( sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()) ); @@ -229,7 +229,7 @@ public function toVideoFile(): FileInterface { $file = $this->toFile(); - if (!$file->getMimeType()->isVideo()) { + if (!$file->isVideo()) { throw new \RuntimeException( sprintf('File is not a video file. MIME type: %s', $file->getMimeType()) ); @@ -313,7 +313,7 @@ public function toImageFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->getMimeType()->isImage() + fn(FileInterface $file) => $file->isImage() ); } @@ -328,7 +328,7 @@ public function toAudioFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->getMimeType()->isAudio() + fn(FileInterface $file) => $file->isAudio() ); } @@ -343,7 +343,7 @@ public function toVideoFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->getMimeType()->isVideo() + fn(FileInterface $file) => $file->isVideo() ); } From 980a880d541ee5943e0af608f415fc8dccd9f409 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 13:33:03 -0600 Subject: [PATCH 33/42] refactor: consolidates down to a single File class --- src/Files/DTO/File.php | 377 ++++++++++++++++++ src/Files/DTO/InlineFile.php | 140 ------- src/Files/DTO/LocalFile.php | 106 ----- src/Files/DTO/RemoteFile.php | 119 ------ src/Files/Enums/FileTypeEnum.php | 34 ++ src/Files/Traits/HasMimeType.php | 113 ------ src/Messages/DTO/MessagePart.php | 61 +-- src/Messages/Enums/MessagePartTypeEnum.php | 15 +- src/Results/DTO/GenerativeAiResult.php | 23 +- .../Enums/MessagePartTypeEnumTest.php | 11 +- 10 files changed, 440 insertions(+), 559 deletions(-) create mode 100644 src/Files/DTO/File.php delete mode 100644 src/Files/DTO/InlineFile.php delete mode 100644 src/Files/DTO/LocalFile.php delete mode 100644 src/Files/DTO/RemoteFile.php create mode 100644 src/Files/Enums/FileTypeEnum.php delete mode 100644 src/Files/Traits/HasMimeType.php diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php new file mode 100644 index 00000000..256225a5 --- /dev/null +++ b/src/Files/DTO/File.php @@ -0,0 +1,377 @@ +detectAndProcessFile($file, $mimeType); + } + + /** + * Detects the file type and processes it accordingly. + * + * @since n.e.x.t + * + * @param string $file The file string to process. + * @param string|null $providedMimeType The explicitly provided MIME type. + * @throws \InvalidArgumentException If the file format is invalid or MIME type cannot be determined. + */ + private function detectAndProcessFile(string $file, ?string $providedMimeType): void + { + // Check if it's a URL + if ($this->isUrl($file)) { + $this->fileType = FileTypeEnum::remote(); + $this->data = $file; + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + + // Check if it's a data URI + $dataUriPattern = '/^data:(?:([a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_+.]*' + . '(?:;[a-zA-Z0-9\-]+=[a-zA-Z0-9\-]+)*)?;)?base64,([A-Za-z0-9+\/]*={0,2})$/'; + + if (preg_match($dataUriPattern, $file, $matches)) { + $this->fileType = FileTypeEnum::inline(); + $this->data = $matches[2]; // Extract just the base64 data + $extractedMimeType = empty($matches[1]) ? null : $matches[1]; + $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null); + return; + } + + // Check if it's plain base64 + if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) { + if ($providedMimeType === null) { + throw new \InvalidArgumentException( + 'MIME type is required when providing plain base64 data without data URI format.' + ); + } + $this->fileType = FileTypeEnum::inline(); + $this->data = $file; + $this->mimeType = new MimeType($providedMimeType); + return; + } + + // If none of the above, assume it's a local file path + if (file_exists($file)) { + $this->fileType = FileTypeEnum::inline(); + $this->data = $this->convertFileToBase64($file); + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + + throw new \InvalidArgumentException( + 'Invalid file provided. Expected URL, base64 data, or valid local file path.' + ); + } + + /** + * Checks if a string is a valid URL. + * + * @since n.e.x.t + * + * @param string $string The string to check. + * @return bool True if the string is a URL. + */ + private function isUrl(string $string): bool + { + return filter_var($string, FILTER_VALIDATE_URL) !== false + && preg_match('/^https?:\/\//i', $string); + } + + /** + * Converts a local file to base64. + * + * @since n.e.x.t + * + * @param string $filePath The path to the local file. + * @return string The base64-encoded file data. + * @throws \RuntimeException If the file cannot be read. + */ + private function convertFileToBase64(string $filePath): string + { + $fileContent = @file_get_contents($filePath); + + if ($fileContent === false) { + throw new \RuntimeException( + sprintf('Unable to read file: %s', $filePath) + ); + } + + return base64_encode($fileContent); + } + + /** + * Gets the file type. + * + * @since n.e.x.t + * + * @return FileTypeEnum The file type. + */ + public function getFileType(): FileTypeEnum + { + return $this->fileType; + } + + /** + * Gets the URL for remote files. + * + * @since n.e.x.t + * + * @return string The URL. + * @throws \RuntimeException If the file is not remote. + */ + public function getUrl(): string + { + if (!$this->fileType->isRemote()) { + throw new \RuntimeException('Cannot get URL for non-remote file.'); + } + + return $this->data; + } + + /** + * Gets the base64-encoded data for inline files. + * + * @since n.e.x.t + * + * @return string The plain base64-encoded data (without data URI prefix). + * @throws \RuntimeException If the file is not inline. + */ + public function getBase64Data(): string + { + if (!$this->fileType->isInline()) { + throw new \RuntimeException('Cannot get base64 data for non-inline file.'); + } + + return $this->data; + } + + /** + * Gets the data as a data URL for inline files. + * + * @since n.e.x.t + * + * @return string The data URL in format: data:[mimeType];base64,[data]. + * @throws \RuntimeException If the file is not inline. + */ + public function getDataUrl(): string + { + if (!$this->fileType->isInline()) { + throw new \RuntimeException('Cannot get data URL for non-inline file.'); + } + + return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->data); + } + + /** + * Gets the MIME type of the file as a string. + * + * @since n.e.x.t + * + * @return string The MIME type string value. + */ + public function getMimeType(): string + { + return (string) $this->mimeType; + } + + /** + * Gets the MIME type object. + * + * @since n.e.x.t + * + * @return MimeType The MIME type object. + */ + public function getMimeTypeObject(): MimeType + { + return $this->mimeType; + } + + /** + * Checks if the file is a video. + * + * @since n.e.x.t + * + * @return bool True if the file is a video. + */ + public function isVideo(): bool + { + return $this->mimeType->isVideo(); + } + + /** + * Checks if the file is an image. + * + * @since n.e.x.t + * + * @return bool True if the file is an image. + */ + public function isImage(): bool + { + return $this->mimeType->isImage(); + } + + /** + * Checks if the file is audio. + * + * @since n.e.x.t + * + * @return bool True if the file is audio. + */ + public function isAudio(): bool + { + return $this->mimeType->isAudio(); + } + + /** + * Checks if the file is text. + * + * @since n.e.x.t + * + * @return bool True if the file is text. + */ + public function isText(): bool + { + return $this->mimeType->isText(); + } + + /** + * Determines the MIME type from various sources. + * + * @since n.e.x.t + * + * @param string|null $providedMimeType The explicitly provided MIME type. + * @param string|null $extractedMimeType The MIME type extracted from data URI. + * @param string|null $pathOrUrl The file path or URL to extract extension from. + * @return MimeType The determined MIME type. + * @throws \InvalidArgumentException If MIME type cannot be determined. + */ + private function determineMimeType( + ?string $providedMimeType, + ?string $extractedMimeType, + ?string $pathOrUrl + ): MimeType { + // Prefer explicitly provided MIME type + if ($providedMimeType !== null) { + return new MimeType($providedMimeType); + } + + // Use extracted MIME type from data URI + if ($extractedMimeType !== null) { + return new MimeType($extractedMimeType); + } + + // Try to determine from file extension + if ($pathOrUrl !== null) { + $parsedUrl = parse_url($pathOrUrl); + $path = $parsedUrl['path'] ?? $pathOrUrl; + + // Remove query string and fragment if present + $cleanPath = strtok($path, '?#'); + if ($cleanPath === false) { + $cleanPath = $path; + } + + $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); + if (!empty($extension)) { + try { + return MimeType::fromExtension($extension); + } catch (\InvalidArgumentException $e) { + // Extension not recognized, continue to error + unset($e); + } + } + } + + throw new \InvalidArgumentException( + 'Unable to determine MIME type. Please provide it explicitly.' + ); + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'oneOf' => [ + [ + 'properties' => [ + 'mimeType' => [ + 'type' => 'string', + 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]' + . '[a-zA-Z0-9!#$&\\-\\^_+.]*$', + ], + 'url' => [ + 'type' => 'string', + 'format' => 'uri', + 'description' => 'The URL to the remote file.', + ], + ], + 'required' => ['mimeType', 'url'], + ], + [ + 'properties' => [ + 'mimeType' => [ + 'type' => 'string', + 'description' => 'The MIME type of the file.', + 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9]' + . '[a-zA-Z0-9!#$&\\-\\^_+.]*$', + ], + 'base64Data' => [ + 'type' => 'string', + 'description' => 'The base64-encoded file data.', + ], + ], + 'required' => ['mimeType', 'base64Data'], + ], + ], + ]; + } +} diff --git a/src/Files/DTO/InlineFile.php b/src/Files/DTO/InlineFile.php deleted file mode 100644 index 27b306b0..00000000 --- a/src/Files/DTO/InlineFile.php +++ /dev/null @@ -1,140 +0,0 @@ -base64Data = $matches[2]; - $this->mimeType = $this->parseMimeType($mimeType, empty($matches[1]) ? null : $matches[1]); - return; - } - - // Check if this is plain base64 data - if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $base64Data)) { - if ($mimeType === null) { - throw new \InvalidArgumentException( - 'MIME type is required when providing plain base64 data without data URI format.' - ); - } - $this->base64Data = $base64Data; - $this->mimeType = $this->parseMimeType($mimeType); - return; - } - - throw new \InvalidArgumentException( - 'Invalid base64 data provided. Expected either data URI format ' - . '(data:[mimeType];base64,[data]) or plain base64 string.' - ); - } - - /** - * Gets the base64-encoded data. - * - * @since n.e.x.t - * - * @return string The plain base64-encoded data (without data URI prefix). - */ - public function getBase64Data(): string - { - return $this->base64Data; - } - - /** - * Gets the data as a data URL. - * - * @since n.e.x.t - * - * @return string The data URL in format: data:[mimeType];base64,[data]. - */ - public function getDataUrl(): string - { - return sprintf('data:%s;base64,%s', (string) $this->mimeType, $this->base64Data); - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'mimeType' => self::getMimeTypePropertySchema(), - 'base64Data' => [ - 'type' => 'string', - 'description' => 'The base64-encoded file data.', - ], - ], - 'required' => ['mimeType', 'base64Data'], - ]; - } - - /** - * Parses and validates the MIME type. - * - * @since n.e.x.t - * - * @param MimeType|string|null $providedMimeType The explicitly provided MIME type. - * @param string|null $extractedMimeType The MIME type extracted from data URI. - * @return MimeType The parsed MIME type. - */ - private function parseMimeType($providedMimeType, ?string $extractedMimeType = null): MimeType - { - // Prefer explicitly provided MIME type - if ($providedMimeType instanceof MimeType) { - return $providedMimeType; - } - - if ($providedMimeType !== null) { - return new MimeType($providedMimeType); - } - - // Use extracted MIME type from data URI - if ($extractedMimeType !== null) { - return new MimeType($extractedMimeType); - } - - // RFC 2397: if mediatype is omitted in data URI, defaults to text/plain - return new MimeType('text/plain'); - } -} diff --git a/src/Files/DTO/LocalFile.php b/src/Files/DTO/LocalFile.php deleted file mode 100644 index e2b43cb5..00000000 --- a/src/Files/DTO/LocalFile.php +++ /dev/null @@ -1,106 +0,0 @@ -path = $path; - - if ($mimeType instanceof MimeType) { - $this->mimeType = $mimeType; - } elseif (is_string($mimeType)) { - $this->mimeType = new MimeType($mimeType); - } else { - $this->mimeType = $this->getMimeTypeFromExtension($path); - } - } - - /** - * Gets the local filesystem path. - * - * @since n.e.x.t - * - * @return string The local path. - */ - public function getPath(): string - { - return $this->path; - } - - /** - * Extracts MIME type from file extension. - * - * @since n.e.x.t - * - * @param string $path The file path. - * @return MimeType The MIME type. - */ - private function getMimeTypeFromExtension(string $path): MimeType - { - $extension = pathinfo($path, PATHINFO_EXTENSION); - - if (!empty($extension)) { - try { - return MimeType::fromExtension($extension); - } catch (\InvalidArgumentException $e) { - // Unknown extension, default to text/plain - return new MimeType('text/plain'); - } - } - - // No extension found, default to text/plain - return new MimeType('text/plain'); - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'mimeType' => self::getMimeTypePropertySchema(), - 'path' => [ - 'type' => 'string', - 'description' => 'The local filesystem path to the file.', - ], - ], - 'required' => ['mimeType', 'path'], - ]; - } -} diff --git a/src/Files/DTO/RemoteFile.php b/src/Files/DTO/RemoteFile.php deleted file mode 100644 index 5ea05936..00000000 --- a/src/Files/DTO/RemoteFile.php +++ /dev/null @@ -1,119 +0,0 @@ -url = $url; - - if ($mimeType instanceof MimeType) { - $this->mimeType = $mimeType; - } elseif (is_string($mimeType)) { - $this->mimeType = new MimeType($mimeType); - } else { - $this->mimeType = $this->getMimeTypeFromExtension($url); - } - } - - /** - * Gets the URL to the remote file. - * - * @since n.e.x.t - * - * @return string The URL. - */ - public function getUrl(): string - { - return $this->url; - } - - /** - * Extracts MIME type from URL extension. - * - * @since n.e.x.t - * - * @param string $url The file URL. - * @return MimeType The MIME type. - */ - private function getMimeTypeFromExtension(string $url): MimeType - { - // Parse URL to extract filename and extension - $parsedUrl = parse_url($url); - $path = $parsedUrl['path'] ?? ''; - - // Remove query string and fragment if present in the path - $cleanPath = strtok($path, '?#'); - - if ($cleanPath === false) { - $cleanPath = $path; - } - - // Extract extension from the path - $extension = pathinfo($cleanPath, PATHINFO_EXTENSION); - - if (!empty($extension)) { - try { - return MimeType::fromExtension($extension); - } catch (\InvalidArgumentException $e) { - // Unknown extension, default to text/plain - return new MimeType('text/plain'); - } - } - - // No extension found, default to text/plain - return new MimeType('text/plain'); - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'mimeType' => self::getMimeTypePropertySchema(), - 'url' => [ - 'type' => 'string', - 'format' => 'uri', - 'description' => 'The URL to the remote file.', - ], - ], - 'required' => ['mimeType', 'url'], - ]; - } -} diff --git a/src/Files/Enums/FileTypeEnum.php b/src/Files/Enums/FileTypeEnum.php new file mode 100644 index 00000000..557bc5ca --- /dev/null +++ b/src/Files/Enums/FileTypeEnum.php @@ -0,0 +1,34 @@ +mimeType; - } - - /** - * Gets the MIME type object. - * - * @since n.e.x.t - * - * @return MimeType The MIME type object. - */ - public function getMimeTypeObject(): MimeType - { - return $this->mimeType; - } - - /** - * Checks if the file is a video. - * - * @since n.e.x.t - * - * @return bool True if the file is a video. - */ - public function isVideo(): bool - { - return $this->mimeType->isVideo(); - } - - /** - * Checks if the file is an image. - * - * @since n.e.x.t - * - * @return bool True if the file is an image. - */ - public function isImage(): bool - { - return $this->mimeType->isImage(); - } - - /** - * Checks if the file is audio. - * - * @since n.e.x.t - * - * @return bool True if the file is audio. - */ - public function isAudio(): bool - { - return $this->mimeType->isAudio(); - } - - /** - * Checks if the file is text. - * - * @since n.e.x.t - * - * @return bool True if the file is text. - */ - public function isText(): bool - { - return $this->mimeType->isText(); - } - - /** - * Gets the JSON schema for the MIME type property. - * - * @since n.e.x.t - * - * @return array{type: string, description: string, pattern: string} The JSON schema for the mimeType property. - */ - protected static function getMimeTypePropertySchema(): array - { - return [ - 'type' => 'string', - 'description' => 'The MIME type of the file.', - 'pattern' => '^[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*\\/[a-zA-Z0-9][a-zA-Z0-9!#$&\\-\\^_+.]*$', - ]; - } -} diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 38ac9f0c..ddaf3945 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -5,8 +5,7 @@ namespace WordPress\AiClient\Messages\DTO; use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface; -use WordPress\AiClient\Files\DTO\InlineFile; -use WordPress\AiClient\Files\DTO\RemoteFile; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -32,14 +31,9 @@ class MessagePart implements WithJsonSchemaInterface private ?string $text = null; /** - * @var InlineFile|null Inline file data (when type is INLINE_FILE). + * @var File|null File data (when type is FILE). */ - private ?InlineFile $inlineFile = null; - - /** - * @var RemoteFile|null Remote file reference (when type is REMOTE_FILE). - */ - private ?RemoteFile $remoteFile = null; + private ?File $file = null; /** * @var FunctionCall|null Function call request (when type is FUNCTION_CALL). @@ -64,12 +58,9 @@ public function __construct($content) if (is_string($content)) { $this->type = MessagePartTypeEnum::text(); $this->text = $content; - } elseif ($content instanceof InlineFile) { - $this->type = MessagePartTypeEnum::inlineFile(); - $this->inlineFile = $content; - } elseif ($content instanceof RemoteFile) { - $this->type = MessagePartTypeEnum::remoteFile(); - $this->remoteFile = $content; + } elseif ($content instanceof File) { + $this->type = MessagePartTypeEnum::file(); + $this->file = $content; } elseif ($content instanceof FunctionCall) { $this->type = MessagePartTypeEnum::functionCall(); $this->functionCall = $content; @@ -80,7 +71,7 @@ public function __construct($content) $type = is_object($content) ? get_class($content) : gettype($content); throw new \InvalidArgumentException( sprintf( - 'Unsupported content type %s. Expected string, InlineFile, RemoteFile, ' + 'Unsupported content type %s. Expected string, File, ' . 'FunctionCall, or FunctionResponse.', $type ) @@ -113,27 +104,15 @@ public function getText(): ?string } /** - * Gets the inline file. - * - * @since n.e.x.t - * - * @return InlineFile|null The inline file or null if not an inline file part. - */ - public function getInlineFile(): ?InlineFile - { - return $this->inlineFile; - } - - /** - * Gets the remote file. + * Gets the file. * * @since n.e.x.t * - * @return RemoteFile|null The remote file or null if not a remote file part. + * @return File|null The file or null if not a file part. */ - public function getRemoteFile(): ?RemoteFile + public function getFile(): ?File { - return $this->remoteFile; + return $this->file; } /** @@ -189,23 +168,11 @@ public static function getJsonSchema(): array 'properties' => [ 'type' => [ 'type' => 'string', - 'const' => MessagePartTypeEnum::inlineFile()->value, - ], - 'inlineFile' => InlineFile::getJsonSchema(), - ], - 'required' => ['type', 'inlineFile'], - 'additionalProperties' => false, - ], - [ - 'type' => 'object', - 'properties' => [ - 'type' => [ - 'type' => 'string', - 'const' => MessagePartTypeEnum::remoteFile()->value, + 'const' => MessagePartTypeEnum::file()->value, ], - 'remoteFile' => RemoteFile::getJsonSchema(), + 'file' => File::getJsonSchema(), ], - 'required' => ['type', 'remoteFile'], + 'required' => ['type', 'file'], 'additionalProperties' => false, ], [ diff --git a/src/Messages/Enums/MessagePartTypeEnum.php b/src/Messages/Enums/MessagePartTypeEnum.php index b7abeb64..3db70b9a 100644 --- a/src/Messages/Enums/MessagePartTypeEnum.php +++ b/src/Messages/Enums/MessagePartTypeEnum.php @@ -12,13 +12,11 @@ * @since n.e.x.t * * @method static self text() Creates an instance for TEXT type. - * @method static self inlineFile() Creates an instance for INLINE_FILE type. - * @method static self remoteFile() Creates an instance for REMOTE_FILE type. + * @method static self file() Creates an instance for FILE type. * @method static self functionCall() Creates an instance for FUNCTION_CALL type. * @method static self functionResponse() Creates an instance for FUNCTION_RESPONSE type. * @method bool isText() Checks if the type is TEXT. - * @method bool isInlineFile() Checks if the type is INLINE_FILE. - * @method bool isRemoteFile() Checks if the type is REMOTE_FILE. + * @method bool isFile() Checks if the type is FILE. * @method bool isFunctionCall() Checks if the type is FUNCTION_CALL. * @method bool isFunctionResponse() Checks if the type is FUNCTION_RESPONSE. */ @@ -30,14 +28,9 @@ class MessagePartTypeEnum extends AbstractEnum public const TEXT = 'text'; /** - * Inline file content (base64 encoded). + * File content (inline or remote). */ - public const INLINE_FILE = 'inline_file'; - - /** - * Remote file reference (URL). - */ - public const REMOTE_FILE = 'remote_file'; + public const FILE = 'file'; /** * Function call request. diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 59d63e93..f1cacb02 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -161,14 +161,9 @@ public function toFile(): FileInterface { $message = $this->candidates[0]->getMessage(); foreach ($message->getParts() as $part) { - $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null) { - return $inlineFile; - } - - $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null) { - return $remoteFile; + $file = $part->getFile(); + if ($file !== null) { + return $file; } } @@ -286,15 +281,9 @@ public function toFiles(): array foreach ($this->candidates as $candidate) { $message = $candidate->getMessage(); foreach ($message->getParts() as $part) { - $inlineFile = $part->getInlineFile(); - if ($inlineFile !== null) { - $files[] = $inlineFile; - break; - } - - $remoteFile = $part->getRemoteFile(); - if ($remoteFile !== null) { - $files[] = $remoteFile; + $file = $part->getFile(); + if ($file !== null) { + $files[] = $file; break; } } diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php index 937c6dc7..e0551d24 100644 --- a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -34,8 +34,7 @@ protected function getExpectedValues(): array { return [ 'TEXT' => 'text', - 'INLINE_FILE' => 'inline_file', - 'REMOTE_FILE' => 'remote_file', + 'FILE' => 'file', 'FUNCTION_CALL' => 'function_call', 'FUNCTION_RESPONSE' => 'function_response', ]; @@ -50,11 +49,11 @@ public function testSpecificEnumMethods(): void { $text = MessagePartTypeEnum::text(); $this->assertTrue($text->isText()); - $this->assertFalse($text->isInlineFile()); + $this->assertFalse($text->isFile()); - $inlineFile = MessagePartTypeEnum::inlineFile(); - $this->assertTrue($inlineFile->isInlineFile()); - $this->assertFalse($inlineFile->isRemoteFile()); + $file = MessagePartTypeEnum::file(); + $this->assertTrue($file->isFile()); + $this->assertFalse($file->isText()); $functionCall = MessagePartTypeEnum::functionCall(); $this->assertTrue($functionCall->isFunctionCall()); From c19aa8daca1a70af95db80f37d57d2f11f70aba4 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 13:44:51 -0600 Subject: [PATCH 34/42] refactor: removes FileInterface --- src/Files/Contracts/FileInterface.php | 70 -------------------------- src/Files/DTO/File.php | 3 +- src/Results/DTO/GenerativeAiResult.php | 32 ++++++------ 3 files changed, 17 insertions(+), 88 deletions(-) delete mode 100644 src/Files/Contracts/FileInterface.php diff --git a/src/Files/Contracts/FileInterface.php b/src/Files/Contracts/FileInterface.php deleted file mode 100644 index fa6688eb..00000000 --- a/src/Files/Contracts/FileInterface.php +++ /dev/null @@ -1,70 +0,0 @@ -candidates[0]->getMessage(); foreach ($message->getParts() as $part) { @@ -175,10 +175,10 @@ public function toFile(): FileInterface * * @since n.e.x.t * - * @return FileInterface The image file. + * @return File The image file. * @throws \RuntimeException If no image content. */ - public function toImageFile(): FileInterface + public function toImageFile(): File { $file = $this->toFile(); @@ -196,10 +196,10 @@ public function toImageFile(): FileInterface * * @since n.e.x.t * - * @return FileInterface The audio file. + * @return File The audio file. * @throws \RuntimeException If no audio content. */ - public function toAudioFile(): FileInterface + public function toAudioFile(): File { $file = $this->toFile(); @@ -217,10 +217,10 @@ public function toAudioFile(): FileInterface * * @since n.e.x.t * - * @return FileInterface The video file. + * @return File The video file. * @throws \RuntimeException If no video content. */ - public function toVideoFile(): FileInterface + public function toVideoFile(): File { $file = $this->toFile(); @@ -273,7 +273,7 @@ public function toTexts(): array * * @since n.e.x.t * - * @return FileInterface[] Array of files. + * @return File[] Array of files. */ public function toFiles(): array { @@ -296,13 +296,13 @@ public function toFiles(): array * * @since n.e.x.t * - * @return FileInterface[] Array of image files. + * @return File[] Array of image files. */ public function toImageFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->isImage() + fn(File $file) => $file->isImage() ); } @@ -311,13 +311,13 @@ public function toImageFiles(): array * * @since n.e.x.t * - * @return FileInterface[] Array of audio files. + * @return File[] Array of audio files. */ public function toAudioFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->isAudio() + fn(File $file) => $file->isAudio() ); } @@ -326,13 +326,13 @@ public function toAudioFiles(): array * * @since n.e.x.t * - * @return FileInterface[] Array of video files. + * @return File[] Array of video files. */ public function toVideoFiles(): array { return array_filter( $this->toFiles(), - fn(FileInterface $file) => $file->isVideo() + fn(File $file) => $file->isVideo() ); } From e93e2ee9215de038c2224f34d598f6b713f92497 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 14:51:47 -0600 Subject: [PATCH 35/42] refactor: returns null for non-type getters --- src/Files/DTO/File.php | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 4007435c..e8fa0140 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -158,13 +158,12 @@ public function getFileType(): FileTypeEnum * * @since n.e.x.t * - * @return string The URL. - * @throws \RuntimeException If the file is not remote. + * @return string|null The URL, or null if not a remote file. */ - public function getUrl(): string + public function getUrl(): ?string { if (!$this->fileType->isRemote()) { - throw new \RuntimeException('Cannot get URL for non-remote file.'); + return null; } return $this->data; @@ -175,30 +174,28 @@ public function getUrl(): string * * @since n.e.x.t * - * @return string The plain base64-encoded data (without data URI prefix). - * @throws \RuntimeException If the file is not inline. + * @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file. */ - public function getBase64Data(): string + public function getBase64Data(): ?string { if (!$this->fileType->isInline()) { - throw new \RuntimeException('Cannot get base64 data for non-inline file.'); + return null; } return $this->data; } /** - * Gets the data as a data URL for inline files. + * Gets the data as a data URI for inline files. * * @since n.e.x.t * - * @return string The data URL in format: data:[mimeType];base64,[data]. - * @throws \RuntimeException If the file is not inline. + * @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file. */ - public function getDataUrl(): string + public function getDataUri(): ?string { if (!$this->fileType->isInline()) { - throw new \RuntimeException('Cannot get data URL for non-inline file.'); + return null; } return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->data); From b7b7b6316d99c72ca4eb0ffddff9d3aee06ee011 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 14:56:15 -0600 Subject: [PATCH 36/42] refactor: splits up data and url parameters --- src/Files/DTO/File.php | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index e8fa0140..efdc222e 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -29,9 +29,14 @@ class File implements WithJsonSchemaInterface private FileTypeEnum $fileType; /** - * @var string The file data (URL for remote, base64 for inline). + * @var string|null The URL for remote files. */ - private string $data; + private ?string $url = null; + + /** + * @var string|null The base64 data for inline files. + */ + private ?string $data = null; /** * Constructor. @@ -62,7 +67,7 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): // Check if it's a URL if ($this->isUrl($file)) { $this->fileType = FileTypeEnum::remote(); - $this->data = $file; + $this->url = $file; $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); return; } @@ -162,11 +167,7 @@ public function getFileType(): FileTypeEnum */ public function getUrl(): ?string { - if (!$this->fileType->isRemote()) { - return null; - } - - return $this->data; + return $this->url; } /** @@ -178,10 +179,6 @@ public function getUrl(): ?string */ public function getBase64Data(): ?string { - if (!$this->fileType->isInline()) { - return null; - } - return $this->data; } @@ -194,7 +191,7 @@ public function getBase64Data(): ?string */ public function getDataUri(): ?string { - if (!$this->fileType->isInline()) { + if ($this->data === null) { return null; } From 314e9f44c5ec4dab31ab246fda7097210a4f82b6 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 14:57:13 -0600 Subject: [PATCH 37/42] refactor: renames data to base64Data --- src/Files/DTO/File.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index efdc222e..8c617137 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -36,7 +36,7 @@ class File implements WithJsonSchemaInterface /** * @var string|null The base64 data for inline files. */ - private ?string $data = null; + private ?string $base64Data = null; /** * Constructor. @@ -78,7 +78,7 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): if (preg_match($dataUriPattern, $file, $matches)) { $this->fileType = FileTypeEnum::inline(); - $this->data = $matches[2]; // Extract just the base64 data + $this->base64Data = $matches[2]; // Extract just the base64 data $extractedMimeType = empty($matches[1]) ? null : $matches[1]; $this->mimeType = $this->determineMimeType($providedMimeType, $extractedMimeType, null); return; @@ -92,7 +92,7 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): ); } $this->fileType = FileTypeEnum::inline(); - $this->data = $file; + $this->base64Data = $file; $this->mimeType = new MimeType($providedMimeType); return; } @@ -100,7 +100,7 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): // If none of the above, assume it's a local file path if (file_exists($file)) { $this->fileType = FileTypeEnum::inline(); - $this->data = $this->convertFileToBase64($file); + $this->base64Data = $this->convertFileToBase64($file); $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); return; } @@ -179,7 +179,7 @@ public function getUrl(): ?string */ public function getBase64Data(): ?string { - return $this->data; + return $this->base64Data; } /** @@ -191,11 +191,11 @@ public function getBase64Data(): ?string */ public function getDataUri(): ?string { - if ($this->data === null) { + if ($this->base64Data === null) { return null; } - return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->data); + return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data); } /** From e7a58d07ec1e61a314e637eb96c5307157b4a85a Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 15:01:30 -0600 Subject: [PATCH 38/42] feat: adds file type to json schema --- src/Files/DTO/File.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index 8c617137..b68df3d6 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -335,6 +335,11 @@ public static function getJsonSchema(): array 'oneOf' => [ [ 'properties' => [ + 'fileType' => [ + 'type' => 'string', + 'const' => FileTypeEnum::REMOTE, + 'description' => 'The file type.', + ], 'mimeType' => [ 'type' => 'string', 'description' => 'The MIME type of the file.', @@ -347,10 +352,15 @@ public static function getJsonSchema(): array 'description' => 'The URL to the remote file.', ], ], - 'required' => ['mimeType', 'url'], + 'required' => ['fileType', 'mimeType', 'url'], ], [ 'properties' => [ + 'fileType' => [ + 'type' => 'string', + 'const' => FileTypeEnum::INLINE, + 'description' => 'The file type.', + ], 'mimeType' => [ 'type' => 'string', 'description' => 'The MIME type of the file.', @@ -362,7 +372,7 @@ public static function getJsonSchema(): array 'description' => 'The base64-encoded file data.', ], ], - 'required' => ['mimeType', 'base64Data'], + 'required' => ['fileType', 'mimeType', 'base64Data'], ], ], ]; From f87fcded44476cd460f2c7fd682033ac46035439 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 15:04:40 -0600 Subject: [PATCH 39/42] refactor: moves document types to static property --- src/Files/ValueObjects/MimeType.php | 31 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index 1c947c98..d1fbf2ab 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -92,6 +92,23 @@ final class MimeType 'exe' => 'application/x-msdownload', ]; + /** + * Document MIME types. + * + * @var array + */ + private static array $documentTypes = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + ]; + /** * Constructor. * @@ -207,19 +224,7 @@ public function isText(): bool */ public function isDocument(): bool { - $documentTypes = [ - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.oasis.opendocument.text', - 'application/vnd.oasis.opendocument.spreadsheet', - ]; - - return in_array($this->value, $documentTypes, true); + return in_array($this->value, self::$documentTypes, true); } /** From e4d5c3f6ceb55b3ae6865d2690da02acbc7cf115 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 15:07:25 -0600 Subject: [PATCH 40/42] refactore: updates phpstan to not treat doc types as certain --- phpstan.neon.dist | 1 + src/Files/ValueObjects/MimeType.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 776ccd86..b74b10f0 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,3 +2,4 @@ parameters: level: max paths: - src + treatPhpDocTypesAsCertain: false diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index d1fbf2ab..18edca09 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -232,7 +232,7 @@ public function isDocument(): bool * * @since n.e.x.t * - * @param mixed $other The other MIME type to compare. + * @param self|string $other The other MIME type to compare. * @return bool True if equal. */ public function equals($other): bool From 62b877694cb4a5c0b58825afa79c8ed0af8ed750 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 15:58:41 -0600 Subject: [PATCH 41/42] refactor: adjusts function requirements --- src/Tools/DTO/FunctionCall.php | 39 ++++++++++++++++++--------- src/Tools/DTO/FunctionDeclaration.php | 10 +++---- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/Tools/DTO/FunctionCall.php b/src/Tools/DTO/FunctionCall.php index d61ec094..bcbcfd3b 100644 --- a/src/Tools/DTO/FunctionCall.php +++ b/src/Tools/DTO/FunctionCall.php @@ -17,14 +17,14 @@ class FunctionCall implements WithJsonSchemaInterface { /** - * @var string Unique identifier for this function call. + * @var string|null Unique identifier for this function call. */ - private string $id; + private ?string $id; /** - * @var string The name of the function to call. + * @var string|null The name of the function to call. */ - private string $name; + private ?string $name; /** * @var array The arguments to pass to the function. @@ -36,12 +36,17 @@ class FunctionCall implements WithJsonSchemaInterface * * @since n.e.x.t * - * @param string $id Unique identifier for this function call. - * @param string $name The name of the function to call. + * @param string|null $id Unique identifier for this function call. + * @param string|null $name The name of the function to call. * @param array $args The arguments to pass to the function. + * @throws \InvalidArgumentException If neither id nor name is provided. */ - public function __construct(string $id, string $name, array $args) + public function __construct(?string $id = null, ?string $name = null, array $args = []) { + if ($id === null && $name === null) { + throw new \InvalidArgumentException('At least one of id or name must be provided.'); + } + $this->id = $id; $this->name = $name; $this->args = $args; @@ -52,9 +57,9 @@ public function __construct(string $id, string $name, array $args) * * @since n.e.x.t * - * @return string The unique identifier. + * @return string|null The unique identifier. */ - public function getId(): string + public function getId(): ?string { return $this->id; } @@ -64,9 +69,9 @@ public function getId(): string * * @since n.e.x.t * - * @return string The function name. + * @return string|null The function name. */ - public function getName(): string + public function getName(): ?string { return $this->name; } @@ -107,7 +112,17 @@ public static function getJsonSchema(): array 'additionalProperties' => true, ], ], - 'required' => ['id', 'name', 'args'], + 'oneOf' => [ + [ + 'required' => ['id'], + ], + [ + 'required' => ['name'], + ], + [ + 'required' => ['id', 'name'], + ], + ], ]; } } diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index b1755914..435ca021 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -27,7 +27,7 @@ class FunctionDeclaration implements WithJsonSchemaInterface private string $description; /** - * @var mixed The JSON schema for the function parameters. + * @var mixed|null The JSON schema for the function parameters. */ private $parameters; @@ -38,9 +38,9 @@ class FunctionDeclaration implements WithJsonSchemaInterface * * @param string $name The name of the function. * @param string $description A description of what the function does. - * @param mixed $parameters The JSON schema for the function parameters. + * @param mixed|null $parameters The JSON schema for the function parameters. */ - public function __construct(string $name, string $description, $parameters) + public function __construct(string $name, string $description, $parameters = null) { $this->name = $name; $this->description = $description; @@ -76,7 +76,7 @@ public function getDescription(): string * * @since n.e.x.t * - * @return mixed The parameters schema. + * @return mixed|null The parameters schema. */ public function getParameters() { @@ -106,7 +106,7 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', ], ], - 'required' => ['name', 'description', 'parameters'], + 'required' => ['name', 'description'], ]; } } From 82e6c8ffee3995fcc67cb8f7984b27351eabf334 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 25 Jul 2025 16:36:06 -0600 Subject: [PATCH 42/42] test: adds unit tests --- src/Files/DTO/File.php | 16 +- src/Results/DTO/GenerativeAiResult.php | 12 +- tests/unit/Files/DTO/FileTest.php | 270 ++++++++ tests/unit/Files/Enums/FileTypeEnumTest.php | 56 ++ .../unit/Files/ValueObjects/MimeTypeTest.php | 284 +++++++++ tests/unit/Messages/DTO/MessagePartTest.php | 233 +++++++ tests/unit/Messages/DTO/MessageTest.php | 230 +++++++ tests/unit/Messages/DTO/ModelMessageTest.php | 117 ++++ tests/unit/Messages/DTO/SystemMessageTest.php | 166 +++++ tests/unit/Messages/DTO/UserMessageTest.php | 226 +++++++ .../DTO/GenerativeAiOperationTest.php | 291 +++++++++ tests/unit/Results/DTO/CandidateTest.php | 332 ++++++++++ .../Results/DTO/GenerativeAiResultTest.php | 597 ++++++++++++++++++ tests/unit/Results/DTO/TokenUsageTest.php | 235 +++++++ tests/unit/Tools/DTO/FunctionCallTest.php | 164 +++++ .../Tools/DTO/FunctionDeclarationTest.php | 188 ++++++ tests/unit/Tools/DTO/FunctionResponseTest.php | 183 ++++++ tests/unit/Tools/DTO/ToolTest.php | 298 +++++++++ tests/unit/Tools/DTO/WebSearchTest.php | 294 +++++++++ 19 files changed, 4178 insertions(+), 14 deletions(-) create mode 100644 tests/unit/Files/DTO/FileTest.php create mode 100644 tests/unit/Files/Enums/FileTypeEnumTest.php create mode 100644 tests/unit/Files/ValueObjects/MimeTypeTest.php create mode 100644 tests/unit/Messages/DTO/MessagePartTest.php create mode 100644 tests/unit/Messages/DTO/MessageTest.php create mode 100644 tests/unit/Messages/DTO/ModelMessageTest.php create mode 100644 tests/unit/Messages/DTO/SystemMessageTest.php create mode 100644 tests/unit/Messages/DTO/UserMessageTest.php create mode 100644 tests/unit/Operations/DTO/GenerativeAiOperationTest.php create mode 100644 tests/unit/Results/DTO/CandidateTest.php create mode 100644 tests/unit/Results/DTO/GenerativeAiResultTest.php create mode 100644 tests/unit/Results/DTO/TokenUsageTest.php create mode 100644 tests/unit/Tools/DTO/FunctionCallTest.php create mode 100644 tests/unit/Tools/DTO/FunctionDeclarationTest.php create mode 100644 tests/unit/Tools/DTO/FunctionResponseTest.php create mode 100644 tests/unit/Tools/DTO/ToolTest.php create mode 100644 tests/unit/Tools/DTO/WebSearchTest.php diff --git a/src/Files/DTO/File.php b/src/Files/DTO/File.php index b68df3d6..cc84724f 100644 --- a/src/Files/DTO/File.php +++ b/src/Files/DTO/File.php @@ -84,6 +84,14 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): return; } + // Check if it's a local file path (before base64 check) + if (file_exists($file) && is_file($file)) { + $this->fileType = FileTypeEnum::inline(); + $this->base64Data = $this->convertFileToBase64($file); + $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); + return; + } + // Check if it's plain base64 if (preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $file)) { if ($providedMimeType === null) { @@ -97,14 +105,6 @@ private function detectAndProcessFile(string $file, ?string $providedMimeType): return; } - // If none of the above, assume it's a local file path - if (file_exists($file)) { - $this->fileType = FileTypeEnum::inline(); - $this->base64Data = $this->convertFileToBase64($file); - $this->mimeType = $this->determineMimeType($providedMimeType, null, $file); - return; - } - throw new \InvalidArgumentException( 'Invalid file provided. Expected URL, base64 data, or valid local file path.' ); diff --git a/src/Results/DTO/GenerativeAiResult.php b/src/Results/DTO/GenerativeAiResult.php index 4c033485..b9a19d5b 100644 --- a/src/Results/DTO/GenerativeAiResult.php +++ b/src/Results/DTO/GenerativeAiResult.php @@ -300,10 +300,10 @@ public function toFiles(): array */ public function toImageFiles(): array { - return array_filter( + return array_values(array_filter( $this->toFiles(), fn(File $file) => $file->isImage() - ); + )); } /** @@ -315,10 +315,10 @@ public function toImageFiles(): array */ public function toAudioFiles(): array { - return array_filter( + return array_values(array_filter( $this->toFiles(), fn(File $file) => $file->isAudio() - ); + )); } /** @@ -330,10 +330,10 @@ public function toAudioFiles(): array */ public function toVideoFiles(): array { - return array_filter( + return array_values(array_filter( $this->toFiles(), fn(File $file) => $file->isVideo() - ); + )); } /** diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php new file mode 100644 index 00000000..15ee2ae4 --- /dev/null +++ b/tests/unit/Files/DTO/FileTest.php @@ -0,0 +1,270 @@ +assertEquals(FileTypeEnum::remote(), $file->getFileType()); + $this->assertEquals($url, $file->getUrl()); + $this->assertNull($file->getBase64Data()); + $this->assertNull($file->getDataUri()); + $this->assertEquals($mimeType, $file->getMimeType()); + $this->assertTrue($file->isImage()); + } + + /** + * Tests creating a File from a URL with inferred MIME type. + * + * @return void + */ + public function testCreateFromUrlWithInferredMimeType(): void + { + $url = 'https://example.com/document.pdf'; + + $file = new File($url); + + $this->assertEquals(FileTypeEnum::remote(), $file->getFileType()); + $this->assertEquals($url, $file->getUrl()); + $this->assertEquals('application/pdf', $file->getMimeType()); + $this->assertFalse($file->isText()); + } + + /** + * Tests creating a File from a data URI. + * + * @return void + */ + public function testCreateFromDataUri(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:text/plain;base64,' . $base64Data; + + $file = new File($dataUri); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals($dataUri, $file->getDataUri()); + $this->assertEquals('text/plain', $file->getMimeType()); + $this->assertTrue($file->isText()); + } + + /** + * Tests creating a File from a data URI with provided MIME type override. + * + * @return void + */ + public function testCreateFromDataUriWithMimeTypeOverride(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:text/plain;base64,' . $base64Data; + $overrideMimeType = 'text/html'; + + $file = new File($dataUri, $overrideMimeType); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals($overrideMimeType, $file->getMimeType()); + $this->assertEquals('data:text/html;base64,' . $base64Data, $file->getDataUri()); + } + + /** + * Tests creating a File from plain base64 data. + * + * @return void + */ + public function testCreateFromPlainBase64(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $mimeType = 'text/plain'; + + $file = new File($base64Data, $mimeType); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals($base64Data, $file->getBase64Data()); + $this->assertEquals('data:text/plain;base64,' . $base64Data, $file->getDataUri()); + $this->assertEquals($mimeType, $file->getMimeType()); + } + + /** + * Tests that plain base64 without MIME type throws exception. + * + * @return void + */ + public function testPlainBase64WithoutMimeTypeThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('MIME type is required when providing plain base64 data without data URI format.'); + + new File('SGVsbG8gV29ybGQ='); + } + + /** + * Tests creating a File from a local file path. + * + * @return void + */ + public function testCreateFromLocalFile(): void + { + // Create a temporary file + $tempFile = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($tempFile, 'Hello World'); + + try { + $file = new File($tempFile, 'text/plain'); + + $this->assertEquals(FileTypeEnum::inline(), $file->getFileType()); + $this->assertNull($file->getUrl()); + $this->assertEquals(base64_encode('Hello World'), $file->getBase64Data()); + $this->assertEquals('text/plain', $file->getMimeType()); + } finally { + unlink($tempFile); + } + } + + /** + * Tests that invalid file format throws exception. + * + * @return void + */ + public function testInvalidFileFormatThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File('not-a-valid-file-or-url', 'text/plain'); + } + + /** + * Tests that non-existent local file throws exception. + * + * @return void + */ + public function testNonExistentLocalFileThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File('/path/to/non/existent/file.txt', 'text/plain'); + } + + /** + * Tests that passing a directory throws exception. + * + * @return void + */ + public function testDirectoryThrowsException(): void + { + // Create a directory instead of a file + $tempDir = sys_get_temp_dir() . '/test_dir_' . uniqid(); + mkdir($tempDir); + + try { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided. Expected URL, base64 data, or valid local file path.'); + + new File($tempDir, 'text/plain'); + } finally { + rmdir($tempDir); + } + } + + /** + * Tests MIME type methods. + * + * @return void + */ + public function testMimeTypeMethods(): void + { + $file = new File('https://example.com/video.mp4'); + + $this->assertEquals('video/mp4', $file->getMimeType()); + $this->assertInstanceOf(\WordPress\AiClient\Files\ValueObjects\MimeType::class, $file->getMimeTypeObject()); + $this->assertTrue($file->isVideo()); + $this->assertFalse($file->isImage()); + $this->assertFalse($file->isAudio()); + $this->assertFalse($file->isText()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = File::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // Check remote file schema + $remoteSchema = $schema['oneOf'][0]; + $this->assertArrayHasKey('properties', $remoteSchema); + $this->assertArrayHasKey('fileType', $remoteSchema['properties']); + $this->assertArrayHasKey('mimeType', $remoteSchema['properties']); + $this->assertArrayHasKey('url', $remoteSchema['properties']); + $this->assertEquals(['fileType', 'mimeType', 'url'], $remoteSchema['required']); + + // Check inline file schema + $inlineSchema = $schema['oneOf'][1]; + $this->assertArrayHasKey('properties', $inlineSchema); + $this->assertArrayHasKey('fileType', $inlineSchema['properties']); + $this->assertArrayHasKey('mimeType', $inlineSchema['properties']); + $this->assertArrayHasKey('base64Data', $inlineSchema['properties']); + $this->assertEquals(['fileType', 'mimeType', 'base64Data'], $inlineSchema['required']); + } + + /** + * Tests data URI without MIME type defaults correctly. + * + * @return void + */ + public function testDataUriWithoutMimeType(): void + { + $base64Data = 'SGVsbG8gV29ybGQ='; + $dataUri = 'data:;base64,' . $base64Data; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); + + new File($dataUri); + } + + /** + * Tests URL with unknown extension. + * + * @return void + */ + public function testUrlWithUnknownExtension(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to determine MIME type. Please provide it explicitly.'); + + new File('https://example.com/file.unknown'); + } +} \ No newline at end of file diff --git a/tests/unit/Files/Enums/FileTypeEnumTest.php b/tests/unit/Files/Enums/FileTypeEnumTest.php new file mode 100644 index 00000000..c80101d2 --- /dev/null +++ b/tests/unit/Files/Enums/FileTypeEnumTest.php @@ -0,0 +1,56 @@ + 'inline', + 'REMOTE' => 'remote', + ]; + } + + /** + * Tests the specific enum methods. + * + * @return void + */ + public function testSpecificEnumMethods(): void + { + $inline = FileTypeEnum::inline(); + $this->assertTrue($inline->isInline()); + $this->assertFalse($inline->isRemote()); + + $remote = FileTypeEnum::remote(); + $this->assertTrue($remote->isRemote()); + $this->assertFalse($remote->isInline()); + } +} \ No newline at end of file diff --git a/tests/unit/Files/ValueObjects/MimeTypeTest.php b/tests/unit/Files/ValueObjects/MimeTypeTest.php new file mode 100644 index 00000000..42ffc2c0 --- /dev/null +++ b/tests/unit/Files/ValueObjects/MimeTypeTest.php @@ -0,0 +1,284 @@ +assertEquals($expected, (string) $mimeType); + } + + /** + * Provides valid MIME types. + * + * @return array + */ + public function validMimeTypeProvider(): array + { + return [ + 'simple type' => ['text/plain', 'text/plain'], + 'with uppercase' => ['TEXT/HTML', 'text/html'], + 'complex type' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ]; + } + + /** + * Tests invalid MIME type throws exception. + * + * @dataProvider invalidMimeTypeProvider + * @param string $input + * @return void + */ + public function testInvalidMimeTypeThrowsException(string $input): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid MIME type: ' . $input); + + new MimeType($input); + } + + /** + * Provides invalid MIME types. + * + * @return array + */ + public function invalidMimeTypeProvider(): array + { + return [ + 'empty string' => [''], + 'no slash' => ['textplain'], + 'multiple slashes' => ['text/plain/extra'], + 'starts with slash' => ['/text/plain'], + 'ends with slash' => ['text/plain/'], + 'only type' => ['text/'], + 'only subtype' => ['/plain'], + 'invalid characters' => ['text/pl@in'], + ]; + } + + /** + * Tests creating MimeType from file extension. + * + * @dataProvider extensionProvider + * @param string $extension + * @param string $expectedMimeType + * @return void + */ + public function testFromExtension(string $extension, string $expectedMimeType): void + { + $mimeType = MimeType::fromExtension($extension); + $this->assertEquals($expectedMimeType, (string) $mimeType); + } + + /** + * Provides file extensions and expected MIME types. + * + * @return array + */ + public function extensionProvider(): array + { + return [ + // Text + ['txt', 'text/plain'], + ['html', 'text/html'], + ['css', 'text/css'], + ['js', 'application/javascript'], + ['json', 'application/json'], + ['xml', 'application/xml'], + ['csv', 'text/csv'], + + // Images + ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], + ['png', 'image/png'], + ['gif', 'image/gif'], + ['webp', 'image/webp'], + ['svg', 'image/svg+xml'], + ['ico', 'image/x-icon'], + + // Documents + ['pdf', 'application/pdf'], + ['doc', 'application/msword'], + ['docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['xls', 'application/vnd.ms-excel'], + ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + + // Audio + ['mp3', 'audio/mpeg'], + ['wav', 'audio/wav'], + ['ogg', 'audio/ogg'], + + // Video + ['mp4', 'video/mp4'], + ['avi', 'video/x-msvideo'], + ['webm', 'video/webm'], + + // Archives + ['zip', 'application/zip'], + ['tar', 'application/x-tar'], + ['gz', 'application/gzip'], + ]; + } + + /** + * Tests unknown extension throws exception. + * + * @return void + */ + public function testUnknownExtensionThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown file extension: xyz'); + + MimeType::fromExtension('xyz'); + } + + /** + * Tests isValid method. + * + * @return void + */ + public function testIsValid(): void + { + $this->assertTrue(MimeType::isValid('text/plain')); + $this->assertTrue(MimeType::isValid('application/json')); + $this->assertFalse(MimeType::isValid('invalid')); + $this->assertFalse(MimeType::isValid('')); + } + + /** + * Tests isImage method. + * + * @return void + */ + public function testIsImage(): void + { + $this->assertTrue((new MimeType('image/jpeg'))->isImage()); + $this->assertTrue((new MimeType('image/png'))->isImage()); + $this->assertTrue((new MimeType('image/gif'))->isImage()); + $this->assertFalse((new MimeType('text/plain'))->isImage()); + $this->assertFalse((new MimeType('video/mp4'))->isImage()); + } + + /** + * Tests isVideo method. + * + * @return void + */ + public function testIsVideo(): void + { + $this->assertTrue((new MimeType('video/mp4'))->isVideo()); + $this->assertTrue((new MimeType('video/webm'))->isVideo()); + $this->assertFalse((new MimeType('image/jpeg'))->isVideo()); + $this->assertFalse((new MimeType('audio/mp3'))->isVideo()); + } + + /** + * Tests isAudio method. + * + * @return void + */ + public function testIsAudio(): void + { + $this->assertTrue((new MimeType('audio/mpeg'))->isAudio()); + $this->assertTrue((new MimeType('audio/wav'))->isAudio()); + $this->assertFalse((new MimeType('video/mp4'))->isAudio()); + $this->assertFalse((new MimeType('text/plain'))->isAudio()); + } + + /** + * Tests isText method. + * + * @return void + */ + public function testIsText(): void + { + $this->assertTrue((new MimeType('text/plain'))->isText()); + $this->assertTrue((new MimeType('text/html'))->isText()); + $this->assertFalse((new MimeType('application/json'))->isText()); + $this->assertFalse((new MimeType('application/xml'))->isText()); + $this->assertFalse((new MimeType('image/jpeg'))->isText()); + $this->assertFalse((new MimeType('video/mp4'))->isText()); + } + + /** + * Tests isDocument method. + * + * @return void + */ + public function testIsDocument(): void + { + $this->assertTrue((new MimeType('application/pdf'))->isDocument()); + $this->assertTrue((new MimeType('application/msword'))->isDocument()); + $this->assertTrue((new MimeType('application/vnd.openxmlformats-officedocument.wordprocessingml.document'))->isDocument()); + $this->assertFalse((new MimeType('text/plain'))->isDocument()); + $this->assertFalse((new MimeType('image/jpeg'))->isDocument()); + } + + /** + * Tests equals method. + * + * @return void + */ + public function testEquals(): void + { + $mimeType1 = new MimeType('text/plain'); + $mimeType2 = new MimeType('text/plain'); + $mimeType3 = new MimeType('text/html'); + + // Test with MimeType objects + $this->assertTrue($mimeType1->equals($mimeType2)); + $this->assertFalse($mimeType1->equals($mimeType3)); + + // Test with strings + $this->assertTrue($mimeType1->equals('text/plain')); + $this->assertTrue($mimeType1->equals('TEXT/PLAIN')); + $this->assertFalse($mimeType1->equals('text/html')); + + // Test with invalid types + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid MIME type comparison: integer'); + $mimeType1->equals(123); + } + + /** + * Tests toString method. + * + * @return void + */ + public function testToString(): void + { + $mimeType = new MimeType('TEXT/HTML'); + $this->assertEquals('text/html', (string) $mimeType); + } + + /** + * Tests normalizing values. + * + * @return void + */ + public function testNormalizesValues(): void + { + $mimeType = new MimeType('IMAGE/JPEG'); + $this->assertEquals('image/jpeg', (string) $mimeType); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php new file mode 100644 index 00000000..0bdbccdf --- /dev/null +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -0,0 +1,233 @@ +assertEquals(MessagePartTypeEnum::text(), $part->getType()); + $this->assertEquals($text, $part->getText()); + $this->assertNull($part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with File content. + * + * @return void + */ + public function testCreateWithFileContent(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $part = new MessagePart($file); + + $this->assertEquals(MessagePartTypeEnum::file(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertSame($file, $part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with FunctionCall content. + * + * @return void + */ + public function testCreateWithFunctionCallContent(): void + { + $functionCall = new FunctionCall('func_123', 'testFunction', ['param' => 'value']); + $part = new MessagePart($functionCall); + + $this->assertEquals(MessagePartTypeEnum::functionCall(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertNull($part->getFile()); + $this->assertSame($functionCall, $part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with FunctionResponse content. + * + * @return void + */ + public function testCreateWithFunctionResponseContent(): void + { + $functionResponse = new FunctionResponse('func_123', 'testFunction', ['result' => 'success']); + $part = new MessagePart($functionResponse); + + $this->assertEquals(MessagePartTypeEnum::functionResponse(), $part->getType()); + $this->assertNull($part->getText()); + $this->assertNull($part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertSame($functionResponse, $part->getFunctionResponse()); + } + + /** + * Tests creating MessagePart with empty string. + * + * @return void + */ + public function testCreateWithEmptyString(): void + { + $part = new MessagePart(''); + + $this->assertEquals(MessagePartTypeEnum::text(), $part->getType()); + $this->assertEquals('', $part->getText()); + } + + /** + * Tests that unsupported content type throws exception. + * + * @dataProvider unsupportedContentProvider + * @param mixed $content + * @param string $expectedType + * @return void + */ + public function testUnsupportedContentThrowsException($content, string $expectedType): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'Unsupported content type %s. Expected string, File, FunctionCall, or FunctionResponse.', + $expectedType + )); + + new MessagePart($content); + } + + /** + * Provides unsupported content types. + * + * @return array + */ + public function unsupportedContentProvider(): array + { + return [ + 'integer' => [123, 'integer'], + 'float' => [3.14, 'double'], + 'boolean' => [true, 'boolean'], + 'array' => [['key' => 'value'], 'array'], + 'null' => [null, 'NULL'], + 'stdClass' => [new \stdClass(), 'stdClass'], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = MessagePart::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(4, $schema['oneOf']); // text, file, function_call, function_response + + // Check text variant + $textSchema = $schema['oneOf'][0]; + $this->assertEquals('object', $textSchema['type']); + $this->assertEquals(MessagePartTypeEnum::text()->value, $textSchema['properties']['type']['const']); + $this->assertArrayHasKey('text', $textSchema['properties']); + $this->assertEquals(['type', 'text'], $textSchema['required']); + + // Check file variant + $fileSchema = $schema['oneOf'][1]; + $this->assertEquals('object', $fileSchema['type']); + $this->assertEquals(MessagePartTypeEnum::file()->value, $fileSchema['properties']['type']['const']); + $this->assertArrayHasKey('file', $fileSchema['properties']); + $this->assertEquals(['type', 'file'], $fileSchema['required']); + + // Check function_call variant + $functionCallSchema = $schema['oneOf'][2]; + $this->assertEquals('object', $functionCallSchema['type']); + $this->assertEquals(MessagePartTypeEnum::functionCall()->value, $functionCallSchema['properties']['type']['const']); + $this->assertArrayHasKey('functionCall', $functionCallSchema['properties']); + $this->assertEquals(['type', 'functionCall'], $functionCallSchema['required']); + + // Check function_response variant + $functionResponseSchema = $schema['oneOf'][3]; + $this->assertEquals('object', $functionResponseSchema['type']); + $this->assertEquals(MessagePartTypeEnum::functionResponse()->value, $functionResponseSchema['properties']['type']['const']); + $this->assertArrayHasKey('functionResponse', $functionResponseSchema['properties']); + $this->assertEquals(['type', 'functionResponse'], $functionResponseSchema['required']); + } + + /** + * Tests with different file types. + * + * @return void + */ + public function testWithDifferentFileTypes(): void + { + // Remote file + $remoteFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $part1 = new MessagePart($remoteFile); + $this->assertEquals('https://example.com/doc.pdf', $part1->getFile()->getUrl()); + + // Inline file + $inlineFile = new File('SGVsbG8gV29ybGQ=', 'text/plain'); + $part2 = new MessagePart($inlineFile); + $this->assertEquals('SGVsbG8gV29ybGQ=', $part2->getFile()->getBase64Data()); + } + + /** + * Tests with complex function call. + * + * @return void + */ + public function testWithComplexFunctionCall(): void + { + $complexArgs = [ + 'query' => 'SELECT * FROM users WHERE active = ?', + 'params' => [true], + 'options' => [ + 'timeout' => 30, + 'retries' => 3, + 'cache' => false + ] + ]; + + $functionCall = new FunctionCall('db_123', 'executeQuery', $complexArgs); + $part = new MessagePart($functionCall); + + $retrievedCall = $part->getFunctionCall(); + $this->assertNotNull($retrievedCall); + $this->assertEquals($complexArgs, $retrievedCall->getArgs()); + } + + /** + * Tests with Unicode text. + * + * @return void + */ + public function testWithUnicodeText(): void + { + $unicodeText = '你好世界 🌍 مرحبا بالعالم'; + $part = new MessagePart($unicodeText); + + $this->assertEquals($unicodeText, $part->getText()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/MessageTest.php b/tests/unit/Messages/DTO/MessageTest.php new file mode 100644 index 00000000..763696a7 --- /dev/null +++ b/tests/unit/Messages/DTO/MessageTest.php @@ -0,0 +1,230 @@ +assertEquals($role, $message->getRole()); + $this->assertCount(1, $message->getParts()); + $this->assertSame($part, $message->getParts()[0]); + } + + /** + * Tests creating Message with multiple parts. + * + * @return void + */ + public function testCreateWithMultipleParts(): void + { + $role = MessageRoleEnum::model(); + $parts = [ + new MessagePart('Here is the information you requested:'), + new MessagePart(new File('https://example.com/data.json', 'application/json')), + new MessagePart('Let me know if you need anything else.'), + ]; + + $message = new Message($role, $parts); + + $this->assertEquals($role, $message->getRole()); + $this->assertCount(3, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests creating Message with empty parts array. + * + * @return void + */ + public function testCreateWithEmptyParts(): void + { + $role = MessageRoleEnum::system(); + $message = new Message($role, []); + + $this->assertEquals($role, $message->getRole()); + $this->assertCount(0, $message->getParts()); + $this->assertEquals([], $message->getParts()); + } + + /** + * Tests with different roles. + * + * @dataProvider roleProvider + * @param MessageRoleEnum $role + * @return void + */ + public function testWithDifferentRoles(MessageRoleEnum $role): void + { + $part = new MessagePart('Test message'); + $message = new Message($role, [$part]); + + $this->assertEquals($role, $message->getRole()); + } + + /** + * Provides different message roles. + * + * @return array + */ + public function roleProvider(): array + { + return [ + 'system' => [MessageRoleEnum::system()], + 'user' => [MessageRoleEnum::user()], + 'model' => [MessageRoleEnum::model()], + ]; + } + + /** + * Tests complex message with all part types. + * + * @return void + */ + public function testComplexMessageWithAllPartTypes(): void + { + $role = MessageRoleEnum::model(); + $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(new FunctionResponse('search_123', 'webSearch', ['results' => ['item1', 'item2']])), + new MessagePart('Based on my search, here are the latest PHP news:'), + new MessagePart(new File('data:text/plain;base64,SGVsbG8=', 'text/plain')), + ]; + + $message = new Message($role, $parts); + + $this->assertCount(5, $message->getParts()); + + // Verify each part type + $this->assertEquals('I\'ll help you with that. Let me search for the information.', $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()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = Message::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('role', $schema['properties']); + $this->assertArrayHasKey('parts', $schema['properties']); + + // Check role property + $roleSchema = $schema['properties']['role']; + $this->assertEquals('string', $roleSchema['type']); + $this->assertArrayHasKey('enum', $roleSchema); + $this->assertContains('system', $roleSchema['enum']); + $this->assertContains('user', $roleSchema['enum']); + $this->assertContains('model', $roleSchema['enum']); + + // Check parts property + $partsSchema = $schema['properties']['parts']; + $this->assertEquals('array', $partsSchema['type']); + $this->assertArrayHasKey('items', $partsSchema); + $this->assertIsArray($partsSchema['items']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['role', 'parts'], $schema['required']); + } + + /** + * Tests message with large number of parts. + * + * @return void + */ + public function testMessageWithManyParts(): void + { + $role = MessageRoleEnum::user(); + $parts = []; + + // Create 100 parts + for ($i = 0; $i < 100; $i++) { + $parts[] = new MessagePart("Part number $i"); + } + + $message = new Message($role, $parts); + + $this->assertCount(100, $message->getParts()); + $this->assertEquals('Part number 0', $message->getParts()[0]->getText()); + $this->assertEquals('Part number 99', $message->getParts()[99]->getText()); + } + + /** + * Tests preserving part order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First'), + new MessagePart('Second'), + new MessagePart('Third'), + new MessagePart('Fourth'), + ]; + + $message = new Message(MessageRoleEnum::user(), $parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First', $retrievedParts[0]->getText()); + $this->assertEquals('Second', $retrievedParts[1]->getText()); + $this->assertEquals('Third', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth', $retrievedParts[3]->getText()); + } + + /** + * Tests model message with function response. + * + * @return void + */ + public function testModelMessageWithFunctionResponse(): void + { + $role = MessageRoleEnum::model(); + $functionResponse = new FunctionResponse( + 'calc_123', + 'calculate', + ['result' => 42, 'formula' => '6 * 7'] + ); + $part = new MessagePart($functionResponse); + + $message = new Message($role, [$part]); + + $this->assertTrue($message->getRole()->isModel()); + $this->assertNotNull($message->getParts()[0]->getFunctionResponse()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/ModelMessageTest.php b/tests/unit/Messages/DTO/ModelMessageTest.php new file mode 100644 index 00000000..9536b0e7 --- /dev/null +++ b/tests/unit/Messages/DTO/ModelMessageTest.php @@ -0,0 +1,117 @@ +assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertTrue($message->getRole()->isModel()); + } + + /** + * Tests ModelMessage with multiple parts. + * + * @return void + */ + public function testWithMultipleParts(): void + { + $parts = [ + new MessagePart('Let me help you with that.'), + new MessagePart('Here are the steps:'), + new MessagePart('1. First step'), + new MessagePart('2. Second step'), + ]; + + $message = new ModelMessage($parts); + + $this->assertCount(4, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests ModelMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new ModelMessage([]); + + $this->assertEquals(MessageRoleEnum::model(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests ModelMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new ModelMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests ModelMessage with various content types. + * + * @return void + */ + public function testWithVariousContentTypes(): void + { + $file = new \WordPress\AiClient\Files\DTO\File('https://example.com/image.jpg', 'image/jpeg'); + $functionCall = new \WordPress\AiClient\Tools\DTO\FunctionCall('func_123', 'search', ['q' => 'test']); + $functionResponse = new \WordPress\AiClient\Tools\DTO\FunctionResponse('func_123', 'search', ['results' => []]); + + $parts = [ + new MessagePart('I found the following:'), + new MessagePart($file), + new MessagePart($functionCall), + new MessagePart($functionResponse), + ]; + + $message = new ModelMessage($parts); + + $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()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = ModelMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/SystemMessageTest.php b/tests/unit/Messages/DTO/SystemMessageTest.php new file mode 100644 index 00000000..5d80ef2b --- /dev/null +++ b/tests/unit/Messages/DTO/SystemMessageTest.php @@ -0,0 +1,166 @@ +assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertTrue($message->getRole()->isSystem()); + } + + /** + * Tests SystemMessage with multiple instruction parts. + * + * @return void + */ + public function testWithMultipleInstructionParts(): void + { + $parts = [ + new MessagePart('You are an expert in PHP programming.'), + new MessagePart('Always provide code examples when explaining concepts.'), + new MessagePart('Be concise and clear in your explanations.'), + new MessagePart('Follow PSR-12 coding standards.'), + ]; + + $message = new SystemMessage($parts); + + $this->assertCount(4, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests SystemMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new SystemMessage([]); + + $this->assertEquals(MessageRoleEnum::system(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests SystemMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new SystemMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests SystemMessage with complex instructions. + * + * @return void + */ + public function testWithComplexInstructions(): void + { + $parts = [ + new MessagePart('You are a specialized code review assistant with expertise in:'), + new MessagePart('- Security best practices'), + new MessagePart('- Performance optimization'), + new MessagePart('- Code maintainability'), + new MessagePart('When reviewing code, always check for:'), + new MessagePart('1. SQL injection vulnerabilities'), + new MessagePart('2. XSS vulnerabilities'), + new MessagePart('3. Performance bottlenecks'), + ]; + + $message = new SystemMessage($parts); + + $this->assertCount(8, $message->getParts()); + + // Verify each part + $this->assertEquals('You are a specialized code review assistant with expertise in:', $message->getParts()[0]->getText()); + $this->assertEquals('- Security best practices', $message->getParts()[1]->getText()); + $this->assertEquals('- Performance optimization', $message->getParts()[2]->getText()); + $this->assertEquals('- Code maintainability', $message->getParts()[3]->getText()); + $this->assertEquals('When reviewing code, always check for:', $message->getParts()[4]->getText()); + $this->assertEquals('1. SQL injection vulnerabilities', $message->getParts()[5]->getText()); + $this->assertEquals('2. XSS vulnerabilities', $message->getParts()[6]->getText()); + $this->assertEquals('3. Performance bottlenecks', $message->getParts()[7]->getText()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = SystemMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } + + /** + * Tests SystemMessage with single long instruction. + * + * @return void + */ + public function testWithSingleLongInstruction(): void + { + $longInstruction = 'You are an AI assistant specialized in helping developers understand ' . + 'and work with PHP code. Always provide clear explanations, use proper ' . + 'terminology, and ensure your code examples follow PSR-12 standards. ' . + 'When explaining complex concepts, break them down into simpler parts ' . + 'and provide practical examples. Be patient and thorough in your responses.'; + + $message = new SystemMessage([new MessagePart($longInstruction)]); + + $this->assertCount(1, $message->getParts()); + $this->assertEquals($longInstruction, $message->getParts()[0]->getText()); + } + + /** + * Tests that parts are preserved in order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First instruction'), + new MessagePart('Second instruction'), + new MessagePart('Third instruction'), + new MessagePart('Fourth instruction'), + ]; + + $message = new SystemMessage($parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First instruction', $retrievedParts[0]->getText()); + $this->assertEquals('Second instruction', $retrievedParts[1]->getText()); + $this->assertEquals('Third instruction', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth instruction', $retrievedParts[3]->getText()); + } +} \ No newline at end of file diff --git a/tests/unit/Messages/DTO/UserMessageTest.php b/tests/unit/Messages/DTO/UserMessageTest.php new file mode 100644 index 00000000..2249d58c --- /dev/null +++ b/tests/unit/Messages/DTO/UserMessageTest.php @@ -0,0 +1,226 @@ +assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests UserMessage with multiple parts. + * + * @return void + */ + public function testWithMultipleParts(): void + { + $parts = [ + new MessagePart('I have a question about this code:'), + new MessagePart('```php'), + new MessagePart('function calculateSum($a, $b) {'), + new MessagePart(' return $a + $b;'), + new MessagePart('}'), + new MessagePart('```'), + new MessagePart('How can I add type hints?'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(7, $message->getParts()); + $this->assertEquals($parts, $message->getParts()); + } + + /** + * Tests UserMessage with empty parts. + * + * @return void + */ + public function testWithEmptyParts(): void + { + $message = new UserMessage([]); + + $this->assertEquals(MessageRoleEnum::user(), $message->getRole()); + $this->assertCount(0, $message->getParts()); + } + + /** + * Tests UserMessage inherits from Message. + * + * @return void + */ + public function testInheritsFromMessage(): void + { + $message = new UserMessage([]); + + $this->assertInstanceOf(\WordPress\AiClient\Messages\DTO\Message::class, $message); + } + + /** + * Tests UserMessage with file attachment. + * + * @return void + */ + public function testWithFileAttachment(): void + { + $file = new File('https://example.com/document.pdf', 'application/pdf'); + + $parts = [ + new MessagePart('Can you analyze this document for me?'), + new MessagePart($file), + new MessagePart('I need a summary of the key points.'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(3, $message->getParts()); + $this->assertEquals('Can you analyze this document for me?', $message->getParts()[0]->getText()); + $this->assertSame($file, $message->getParts()[1]->getFile()); + $this->assertEquals('I need a summary of the key points.', $message->getParts()[2]->getText()); + } + + /** + * Tests UserMessage with image and text. + * + * @return void + */ + public function testWithImageAndText(): void + { + $imageFile = new File('data:image/png;base64,iVBORw0KGgoAAAANS', 'image/png'); + + $parts = [ + new MessagePart('What do you see in this image?'), + new MessagePart($imageFile), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(2, $message->getParts()); + $this->assertNotNull($message->getParts()[0]->getText()); + $this->assertNotNull($message->getParts()[1]->getFile()); + $this->assertEquals('image/png', $message->getParts()[1]->getFile()->getMimeType()); + } + + /** + * Tests JSON schema is inherited from parent. + * + * @return void + */ + public function testJsonSchemaInheritance(): void + { + $schema = UserMessage::getJsonSchema(); + $parentSchema = \WordPress\AiClient\Messages\DTO\Message::getJsonSchema(); + + $this->assertEquals($parentSchema, $schema); + } + + /** + * Tests UserMessage with single question. + * + * @return void + */ + public function testWithSingleQuestion(): void + { + $question = 'What is the difference between abstract classes and interfaces in PHP?'; + + $message = new UserMessage([new MessagePart($question)]); + + $this->assertCount(1, $message->getParts()); + $this->assertEquals($question, $message->getParts()[0]->getText()); + } + + /** + * Tests UserMessage with code example request. + * + * @return void + */ + public function testWithCodeExampleRequest(): void + { + $parts = [ + new MessagePart('Can you show me an example of the Singleton pattern in PHP?'), + new MessagePart('Please include:'), + new MessagePart('1. Private constructor'), + new MessagePart('2. Static instance method'), + new MessagePart('3. Clone prevention'), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(5, $message->getParts()); + $this->assertTrue($message->getRole()->isUser()); + } + + /** + * Tests that parts are preserved in order. + * + * @return void + */ + public function testPreservesPartOrder(): void + { + $parts = [ + new MessagePart('First part'), + new MessagePart('Second part'), + new MessagePart('Third part'), + new MessagePart('Fourth part'), + ]; + + $message = new UserMessage($parts); + $retrievedParts = $message->getParts(); + + $this->assertEquals('First part', $retrievedParts[0]->getText()); + $this->assertEquals('Second part', $retrievedParts[1]->getText()); + $this->assertEquals('Third part', $retrievedParts[2]->getText()); + $this->assertEquals('Fourth part', $retrievedParts[3]->getText()); + } + + /** + * Tests UserMessage with multiple files. + * + * @return void + */ + public function testWithMultipleFiles(): void + { + $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $file2 = new File('https://example.com/image2.png', 'image/png'); + $file3 = new File('data:application/pdf;base64,JVBERi0xLjMNCg==', 'application/pdf'); + + $parts = [ + new MessagePart('Please compare these images:'), + new MessagePart($file1), + new MessagePart($file2), + new MessagePart('And review this document:'), + new MessagePart($file3), + ]; + + $message = new UserMessage($parts); + + $this->assertCount(5, $message->getParts()); + $this->assertInstanceOf(File::class, $message->getParts()[1]->getFile()); + $this->assertInstanceOf(File::class, $message->getParts()[2]->getFile()); + $this->assertInstanceOf(File::class, $message->getParts()[4]->getFile()); + } +} \ No newline at end of file diff --git a/tests/unit/Operations/DTO/GenerativeAiOperationTest.php b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php new file mode 100644 index 00000000..be2d7970 --- /dev/null +++ b/tests/unit/Operations/DTO/GenerativeAiOperationTest.php @@ -0,0 +1,291 @@ +assertEquals('op_123', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in processing state. + * + * @return void + */ + public function testCreateInProcessingState(): void + { + $operation = new GenerativeAiOperation( + 'op_456', + OperationStateEnum::processing() + ); + + $this->assertEquals('op_456', $operation->getId()); + $this->assertTrue($operation->getState()->isProcessing()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in succeeded state with result. + * + * @return void + */ + public function testCreateInSucceededStateWithResult(): void + { + $modelMessage = new ModelMessage([ + new MessagePart('Generated content') + ]); + $candidate = new Candidate( + $modelMessage, + FinishReasonEnum::stop(), + 42 + ); + $tokenUsage = new TokenUsage(10, 42, 52); + $result = new GenerativeAiResult( + 'result_123', + [$candidate], + $tokenUsage, + ['provider' => 'test'] + ); + + $operation = new GenerativeAiOperation( + 'op_789', + OperationStateEnum::succeeded(), + $result + ); + + $this->assertEquals('op_789', $operation->getId()); + $this->assertTrue($operation->getState()->isSucceeded()); + $this->assertSame($result, $operation->getResult()); + } + + /** + * Tests creating operation in failed state. + * + * @return void + */ + public function testCreateInFailedState(): void + { + $operation = new GenerativeAiOperation( + 'op_failed', + OperationStateEnum::failed() + ); + + $this->assertEquals('op_failed', $operation->getId()); + $this->assertTrue($operation->getState()->isFailed()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating operation in canceled state. + * + * @return void + */ + public function testCreateInCanceledState(): void + { + $operation = new GenerativeAiOperation( + 'op_canceled', + OperationStateEnum::canceled() + ); + + $this->assertEquals('op_canceled', $operation->getId()); + $this->assertTrue($operation->getState()->isCanceled()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests operation implements OperationInterface. + * + * @return void + */ + public function testImplementsOperationInterface(): void + { + $operation = new GenerativeAiOperation( + 'op_test', + OperationStateEnum::starting() + ); + + $this->assertInstanceOf( + \WordPress\AiClient\Operations\Contracts\OperationInterface::class, + $operation + ); + } + + /** + * Tests operation with different ID formats. + * + * @dataProvider idProvider + * @param string $id + * @return void + */ + public function testWithDifferentIdFormats(string $id): void + { + $operation = new GenerativeAiOperation( + $id, + OperationStateEnum::processing() + ); + + $this->assertEquals($id, $operation->getId()); + } + + /** + * Provides different ID formats. + * + * @return array + */ + public function idProvider(): array + { + return [ + 'uuid' => ['550e8400-e29b-41d4-a716-446655440000'], + 'alphanumeric' => ['op_abc123xyz'], + 'numeric' => ['123456789'], + 'with_special_chars' => ['op-2024-01-15_15:30:45'], + 'short' => ['op1'], + 'long' => ['operation_very_long_identifier_with_many_parts_12345'], + ]; + } + + /** + * Tests operation state transitions. + * + * @return void + */ + public function testStateTransitions(): void + { + // Starting -> Processing + $operation1 = new GenerativeAiOperation( + 'op_transition_1', + OperationStateEnum::starting() + ); + $this->assertTrue($operation1->getState()->isStarting()); + + // Processing -> Succeeded with result + $modelMessage = new ModelMessage([ + new MessagePart('Result') + ]); + $tokenUsage = new TokenUsage(5, 10, 15); + $result = new GenerativeAiResult( + 'result_transition', + [new Candidate($modelMessage, FinishReasonEnum::stop(), 10)], + $tokenUsage + ); + $operation2 = new GenerativeAiOperation( + 'op_transition_2', + OperationStateEnum::succeeded(), + $result + ); + $this->assertTrue($operation2->getState()->isSucceeded()); + $this->assertNotNull($operation2->getResult()); + + // Processing -> Failed + $operation3 = new GenerativeAiOperation( + 'op_transition_3', + OperationStateEnum::failed() + ); + $this->assertTrue($operation3->getState()->isFailed()); + $this->assertNull($operation3->getResult()); + } + + /** + * Tests JSON schema for succeeded state. + * + * @return void + */ + public function testJsonSchemaForSucceededState(): void + { + $schema = GenerativeAiOperation::getJsonSchema(); + + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // First schema is for succeeded state with result + $succeededSchema = $schema['oneOf'][0]; + $this->assertEquals('object', $succeededSchema['type']); + $this->assertArrayHasKey('properties', $succeededSchema); + $this->assertArrayHasKey('id', $succeededSchema['properties']); + $this->assertArrayHasKey('state', $succeededSchema['properties']); + $this->assertArrayHasKey('result', $succeededSchema['properties']); + + // State should be const for succeeded + $this->assertEquals( + OperationStateEnum::succeeded()->value, + $succeededSchema['properties']['state']['const'] + ); + + // Required fields + $this->assertEquals(['id', 'state', 'result'], $succeededSchema['required']); + } + + /** + * Tests JSON schema for non-succeeded states. + * + * @return void + */ + public function testJsonSchemaForNonSucceededStates(): void + { + $schema = GenerativeAiOperation::getJsonSchema(); + + // Second schema is for all other states without result + $otherStatesSchema = $schema['oneOf'][1]; + $this->assertEquals('object', $otherStatesSchema['type']); + $this->assertArrayHasKey('properties', $otherStatesSchema); + $this->assertArrayHasKey('id', $otherStatesSchema['properties']); + $this->assertArrayHasKey('state', $otherStatesSchema['properties']); + $this->assertArrayNotHasKey('result', $otherStatesSchema['properties']); + + // State should be enum for other states + $stateEnum = $otherStatesSchema['properties']['state']['enum']; + $this->assertContains(OperationStateEnum::starting()->value, $stateEnum); + $this->assertContains(OperationStateEnum::processing()->value, $stateEnum); + $this->assertContains(OperationStateEnum::failed()->value, $stateEnum); + $this->assertContains(OperationStateEnum::canceled()->value, $stateEnum); + + // Required fields + $this->assertEquals(['id', 'state'], $otherStatesSchema['required']); + } + + /** + * Tests operation with empty string ID. + * + * @return void + */ + public function testWithEmptyStringId(): void + { + $operation = new GenerativeAiOperation( + '', + OperationStateEnum::starting() + ); + + $this->assertEquals('', $operation->getId()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/CandidateTest.php b/tests/unit/Results/DTO/CandidateTest.php new file mode 100644 index 00000000..7a1fcd75 --- /dev/null +++ b/tests/unit/Results/DTO/CandidateTest.php @@ -0,0 +1,332 @@ +assertSame($message, $candidate->getMessage()); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + $this->assertEquals(25, $candidate->getTokenCount()); + } + + /** + * Tests candidate with different finish reasons. + * + * @dataProvider finishReasonProvider + * @param FinishReasonEnum $finishReason + * @return void + */ + public function testWithDifferentFinishReasons(FinishReasonEnum $finishReason): void + { + $message = new ModelMessage([new MessagePart('Response')]); + + $candidate = new Candidate($message, $finishReason, 10); + + $this->assertEquals($finishReason, $candidate->getFinishReason()); + } + + /** + * Provides different finish reasons. + * + * @return array + */ + public function finishReasonProvider(): array + { + return [ + 'stop' => [FinishReasonEnum::stop()], + 'length' => [FinishReasonEnum::length()], + 'content_filter' => [FinishReasonEnum::contentFilter()], + 'tool_calls' => [FinishReasonEnum::toolCalls()], + 'error' => [FinishReasonEnum::error()], + ]; + } + + /** + * Tests candidate with complex message. + * + * @return void + */ + public function testWithComplexMessage(): void + { + $functionCall = new FunctionCall( + 'func_123', + 'searchWeb', + ['query' => 'PHP best practices'] + ); + + $message = new ModelMessage([ + new MessagePart('Let me search for that information.'), + new MessagePart($functionCall), + new MessagePart('Based on my search, here are the PHP best practices:'), + new MessagePart('1. Follow PSR standards'), + new MessagePart('2. Use type declarations'), + new MessagePart('3. Write unit tests'), + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::toolCalls(), + 150 + ); + + $this->assertCount(6, $candidate->getMessage()->getParts()); + $this->assertTrue($candidate->getFinishReason()->isToolCalls()); + $this->assertEquals(150, $candidate->getTokenCount()); + } + + /** + * Tests candidate with message containing files. + * + * @return void + */ + public function testWithMessageContainingFiles(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg==', 'image/png'); + + $message = new ModelMessage([ + new MessagePart('I\'ve generated the requested image:'), + new MessagePart($file), + new MessagePart('The image shows a flowchart of the process.'), + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + 85 + ); + + $parts = $candidate->getMessage()->getParts(); + $this->assertEquals('I\'ve generated the requested image:', $parts[0]->getText()); + $this->assertSame($file, $parts[1]->getFile()); + $this->assertEquals('The image shows a flowchart of the process.', $parts[2]->getText()); + } + + /** + * Tests candidate with different token counts. + * + * @dataProvider tokenCountProvider + * @param int $tokenCount + * @return void + */ + public function testWithDifferentTokenCounts(int $tokenCount): void + { + $message = new ModelMessage([new MessagePart('Response')]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + $tokenCount + ); + + $this->assertEquals($tokenCount, $candidate->getTokenCount()); + } + + /** + * Provides different token counts. + * + * @return array + */ + public function tokenCountProvider(): array + { + return [ + 'zero' => [0], + 'small' => [10], + 'medium' => [500], + 'large' => [4000], + 'very_large' => [100000], + ]; + } + + /** + * Tests candidate rejects non-model message. + * + * @return void + */ + public function testRejectsNonModelMessage(): void + { + $userMessage = new UserMessage([ + new MessagePart('This is a user message.') + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Message must be a model message.'); + + new Candidate( + $userMessage, + FinishReasonEnum::stop(), + 10 + ); + } + + /** + * Tests candidate with message using different role. + * + * @return void + */ + public function testRejectsMessageWithDifferentRole(): void + { + $message = new Message( + MessageRoleEnum::user(), + [new MessagePart('User message')] + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Message must be a model message.'); + + new Candidate( + $message, + FinishReasonEnum::stop(), + 10 + ); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = Candidate::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('message', $schema['properties']); + $this->assertArrayHasKey('finishReason', $schema['properties']); + $this->assertArrayHasKey('tokenCount', $schema['properties']); + + // Check finishReason property + $finishReasonSchema = $schema['properties']['finishReason']; + $this->assertEquals('string', $finishReasonSchema['type']); + $this->assertArrayHasKey('enum', $finishReasonSchema); + $this->assertContains('stop', $finishReasonSchema['enum']); + $this->assertContains('length', $finishReasonSchema['enum']); + $this->assertContains('content_filter', $finishReasonSchema['enum']); + $this->assertContains('tool_calls', $finishReasonSchema['enum']); + $this->assertContains('error', $finishReasonSchema['enum']); + + // Check tokenCount property + $tokenCountSchema = $schema['properties']['tokenCount']; + $this->assertEquals('integer', $tokenCountSchema['type']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['message', 'finishReason', 'tokenCount'], $schema['required']); + } + + /** + * Tests candidate with empty message parts. + * + * @return void + */ + public function testWithEmptyMessageParts(): void + { + $message = new ModelMessage([]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::stop(), + 0 + ); + + $this->assertCount(0, $candidate->getMessage()->getParts()); + $this->assertEquals(0, $candidate->getTokenCount()); + } + + /** + * Tests candidate with max length finish reason. + * + * @return void + */ + public function testWithMaxLengthFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('This is a long response that was cut off due to reaching the maximum token limit...') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::length(), + 4096 + ); + + $this->assertTrue($candidate->getFinishReason()->isLength()); + $this->assertEquals(4096, $candidate->getTokenCount()); + } + + /** + * Tests candidate with content filter finish reason. + * + * @return void + */ + public function testWithContentFilterFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('I cannot provide that information.') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::contentFilter(), + 8 + ); + + $this->assertTrue($candidate->getFinishReason()->isContentFilter()); + } + + /** + * Tests candidate with error finish reason. + * + * @return void + */ + public function testWithErrorFinishReason(): void + { + $message = new ModelMessage([ + new MessagePart('An error occurred while generating the response.') + ]); + + $candidate = new Candidate( + $message, + FinishReasonEnum::error(), + 9 + ); + + $this->assertTrue($candidate->getFinishReason()->isError()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php new file mode 100644 index 00000000..61d67580 --- /dev/null +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -0,0 +1,597 @@ +assertEquals('result_123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertSame($candidate, $result->getCandidates()[0]); + $this->assertSame($tokenUsage, $result->getTokenUsage()); + $this->assertEquals([], $result->getProviderMetadata()); + } + + /** + * Tests creating result with multiple candidates. + * + * @return void + */ + public function testCreateWithMultipleCandidates(): void + { + $candidates = []; + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Response variant $i") + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), $i * 10); + } + $tokenUsage = new TokenUsage(20, 90, 110); + + $result = new GenerativeAiResult( + 'result_multi', + $candidates, + $tokenUsage + ); + + $this->assertCount(3, $result->getCandidates()); + $this->assertEquals(3, $result->getCandidateCount()); + $this->assertTrue($result->hasMultipleCandidates()); + } + + /** + * Tests creating result with provider metadata. + * + * @return void + */ + public function testCreateWithProviderMetadata(): void + { + $message = new ModelMessage([new MessagePart('Response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + $metadata = [ + 'model' => 'gpt-4', + 'temperature' => 0.7, + 'max_tokens' => 1000, + 'custom_data' => ['key' => 'value'] + ]; + + $result = new GenerativeAiResult( + 'result_meta', + [$candidate], + $tokenUsage, + $metadata + ); + + $this->assertEquals($metadata, $result->getProviderMetadata()); + } + + /** + * Tests result rejects empty candidates array. + * + * @return void + */ + public function testRejectsEmptyCandidatesArray(): void + { + $tokenUsage = new TokenUsage(0, 0, 0); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one candidate must be provided'); + + new GenerativeAiResult('result_empty', [], $tokenUsage); + } + + /** + * Tests toText method. + * + * @return void + */ + public function testToText(): void + { + $text = 'This is the extracted text content.'; + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 8); + $tokenUsage = new TokenUsage(10, 8, 18); + + $result = new GenerativeAiResult( + 'result_text', + [$candidate], + $tokenUsage + ); + + $this->assertEquals($text, $result->toText()); + } + + /** + * Tests toText throws exception when no text content. + * + * @return void + */ + public function testToTextThrowsExceptionWhenNoTextContent(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_text', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $result->toText(); + } + + /** + * Tests toFile method. + * + * @return void + */ + public function testToFile(): void + { + $file = new File('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg==', 'image/png'); + $message = new ModelMessage([ + new MessagePart('Here is the generated image:'), + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 20); + $tokenUsage = new TokenUsage(15, 20, 35); + + $result = new GenerativeAiResult( + 'result_file', + [$candidate], + $tokenUsage + ); + + $this->assertSame($file, $result->toFile()); + } + + /** + * Tests toFile throws exception when no file content. + * + * @return void + */ + public function testToFileThrowsExceptionWhenNoFileContent(): void + { + $message = new ModelMessage([ + new MessagePart('Just text, no file.') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_file', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No file content found in first candidate'); + + $result->toFile(); + } + + /** + * Tests toImageFile method. + * + * @return void + */ + public function testToImageFile(): void + { + $imageFile = new File('https://example.com/photo.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($imageFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_image', + [$candidate], + $tokenUsage + ); + + $this->assertSame($imageFile, $result->toImageFile()); + } + + /** + * Tests toImageFile throws exception for non-image file. + * + * @return void + */ + public function testToImageFileThrowsExceptionForNonImageFile(): void + { + $pdfFile = new File('https://example.com/document.pdf', 'application/pdf'); + $message = new ModelMessage([ + new MessagePart($pdfFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_pdf', + [$candidate], + $tokenUsage + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('File is not an image. MIME type: application/pdf'); + + $result->toImageFile(); + } + + /** + * Tests toAudioFile method. + * + * @return void + */ + public function testToAudioFile(): void + { + $audioFile = new File('https://example.com/song.mp3', 'audio/mpeg'); + $message = new ModelMessage([ + new MessagePart($audioFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_audio', + [$candidate], + $tokenUsage + ); + + $this->assertSame($audioFile, $result->toAudioFile()); + } + + /** + * Tests toVideoFile method. + * + * @return void + */ + public function testToVideoFile(): void + { + $videoFile = new File('https://example.com/video.mp4', 'video/mp4'); + $message = new ModelMessage([ + new MessagePart($videoFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_video', + [$candidate], + $tokenUsage + ); + + $this->assertSame($videoFile, $result->toVideoFile()); + } + + /** + * Tests toMessage method. + * + * @return void + */ + public function testToMessage(): void + { + $message = new ModelMessage([ + new MessagePart('Response message') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_msg', + [$candidate], + $tokenUsage + ); + + $this->assertSame($message, $result->toMessage()); + } + + /** + * Tests toTexts method with multiple candidates. + * + * @return void + */ + public function testToTextsWithMultipleCandidates(): void + { + $texts = ['First response', 'Second response', 'Third response']; + $candidates = []; + + foreach ($texts as $text) { + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(20, 15, 35); + $result = new GenerativeAiResult( + 'result_texts', + $candidates, + $tokenUsage + ); + + $this->assertEquals($texts, $result->toTexts()); + } + + /** + * Tests toFiles method with multiple candidates. + * + * @return void + */ + public function testToFilesWithMultipleCandidates(): void + { + $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $file2 = new File('https://example.com/image2.png', 'image/png'); + $file3 = new File('https://example.com/doc.pdf', 'application/pdf'); + + $candidates = []; + foreach ([$file1, $file2, $file3] as $file) { + $message = new ModelMessage([ + new MessagePart('Generated file:'), + new MessagePart($file) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_files', + $candidates, + $tokenUsage + ); + + $files = $result->toFiles(); + $this->assertCount(3, $files); + $this->assertSame($file1, $files[0]); + $this->assertSame($file2, $files[1]); + $this->assertSame($file3, $files[2]); + } + + /** + * Tests toImageFiles filters only image files. + * + * @return void + */ + public function testToImageFilesFiltersOnlyImages(): void + { + $imageFile1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $pdfFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $imageFile2 = new File('https://example.com/image2.png', 'image/png'); + + $candidates = []; + foreach ([$imageFile1, $pdfFile, $imageFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_mixed', + $candidates, + $tokenUsage + ); + + $images = $result->toImageFiles(); + $this->assertCount(2, $images); + $this->assertSame($imageFile1, $images[0]); + $this->assertSame($imageFile2, $images[1]); + } + + /** + * Tests toAudioFiles filters only audio files. + * + * @return void + */ + public function testToAudioFilesFiltersOnlyAudio(): void + { + $audioFile1 = new File('https://example.com/song.mp3', 'audio/mpeg'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $audioFile2 = new File('https://example.com/podcast.wav', 'audio/wav'); + + $candidates = []; + foreach ([$audioFile1, $imageFile, $audioFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_audio_mix', + $candidates, + $tokenUsage + ); + + $audioFiles = $result->toAudioFiles(); + $this->assertCount(2, $audioFiles); + $this->assertSame($audioFile1, $audioFiles[0]); + $this->assertSame($audioFile2, $audioFiles[1]); + } + + /** + * Tests toVideoFiles filters only video files. + * + * @return void + */ + public function testToVideoFilesFiltersOnlyVideo(): void + { + $videoFile1 = new File('https://example.com/movie.mp4', 'video/mp4'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $videoFile2 = new File('https://example.com/clip.webm', 'video/webm'); + + $candidates = []; + foreach ([$videoFile1, $imageFile, $videoFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_video_mix', + $candidates, + $tokenUsage + ); + + $videoFiles = $result->toVideoFiles(); + $this->assertCount(2, $videoFiles); + $this->assertSame($videoFile1, $videoFiles[0]); + $this->assertSame($videoFile2, $videoFiles[1]); + } + + /** + * Tests toMessages method. + * + * @return void + */ + public function testToMessages(): void + { + $messages = []; + $candidates = []; + + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Message $i") + ]); + $messages[] = $message; + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(15, 15, 30); + $result = new GenerativeAiResult( + 'result_messages', + $candidates, + $tokenUsage + ); + + $extractedMessages = $result->toMessages(); + $this->assertCount(3, $extractedMessages); + foreach ($messages as $index => $message) { + $this->assertSame($message, $extractedMessages[$index]); + } + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = GenerativeAiResult::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('candidates', $schema['properties']); + $this->assertArrayHasKey('tokenUsage', $schema['properties']); + $this->assertArrayHasKey('providerMetadata', $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + + // Check candidates property + $candidatesSchema = $schema['properties']['candidates']; + $this->assertEquals('array', $candidatesSchema['type']); + $this->assertEquals(1, $candidatesSchema['minItems']); + + // Check providerMetadata property + $metadataSchema = $schema['properties']['providerMetadata']; + $this->assertEquals('object', $metadataSchema['type']); + $this->assertTrue($metadataSchema['additionalProperties']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertContains('id', $schema['required']); + $this->assertContains('candidates', $schema['required']); + $this->assertContains('tokenUsage', $schema['required']); + $this->assertNotContains('providerMetadata', $schema['required']); + } + + /** + * Tests result implements ResultInterface. + * + * @return void + */ + public function testImplementsResultInterface(): void + { + $message = new ModelMessage([new MessagePart('Test')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $tokenUsage = new TokenUsage(1, 1, 2); + + $result = new GenerativeAiResult( + 'result_interface', + [$candidate], + $tokenUsage + ); + + $this->assertInstanceOf( + \WordPress\AiClient\Results\Contracts\ResultInterface::class, + $result + ); + } + + /** + * Tests hasMultipleCandidates returns false for single candidate. + * + * @return void + */ + public function testHasMultipleCandidatesReturnsFalseForSingle(): void + { + $message = new ModelMessage([new MessagePart('Single response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_single', + [$candidate], + $tokenUsage + ); + + $this->assertFalse($result->hasMultipleCandidates()); + $this->assertEquals(1, $result->getCandidateCount()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/TokenUsageTest.php b/tests/unit/Results/DTO/TokenUsageTest.php new file mode 100644 index 00000000..af299c1f --- /dev/null +++ b/tests/unit/Results/DTO/TokenUsageTest.php @@ -0,0 +1,235 @@ +assertEquals(100, $tokenUsage->getPromptTokens()); + $this->assertEquals(50, $tokenUsage->getCompletionTokens()); + $this->assertEquals(150, $tokenUsage->getTotalTokens()); + } + + /** + * Tests creating TokenUsage with zero values. + * + * @return void + */ + public function testCreateWithZeroValues(): void + { + $tokenUsage = new TokenUsage(0, 0, 0); + + $this->assertEquals(0, $tokenUsage->getPromptTokens()); + $this->assertEquals(0, $tokenUsage->getCompletionTokens()); + $this->assertEquals(0, $tokenUsage->getTotalTokens()); + } + + /** + * Tests creating TokenUsage with large values. + * + * @return void + */ + public function testCreateWithLargeValues(): void + { + $tokenUsage = new TokenUsage(1000000, 500000, 1500000); + + $this->assertEquals(1000000, $tokenUsage->getPromptTokens()); + $this->assertEquals(500000, $tokenUsage->getCompletionTokens()); + $this->assertEquals(1500000, $tokenUsage->getTotalTokens()); + } + + /** + * Tests different token usage scenarios. + * + * @dataProvider tokenUsageScenarioProvider + * @param int $promptTokens + * @param int $completionTokens + * @param int $totalTokens + * @return void + */ + public function testDifferentTokenUsageScenarios(int $promptTokens, int $completionTokens, int $totalTokens): void + { + $tokenUsage = new TokenUsage($promptTokens, $completionTokens, $totalTokens); + + $this->assertEquals($promptTokens, $tokenUsage->getPromptTokens()); + $this->assertEquals($completionTokens, $tokenUsage->getCompletionTokens()); + $this->assertEquals($totalTokens, $tokenUsage->getTotalTokens()); + } + + /** + * Provides different token usage scenarios. + * + * @return array + */ + public function tokenUsageScenarioProvider(): array + { + return [ + 'small_prompt_large_completion' => [10, 1000, 1010], + 'large_prompt_small_completion' => [1000, 10, 1010], + 'equal_prompt_and_completion' => [500, 500, 1000], + 'only_prompt_tokens' => [100, 0, 100], + 'only_completion_tokens' => [0, 100, 100], + 'typical_chat_response' => [250, 750, 1000], + 'code_generation' => [50, 2000, 2050], + 'summarization' => [5000, 150, 5150], + 'max_context_window' => [4096, 4096, 8192], + 'minimal_usage' => [1, 1, 2], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = TokenUsage::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('promptTokens', $schema['properties']); + $this->assertArrayHasKey('completionTokens', $schema['properties']); + $this->assertArrayHasKey('totalTokens', $schema['properties']); + + // Check each property type + $this->assertEquals('integer', $schema['properties']['promptTokens']['type']); + $this->assertEquals('integer', $schema['properties']['completionTokens']['type']); + $this->assertEquals('integer', $schema['properties']['totalTokens']['type']); + + // Check descriptions + $this->assertArrayHasKey('description', $schema['properties']['promptTokens']); + $this->assertArrayHasKey('description', $schema['properties']['completionTokens']); + $this->assertArrayHasKey('description', $schema['properties']['totalTokens']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['promptTokens', 'completionTokens', 'totalTokens'], $schema['required']); + } + + /** + * Tests TokenUsage with GPT-3.5 typical usage. + * + * @return void + */ + public function testGpt35TypicalUsage(): void + { + // Typical GPT-3.5 conversation + $tokenUsage = new TokenUsage(127, 89, 216); + + $this->assertEquals(127, $tokenUsage->getPromptTokens()); + $this->assertEquals(89, $tokenUsage->getCompletionTokens()); + $this->assertEquals(216, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage with GPT-4 typical usage. + * + * @return void + */ + public function testGpt4TypicalUsage(): void + { + // Typical GPT-4 conversation with more context + $tokenUsage = new TokenUsage(512, 256, 768); + + $this->assertEquals(512, $tokenUsage->getPromptTokens()); + $this->assertEquals(256, $tokenUsage->getCompletionTokens()); + $this->assertEquals(768, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage for embedding models. + * + * @return void + */ + public function testEmbeddingModelUsage(): void + { + // Embedding models only use prompt tokens + $tokenUsage = new TokenUsage(1536, 0, 1536); + + $this->assertEquals(1536, $tokenUsage->getPromptTokens()); + $this->assertEquals(0, $tokenUsage->getCompletionTokens()); + $this->assertEquals(1536, $tokenUsage->getTotalTokens()); + } + + /** + * Tests TokenUsage implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $tokenUsage = new TokenUsage(10, 20, 30); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $tokenUsage + ); + } + + /** + * Tests creating multiple TokenUsage instances. + * + * @return void + */ + public function testMultipleInstances(): void + { + $usage1 = new TokenUsage(100, 50, 150); + $usage2 = new TokenUsage(200, 100, 300); + $usage3 = new TokenUsage(100, 50, 150); + + // Different instances with different values + $this->assertNotSame($usage1, $usage2); + $this->assertNotEquals($usage1->getPromptTokens(), $usage2->getPromptTokens()); + + // Different instances with same values + $this->assertNotSame($usage1, $usage3); + $this->assertEquals($usage1->getPromptTokens(), $usage3->getPromptTokens()); + $this->assertEquals($usage1->getCompletionTokens(), $usage3->getCompletionTokens()); + $this->assertEquals($usage1->getTotalTokens(), $usage3->getTotalTokens()); + } + + /** + * Tests TokenUsage with streaming response simulation. + * + * @return void + */ + public function testStreamingResponseUsage(): void + { + // Simulating a streaming response where tokens accumulate + $initialUsage = new TokenUsage(50, 10, 60); + $midUsage = new TokenUsage(50, 50, 100); + $finalUsage = new TokenUsage(50, 150, 200); + + // Prompt tokens stay the same + $this->assertEquals($initialUsage->getPromptTokens(), $midUsage->getPromptTokens()); + $this->assertEquals($midUsage->getPromptTokens(), $finalUsage->getPromptTokens()); + + // Completion tokens increase + $this->assertLessThan($midUsage->getCompletionTokens(), $initialUsage->getCompletionTokens()); + $this->assertLessThan($finalUsage->getCompletionTokens(), $midUsage->getCompletionTokens()); + + // Total tokens increase accordingly + $this->assertLessThan($midUsage->getTotalTokens(), $initialUsage->getTotalTokens()); + $this->assertLessThan($finalUsage->getTotalTokens(), $midUsage->getTotalTokens()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionCallTest.php b/tests/unit/Tools/DTO/FunctionCallTest.php new file mode 100644 index 00000000..0a387515 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionCallTest.php @@ -0,0 +1,164 @@ + 'New York', 'units' => 'celsius']; + + $functionCall = new FunctionCall($id, $name, $args); + + $this->assertEquals($id, $functionCall->getId()); + $this->assertEquals($name, $functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall with only ID. + * + * @return void + */ + public function testCreateWithOnlyId(): void + { + $id = 'func_123'; + $args = ['param' => 'value']; + + $functionCall = new FunctionCall($id, null, $args); + + $this->assertEquals($id, $functionCall->getId()); + $this->assertNull($functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall with only name. + * + * @return void + */ + public function testCreateWithOnlyName(): void + { + $name = 'calculateTotal'; + $args = ['items' => [1, 2, 3]]; + + $functionCall = new FunctionCall(null, $name, $args); + + $this->assertNull($functionCall->getId()); + $this->assertEquals($name, $functionCall->getName()); + $this->assertEquals($args, $functionCall->getArgs()); + } + + /** + * Tests creating FunctionCall without args. + * + * @return void + */ + public function testCreateWithoutArgs(): void + { + $functionCall = new FunctionCall('func_123', 'getTime'); + + $this->assertEquals('func_123', $functionCall->getId()); + $this->assertEquals('getTime', $functionCall->getName()); + $this->assertEquals([], $functionCall->getArgs()); + } + + /** + * Tests that creating without ID or name throws exception. + * + * @return void + */ + public function testCreateWithoutIdOrNameThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of id or name must be provided.'); + + new FunctionCall(null, null, []); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionCall::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('args', $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + $this->assertArrayHasKey('description', $schema['properties']['id']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check args property + $this->assertEquals('object', $schema['properties']['args']['type']); + $this->assertTrue($schema['properties']['args']['additionalProperties']); + + // Check oneOf for required fields + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(3, $schema['oneOf']); + + // First option: only id required + $this->assertEquals(['id'], $schema['oneOf'][0]['required']); + + // Second option: only name required + $this->assertEquals(['name'], $schema['oneOf'][1]['required']); + + // Third option: both id and name required + $this->assertEquals(['id', 'name'], $schema['oneOf'][2]['required']); + } + + /** + * Tests with complex args. + * + * @return void + */ + public function testWithComplexArgs(): void + { + $args = [ + 'string' => 'value', + 'number' => 42, + 'float' => 3.14, + 'boolean' => true, + 'null' => null, + 'array' => [1, 2, 3], + 'object' => ['key' => 'value'], + 'nested' => [ + 'deep' => [ + 'value' => 'test' + ] + ] + ]; + + $functionCall = new FunctionCall('id', 'name', $args); + + $this->assertEquals($args, $functionCall->getArgs()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php new file mode 100644 index 00000000..b5a2f3d7 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -0,0 +1,188 @@ + 'object', + 'properties' => [ + 'a' => ['type' => 'number', 'description' => 'First number'], + 'b' => ['type' => 'number', 'description' => 'Second number'], + ], + 'required' => ['a', 'b'], + ]; + + $declaration = new FunctionDeclaration($name, $description, $parameters); + + $this->assertEquals($name, $declaration->getName()); + $this->assertEquals($description, $declaration->getDescription()); + $this->assertEquals($parameters, $declaration->getParameters()); + } + + /** + * Tests creating FunctionDeclaration without parameters. + * + * @return void + */ + public function testCreateWithoutParameters(): void + { + $name = 'getCurrentTime'; + $description = 'Gets the current system time'; + + $declaration = new FunctionDeclaration($name, $description); + + $this->assertEquals($name, $declaration->getName()); + $this->assertEquals($description, $declaration->getDescription()); + $this->assertNull($declaration->getParameters()); + } + + /** + * Tests with various parameter types. + * + * @dataProvider parameterTypesProvider + * @param mixed $parameters + * @return void + */ + public function testWithVariousParameterTypes($parameters): void + { + $declaration = new FunctionDeclaration('test', 'test function', $parameters); + + $this->assertSame($parameters, $declaration->getParameters()); + } + + /** + * Provides various parameter types. + * + * @return array + */ + public function parameterTypesProvider(): array + { + return [ + 'null' => [null], + 'string' => ['simple string parameter'], + 'number' => [42], + 'float' => [3.14], + 'boolean' => [true], + 'array' => [['key' => 'value']], + 'object' => [(object) ['property' => 'value']], + 'complex schema' => [[ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'value' => ['type' => 'string'] + ] + ] + ] + ]], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionDeclaration::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('description', $schema['properties']); + $this->assertArrayHasKey('parameters', $schema['properties']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check description property + $this->assertEquals('string', $schema['properties']['description']['type']); + $this->assertArrayHasKey('description', $schema['properties']['description']); + + // Check parameters property allows multiple types + $paramTypes = $schema['properties']['parameters']['type']; + $this->assertIsArray($paramTypes); + $this->assertContains('string', $paramTypes); + $this->assertContains('number', $paramTypes); + $this->assertContains('boolean', $paramTypes); + $this->assertContains('object', $paramTypes); + $this->assertContains('array', $paramTypes); + $this->assertContains('null', $paramTypes); + + // Check required fields - parameters should NOT be required + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['name', 'description'], $schema['required']); + $this->assertNotContains('parameters', $schema['required']); + } + + /** + * Tests empty string values. + * + * @return void + */ + public function testEmptyStringValues(): void + { + $declaration = new FunctionDeclaration('', ''); + + $this->assertEquals('', $declaration->getName()); + $this->assertEquals('', $declaration->getDescription()); + $this->assertNull($declaration->getParameters()); + } + + /** + * Tests with OpenAPI-style parameter schema. + * + * @return void + */ + public function testWithOpenApiStyleSchema(): void + { + $parameters = [ + 'type' => 'object', + 'properties' => [ + 'location' => [ + 'type' => 'string', + 'description' => 'The city and state, e.g. San Francisco, CA' + ], + 'unit' => [ + 'type' => 'string', + 'enum' => ['celsius', 'fahrenheit'], + 'default' => 'fahrenheit' + ] + ], + 'required' => ['location'], + 'additionalProperties' => false + ]; + + $declaration = new FunctionDeclaration( + 'get_weather', + 'Get the current weather in a given location', + $parameters + ); + + $this->assertEquals($parameters, $declaration->getParameters()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/FunctionResponseTest.php b/tests/unit/Tools/DTO/FunctionResponseTest.php new file mode 100644 index 00000000..20180ab9 --- /dev/null +++ b/tests/unit/Tools/DTO/FunctionResponseTest.php @@ -0,0 +1,183 @@ + 22, + 'condition' => 'sunny', + 'humidity' => 65, + ]; + + $functionResponse = new FunctionResponse($id, $name, $response); + + $this->assertEquals($id, $functionResponse->getId()); + $this->assertEquals($name, $functionResponse->getName()); + $this->assertEquals($response, $functionResponse->getResponse()); + } + + /** + * Tests with various response types. + * + * @dataProvider responseTypesProvider + * @param mixed $response + * @return void + */ + public function testWithVariousResponseTypes($response): void + { + $functionResponse = new FunctionResponse('id', 'name', $response); + + $this->assertSame($response, $functionResponse->getResponse()); + } + + /** + * Provides various response types. + * + * @return array + */ + public function responseTypesProvider(): array + { + return [ + 'null' => [null], + 'string' => ['success'], + 'number' => [42], + 'float' => [3.14159], + 'boolean true' => [true], + 'boolean false' => [false], + 'empty array' => [[]], + 'indexed array' => [[1, 2, 3]], + 'associative array' => [['key' => 'value', 'another' => 'test']], + 'nested array' => [[ + 'level1' => [ + 'level2' => [ + 'level3' => 'deep value' + ] + ] + ]], + 'object' => [(object) ['property' => 'value']], + 'mixed array' => [[ + 'string' => 'text', + 'number' => 123, + 'boolean' => true, + 'null' => null, + 'array' => [1, 2, 3] + ]], + ]; + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = FunctionResponse::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('name', $schema['properties']); + $this->assertArrayHasKey('response', $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties']['id']['type']); + $this->assertArrayHasKey('description', $schema['properties']['id']); + + // Check name property + $this->assertEquals('string', $schema['properties']['name']['type']); + $this->assertArrayHasKey('description', $schema['properties']['name']); + + // Check response property allows multiple types + $responseTypes = $schema['properties']['response']['type']; + $this->assertIsArray($responseTypes); + $this->assertContains('string', $responseTypes); + $this->assertContains('number', $responseTypes); + $this->assertContains('boolean', $responseTypes); + $this->assertContains('object', $responseTypes); + $this->assertContains('array', $responseTypes); + $this->assertContains('null', $responseTypes); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertEquals(['id', 'name', 'response'], $schema['required']); + } + + /** + * Tests with empty string values. + * + * @return void + */ + public function testWithEmptyStringValues(): void + { + $response = new FunctionResponse('', '', ''); + + $this->assertEquals('', $response->getId()); + $this->assertEquals('', $response->getName()); + $this->assertEquals('', $response->getResponse()); + } + + /** + * Tests with error response. + * + * @return void + */ + public function testWithErrorResponse(): void + { + $errorResponse = [ + 'error' => true, + 'message' => 'Function execution failed', + 'code' => 'EXEC_ERROR', + 'details' => [ + 'timestamp' => '2024-01-01T00:00:00Z', + 'trace' => 'stack trace here' + ] + ]; + + $response = new FunctionResponse('func_456', 'failingFunction', $errorResponse); + + $this->assertEquals('func_456', $response->getId()); + $this->assertEquals('failingFunction', $response->getName()); + $this->assertEquals($errorResponse, $response->getResponse()); + } + + /** + * Tests with large response data. + * + * @return void + */ + public function testWithLargeResponseData(): void + { + // Create a large array + $largeData = []; + for ($i = 0; $i < 1000; $i++) { + $largeData["key_$i"] = "value_$i"; + } + + $response = new FunctionResponse('id', 'name', $largeData); + + $this->assertEquals($largeData, $response->getResponse()); + $this->assertCount(1000, $response->getResponse()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/ToolTest.php b/tests/unit/Tools/DTO/ToolTest.php new file mode 100644 index 00000000..74058ade --- /dev/null +++ b/tests/unit/Tools/DTO/ToolTest.php @@ -0,0 +1,298 @@ + ['type' => 'string', 'description' => 'Search query']] + ); + $function2 = new FunctionDeclaration( + 'sendEmail', + 'Sends an email', + ['to' => ['type' => 'string'], 'subject' => ['type' => 'string']] + ); + + $tool = new Tool([$function1, $function2]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertTrue($tool->getType()->isFunctionDeclarations()); + $this->assertCount(2, $tool->getFunctionDeclarations()); + $this->assertSame([$function1, $function2], $tool->getFunctionDeclarations()); + $this->assertNull($tool->getWebSearch()); + } + + /** + * Tests creating tool with single function declaration. + * + * @return void + */ + public function testCreateWithSingleFunctionDeclaration(): void + { + $function = new FunctionDeclaration( + 'getCurrentWeather', + 'Gets the current weather for a location' + ); + + $tool = new Tool([$function]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertCount(1, $tool->getFunctionDeclarations()); + $this->assertSame($function, $tool->getFunctionDeclarations()[0]); + } + + /** + * Tests creating tool with empty function declarations array. + * + * @return void + */ + public function testCreateWithEmptyFunctionDeclarationsArray(): void + { + $tool = new Tool([]); + + $this->assertEquals(ToolTypeEnum::functionDeclarations(), $tool->getType()); + $this->assertCount(0, $tool->getFunctionDeclarations()); + $this->assertEquals([], $tool->getFunctionDeclarations()); + } + + /** + * Tests creating tool with web search. + * + * @return void + */ + public function testCreateWithWebSearch(): void + { + $webSearch = new WebSearch( + ['example.com', 'docs.example.com'], + ['spam.com', 'malware.com'] + ); + + $tool = new Tool($webSearch); + + $this->assertEquals(ToolTypeEnum::webSearch(), $tool->getType()); + $this->assertTrue($tool->getType()->isWebSearch()); + $this->assertSame($webSearch, $tool->getWebSearch()); + $this->assertNull($tool->getFunctionDeclarations()); + } + + /** + * Tests creating tool with invalid content throws exception. + * + * @return void + */ + public function testCreateWithInvalidContentThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + + new Tool('invalid content'); + } + + /** + * Tests creating tool with object that is not WebSearch throws exception. + * + * @return void + */ + public function testCreateWithInvalidObjectThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Tool content must be an array of FunctionDeclaration instances or a WebSearch instance' + ); + + new Tool(new \stdClass()); + } + + /** + * Tests JSON schema for function declarations tool. + * + * @return void + */ + public function testJsonSchemaForFunctionDeclarationsTool(): void + { + $schema = Tool::getJsonSchema(); + + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // First schema is for function declarations + $functionSchema = $schema['oneOf'][0]; + $this->assertEquals('object', $functionSchema['type']); + $this->assertArrayHasKey('properties', $functionSchema); + $this->assertArrayHasKey('type', $functionSchema['properties']); + $this->assertArrayHasKey('functionDeclarations', $functionSchema['properties']); + + // Type property + $typeProperty = $functionSchema['properties']['type']; + $this->assertEquals('string', $typeProperty['type']); + $this->assertEquals(ToolTypeEnum::functionDeclarations()->value, $typeProperty['const']); + + // Function declarations property + $functionsProperty = $functionSchema['properties']['functionDeclarations']; + $this->assertEquals('array', $functionsProperty['type']); + $this->assertArrayHasKey('items', $functionsProperty); + + // Required fields + $this->assertEquals(['type', 'functionDeclarations'], $functionSchema['required']); + } + + /** + * Tests JSON schema for web search tool. + * + * @return void + */ + public function testJsonSchemaForWebSearchTool(): void + { + $schema = Tool::getJsonSchema(); + + // Second schema is for web search + $webSearchSchema = $schema['oneOf'][1]; + $this->assertEquals('object', $webSearchSchema['type']); + $this->assertArrayHasKey('properties', $webSearchSchema); + $this->assertArrayHasKey('type', $webSearchSchema['properties']); + $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + + // Type property + $typeProperty = $webSearchSchema['properties']['type']; + $this->assertEquals('string', $typeProperty['type']); + $this->assertEquals(ToolTypeEnum::webSearch()->value, $typeProperty['const']); + + // Web search property + $this->assertArrayHasKey('webSearch', $webSearchSchema['properties']); + + // Required fields + $this->assertEquals(['type', 'webSearch'], $webSearchSchema['required']); + } + + /** + * Tests tool with multiple complex function declarations. + * + * @return void + */ + public function testWithMultipleComplexFunctionDeclarations(): void + { + $functions = [ + new FunctionDeclaration( + 'createUser', + 'Creates a new user in the system', + [ + 'username' => [ + 'type' => 'string', + 'description' => 'The username', + 'minLength' => 3, + 'maxLength' => 20 + ], + 'email' => [ + 'type' => 'string', + 'format' => 'email', + 'description' => 'The user email' + ], + 'role' => [ + 'type' => 'string', + 'enum' => ['admin', 'user', 'guest'], + 'description' => 'The user role' + ] + ] + ), + new FunctionDeclaration( + 'deleteUser', + 'Deletes a user from the system', + [ + 'userId' => [ + 'type' => 'integer', + 'description' => 'The user ID to delete' + ] + ] + ), + new FunctionDeclaration( + 'listUsers', + 'Lists all users with optional filtering', + [ + 'role' => [ + 'type' => 'string', + 'enum' => ['admin', 'user', 'guest'], + 'description' => 'Filter by role' + ], + 'limit' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + 'default' => 10 + ] + ] + ) + ]; + + $tool = new Tool($functions); + + $this->assertCount(3, $tool->getFunctionDeclarations()); + $this->assertEquals('createUser', $tool->getFunctionDeclarations()[0]->getName()); + $this->assertEquals('deleteUser', $tool->getFunctionDeclarations()[1]->getName()); + $this->assertEquals('listUsers', $tool->getFunctionDeclarations()[2]->getName()); + } + + /** + * Tests tool implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $tool = new Tool([]); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $tool + ); + } + + /** + * Tests creating multiple tool instances. + * + * @return void + */ + public function testMultipleToolInstances(): void + { + $function = new FunctionDeclaration('test', 'Test function'); + $webSearch = new WebSearch(['example.com'], ['spam.com']); + + $tool1 = new Tool([$function]); + $tool2 = new Tool($webSearch); + $tool3 = new Tool([$function]); + + // Different tool types + $this->assertNotEquals($tool1->getType(), $tool2->getType()); + + // Same content type but different instances + $this->assertNotSame($tool1, $tool3); + $this->assertEquals($tool1->getType(), $tool3->getType()); + + // Check content accessors + $this->assertNotNull($tool1->getFunctionDeclarations()); + $this->assertNull($tool1->getWebSearch()); + $this->assertNull($tool2->getFunctionDeclarations()); + $this->assertNotNull($tool2->getWebSearch()); + } +} \ No newline at end of file diff --git a/tests/unit/Tools/DTO/WebSearchTest.php b/tests/unit/Tools/DTO/WebSearchTest.php new file mode 100644 index 00000000..0a9aee97 --- /dev/null +++ b/tests/unit/Tools/DTO/WebSearchTest.php @@ -0,0 +1,294 @@ +assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with only allowed domains. + * + * @return void + */ + public function testCreateWithOnlyAllowedDomains(): void + { + $allowedDomains = ['example.com', 'test.org']; + + $webSearch = new WebSearch($allowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with only disallowed domains. + * + * @return void + */ + public function testCreateWithOnlyDisallowedDomains(): void + { + $disallowedDomains = ['bad.com', 'blocked.org']; + + $webSearch = new WebSearch([], $disallowedDomains); + + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests creating WebSearch with no domain restrictions. + * + * @return void + */ + public function testCreateWithNoDomainRestrictions(): void + { + $webSearch = new WebSearch(); + + $this->assertEquals([], $webSearch->getAllowedDomains()); + $this->assertEquals([], $webSearch->getDisallowedDomains()); + } + + /** + * Tests WebSearch with various domain formats. + * + * @return void + */ + public function testWithVariousDomainFormats(): void + { + $allowedDomains = [ + 'example.com', + 'subdomain.example.com', + 'deep.subdomain.example.com', + 'example.co.uk', + 'example.org', + 'localhost', + '192.168.1.1', + 'example-with-dash.com', + 'UPPERCASE.COM' + ]; + + $webSearch = new WebSearch($allowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + } + + /** + * Tests WebSearch with duplicate domains. + * + * @return void + */ + public function testWithDuplicateDomains(): void + { + $allowedDomains = ['example.com', 'test.org', 'example.com']; + $disallowedDomains = ['bad.com', 'bad.com', 'worse.com']; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + // Note: WebSearch doesn't deduplicate - that's up to the implementation + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = WebSearch::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('allowedDomains', $schema['properties']); + $this->assertArrayHasKey('disallowedDomains', $schema['properties']); + + // Check allowedDomains property + $allowedSchema = $schema['properties']['allowedDomains']; + $this->assertEquals('array', $allowedSchema['type']); + $this->assertArrayHasKey('items', $allowedSchema); + $this->assertEquals('string', $allowedSchema['items']['type']); + $this->assertArrayHasKey('description', $allowedSchema); + + // Check disallowedDomains property + $disallowedSchema = $schema['properties']['disallowedDomains']; + $this->assertEquals('array', $disallowedSchema['type']); + $this->assertArrayHasKey('items', $disallowedSchema); + $this->assertEquals('string', $disallowedSchema['items']['type']); + $this->assertArrayHasKey('description', $disallowedSchema); + + // Check required fields (should be empty array) + $this->assertArrayHasKey('required', $schema); + $this->assertEquals([], $schema['required']); + } + + /** + * Tests WebSearch with empty strings in arrays. + * + * @return void + */ + public function testWithEmptyStringsInArrays(): void + { + $allowedDomains = ['example.com', '', 'test.org']; + $disallowedDomains = ['', 'bad.com', '']; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertEquals($allowedDomains, $webSearch->getAllowedDomains()); + $this->assertEquals($disallowedDomains, $webSearch->getDisallowedDomains()); + } + + /** + * Tests WebSearch with single domain in each list. + * + * @return void + */ + public function testWithSingleDomainInEachList(): void + { + $webSearch = new WebSearch(['trusted.com'], ['untrusted.com']); + + $this->assertCount(1, $webSearch->getAllowedDomains()); + $this->assertCount(1, $webSearch->getDisallowedDomains()); + $this->assertEquals('trusted.com', $webSearch->getAllowedDomains()[0]); + $this->assertEquals('untrusted.com', $webSearch->getDisallowedDomains()[0]); + } + + /** + * Tests WebSearch with many domains. + * + * @return void + */ + public function testWithManyDomains(): void + { + $allowedDomains = []; + $disallowedDomains = []; + + // Create 100 allowed domains + for ($i = 0; $i < 100; $i++) { + $allowedDomains[] = "allowed-domain-$i.com"; + } + + // Create 50 disallowed domains + for ($i = 0; $i < 50; $i++) { + $disallowedDomains[] = "blocked-domain-$i.com"; + } + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertCount(100, $webSearch->getAllowedDomains()); + $this->assertCount(50, $webSearch->getDisallowedDomains()); + $this->assertEquals('allowed-domain-0.com', $webSearch->getAllowedDomains()[0]); + $this->assertEquals('allowed-domain-99.com', $webSearch->getAllowedDomains()[99]); + $this->assertEquals('blocked-domain-0.com', $webSearch->getDisallowedDomains()[0]); + $this->assertEquals('blocked-domain-49.com', $webSearch->getDisallowedDomains()[49]); + } + + /** + * Tests WebSearch implements WithJsonSchemaInterface. + * + * @return void + */ + public function testImplementsWithJsonSchemaInterface(): void + { + $webSearch = new WebSearch(); + + $this->assertInstanceOf( + \WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface::class, + $webSearch + ); + } + + /** + * Tests creating multiple WebSearch instances. + * + * @return void + */ + public function testMultipleInstances(): void + { + $webSearch1 = new WebSearch(['a.com'], ['b.com']); + $webSearch2 = new WebSearch(['c.com'], ['d.com']); + $webSearch3 = new WebSearch(['a.com'], ['b.com']); + + // Different instances + $this->assertNotSame($webSearch1, $webSearch2); + $this->assertNotSame($webSearch1, $webSearch3); + + // Different content + $this->assertNotEquals($webSearch1->getAllowedDomains(), $webSearch2->getAllowedDomains()); + $this->assertNotEquals($webSearch1->getDisallowedDomains(), $webSearch2->getDisallowedDomains()); + + // Same content but different instances + $this->assertEquals($webSearch1->getAllowedDomains(), $webSearch3->getAllowedDomains()); + $this->assertEquals($webSearch1->getDisallowedDomains(), $webSearch3->getDisallowedDomains()); + } + + /** + * Tests WebSearch with common domain patterns. + * + * @return void + */ + public function testWithCommonDomainPatterns(): void + { + $allowedDomains = [ + // News sites + 'cnn.com', + 'bbc.co.uk', + 'reuters.com', + + // Documentation sites + 'docs.microsoft.com', + 'developer.mozilla.org', + 'stackoverflow.com', + + // Academic sites + 'arxiv.org', + 'scholar.google.com', + 'pubmed.ncbi.nlm.nih.gov' + ]; + + $disallowedDomains = [ + // Social media + 'facebook.com', + 'twitter.com', + 'instagram.com', + + // Video platforms + 'youtube.com', + 'vimeo.com', + 'tiktok.com' + ]; + + $webSearch = new WebSearch($allowedDomains, $disallowedDomains); + + $this->assertCount(9, $webSearch->getAllowedDomains()); + $this->assertCount(6, $webSearch->getDisallowedDomains()); + $this->assertContains('stackoverflow.com', $webSearch->getAllowedDomains()); + $this->assertContains('youtube.com', $webSearch->getDisallowedDomains()); + } +} \ No newline at end of file