first commit

This commit is contained in:
Roman Pyrih
2026-05-21 15:33:11 +02:00
commit acb036dbd9
8059 changed files with 2885104 additions and 0 deletions

BIN
wp-includes/php-ai-client/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,45 @@
<?php
/**
* Autoloader for the bundled PHP AI Client library.
*
* This file is generated by tools/php-ai-client/installer.sh.
* Do not edit directly.
*
* @package WordPress
* @subpackage AI
* @since 7.0.0
*/
spl_autoload_register(
static function ( $class_name ) {
// Namespace prefix for the AI client.
$client_prefix = 'WordPress\\AiClient\\';
$client_prefix_len = 19; // strlen( 'WordPress\\AiClient\\' )
// Namespace prefix for scoped dependencies (includes Psr\*, Http\*, etc.).
$scoped_prefix = 'WordPress\\AiClientDependencies\\';
$scoped_prefix_len = 31; // strlen( 'WordPress\\AiClientDependencies\\' )
$base_dir = __DIR__;
// 1. WordPress\AiClient\* → src/
if ( 0 === strncmp( $class_name, $client_prefix, $client_prefix_len ) ) {
$relative_class = substr( $class_name, $client_prefix_len );
$file = $base_dir . '/src/' . str_replace( '\\', '/', $relative_class ) . '.php';
if ( file_exists( $file ) ) {
require $file;
}
return;
}
// 2. WordPress\AiClientDependencies\* → third-party/ (strip prefix).
if ( 0 === strncmp( $class_name, $scoped_prefix, $scoped_prefix_len ) ) {
$relative_class = substr( $class_name, $scoped_prefix_len );
$file = $base_dir . '/third-party/' . str_replace( '\\', '/', $relative_class ) . '.php';
if ( file_exists( $file ) ) {
require $file;
}
return;
}
}
);

View File

@@ -0,0 +1,387 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient;
use WordPress\AiClientDependencies\Psr\EventDispatcher\EventDispatcherInterface;
use WordPress\AiClientDependencies\Psr\SimpleCache\CacheInterface;
use WordPress\AiClient\Builders\PromptBuilder;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\ProviderRegistry;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Main AI Client class providing both fluent and traditional APIs for AI operations.
*
* This class serves as the primary entry point for AI operations, offering:
* - Fluent API for easy-to-read chained method calls
* - Traditional API for array-based configuration (WordPress style)
* - Integration with provider registry for model discovery
* - Support for three model specification approaches
*
* All model requirements analysis and capability matching is handled
* automatically by the PromptBuilder, which provides intelligent model
* discovery based on prompt content and configuration.
*
* ## Model Specification Approaches
*
* ### 1. Specific Model Instance
* Use a specific ModelInterface instance when you know exactly which model to use:
* ```php
* $model = $registry->getProvider('openai')->getModel('gpt-4');
* $result = AiClient::generateTextResult('What is PHP?', $model);
* ```
*
* ### 2. ModelConfig for Auto-Discovery
* Use ModelConfig to specify requirements and let the system discover the best model:
* ```php
* $config = new ModelConfig();
* $config->setTemperature(0.7);
* $config->setMaxTokens(150);
*
* $result = AiClient::generateTextResult('What is PHP?', $config);
* ```
*
* ### 3. Automatic Discovery (Default)
* Pass null or omit the parameter for intelligent model discovery based on prompt content:
* ```php
* // System analyzes prompt and selects appropriate model automatically
* $result = AiClient::generateTextResult('What is PHP?');
* $imageResult = AiClient::generateImageResult('A sunset over mountains');
* ```
*
* ## Fluent API Examples
* ```php
* // Fluent API with automatic model discovery
* $result = AiClient::prompt('Generate an image of a sunset')
* ->usingTemperature(0.7)
* ->generateImageResult();
*
* // Fluent API with specific model
* $result = AiClient::prompt('What is PHP?')
* ->usingModel($specificModel)
* ->usingTemperature(0.5)
* ->generateTextResult();
*
* // Fluent API with model configuration
* $result = AiClient::prompt('Explain quantum physics')
* ->usingModelConfig($config)
* ->generateTextResult();
* ```
*
* @since 0.1.0
*
* @phpstan-import-type Prompt from PromptBuilder
*
* phpcs:ignore Generic.Files.LineLength.TooLong
*/
class AiClient
{
/**
* @var string The version of the AI Client.
*/
public const VERSION = '1.3.1';
/**
* @var ProviderRegistry|null The default provider registry instance.
*/
private static ?ProviderRegistry $defaultRegistry = null;
/**
* @var EventDispatcherInterface|null The event dispatcher for prompt lifecycle events.
*/
private static ?EventDispatcherInterface $eventDispatcher = null;
/**
* @var CacheInterface|null The PSR-16 cache for storing and retrieving cached data.
*/
private static ?CacheInterface $cache = null;
/**
* Gets the default provider registry instance.
*
* @since 0.1.0
*
* @return ProviderRegistry The default provider registry.
*/
public static function defaultRegistry(): ProviderRegistry
{
if (self::$defaultRegistry === null) {
self::$defaultRegistry = new ProviderRegistry();
}
return self::$defaultRegistry;
}
/**
* Sets the event dispatcher for prompt lifecycle events.
*
* The event dispatcher will be used to dispatch BeforeGenerateResultEvent and
* AfterGenerateResultEvent during prompt generation.
*
* @since 0.4.0
*
* @param EventDispatcherInterface|null $dispatcher The event dispatcher, or null to disable.
* @return void
*/
public static function setEventDispatcher(?EventDispatcherInterface $dispatcher): void
{
self::$eventDispatcher = $dispatcher;
}
/**
* Gets the event dispatcher for prompt lifecycle events.
*
* @since 0.4.0
*
* @return EventDispatcherInterface|null The event dispatcher, or null if not set.
*/
public static function getEventDispatcher(): ?EventDispatcherInterface
{
return self::$eventDispatcher;
}
/**
* Sets the PSR-16 cache for storing and retrieving cached data.
*
* The cache can be used to store AI responses and other data to avoid
* redundant API calls and improve performance.
*
* @since 0.4.0
*
* @param CacheInterface|null $cache The PSR-16 cache instance, or null to disable caching.
* @return void
*/
public static function setCache(?CacheInterface $cache): void
{
self::$cache = $cache;
}
/**
* Gets the PSR-16 cache instance.
*
* @since 0.4.0
*
* @return CacheInterface|null The cache instance, or null if not set.
*/
public static function getCache(): ?CacheInterface
{
return self::$cache;
}
/**
* Checks if a provider is configured and available for use.
*
* Supports multiple input formats for developer convenience:
* - ProviderAvailabilityInterface: Direct availability check
* - string (provider ID): e.g., AiClient::isConfigured('openai')
* - string (class name): e.g., AiClient::isConfigured(OpenAiProvider::class)
*
* When using string input, this method leverages the ProviderRegistry's centralized
* dependency management, ensuring HttpTransporter and authentication are properly
* injected into availability instances.
*
* @since 0.1.0
* @since 0.2.0 Now supports being passed a provider ID or class name.
*
* @param ProviderAvailabilityInterface|string|class-string<ProviderInterface> $availabilityOrIdOrClassName
* The provider availability instance, provider ID, or provider class name.
* @return bool True if the provider is configured and available, false otherwise.
*/
public static function isConfigured($availabilityOrIdOrClassName): bool
{
// Handle direct ProviderAvailabilityInterface (backward compatibility)
if ($availabilityOrIdOrClassName instanceof ProviderAvailabilityInterface) {
return $availabilityOrIdOrClassName->isConfigured();
}
// Handle string input (provider ID or class name) via registry
if (is_string($availabilityOrIdOrClassName)) {
return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName);
}
throw new \InvalidArgumentException('Parameter must be a ProviderAvailabilityInterface instance, provider ID string, or provider class name. ' . sprintf('Received: %s', is_object($availabilityOrIdOrClassName) ? get_class($availabilityOrIdOrClassName) : gettype($availabilityOrIdOrClassName)));
}
/**
* Creates a new prompt builder for fluent API usage.
*
* Returns a PromptBuilder instance configured with the specified or default registry.
* The traditional API methods in this class delegate to PromptBuilder
* for all generation logic.
*
* @since 0.1.0
*
* @param Prompt $prompt Optional initial prompt content.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return PromptBuilder The prompt builder instance.
*/
public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder
{
return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt, self::$eventDispatcher);
}
/**
* Generates content using a unified API that automatically detects model capabilities.
*
* When no model is provided, this method delegates to PromptBuilder for intelligent
* model discovery based on prompt content and configuration. When a model is provided,
* it infers the capability from the model's interfaces and delegates to the capability-based method.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig $modelOrConfig Specific model to use, or model configuration
* for auto-discovery.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the provided model doesn't support any known generation type.
* @throws \RuntimeException If no suitable model can be found for the prompt.
*/
public static function generateResult($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult();
}
/**
* Generates text using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult();
}
/**
* Generates an image using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult();
}
/**
* Converts text to speech using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult();
}
/**
* Generates speech using the traditional API approach.
*
* @since 0.1.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult();
}
/**
* Generates a video using the traditional API approach.
*
* @since 1.3.0
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use,
* or model configuration for auto-discovery,
* or null for defaults.
* @param ProviderRegistry|null $registry Optional custom registry. If null, uses default.
* @return GenerativeAiResult The generation result.
*
* @throws \InvalidArgumentException If the prompt format is invalid.
* @throws \RuntimeException If no suitable model is found.
*/
public static function generateVideoResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult
{
self::validateModelOrConfigParameter($modelOrConfig);
return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateVideoResult();
}
/**
* Creates a new message builder for fluent API usage.
*
* This method will be implemented once MessageBuilder is available.
* MessageBuilder will provide a fluent interface for constructing complex
* messages with multiple parts, attachments, and metadata.
*
* @since 0.1.0
*
* @param string|null $text Optional initial message text.
* @return object MessageBuilder instance (type will be updated when MessageBuilder is available).
*
* @throws \RuntimeException When MessageBuilder is not yet available.
*/
public static function message(?string $text = null)
{
throw new RuntimeException('MessageBuilder is not yet available. This method depends on builder infrastructure. ' . 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.');
}
/**
* Validates that parameter is ModelInterface, ModelConfig, or null.
*
* @param mixed $modelOrConfig The parameter to validate.
* @return void
* @throws \InvalidArgumentException If parameter is invalid type.
*/
private static function validateModelOrConfigParameter($modelOrConfig): void
{
if ($modelOrConfig !== null && !$modelOrConfig instanceof ModelInterface && !$modelOrConfig instanceof ModelConfig) {
throw new InvalidArgumentException('Parameter must be a ModelInterface instance (specific model), ' . 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)));
}
}
/**
* Configures PromptBuilder based on model/config parameter type.
*
* @param Prompt $prompt The prompt content.
* @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter.
* @param ProviderRegistry|null $registry Optional custom registry to use.
* @return PromptBuilder Configured prompt builder.
*/
private static function getConfiguredPromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder
{
$builder = self::prompt($prompt, $registry);
if ($modelOrConfig instanceof ModelInterface) {
$builder->usingModel($modelOrConfig);
} elseif ($modelOrConfig instanceof ModelConfig) {
$builder->usingModelConfig($modelOrConfig);
}
// null case: use default model discovery
return $builder;
}
}

View File

@@ -0,0 +1,221 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Builders;
use InvalidArgumentException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionResponse;
/**
* Fluent builder for constructing AI messages.
*
* This class provides a fluent interface for building messages with various
* content types including text, files, function calls, and function responses.
*
* @since 0.2.0
*
* @phpstan-import-type MessagePartArrayShape from MessagePart
*
* @phpstan-type Input string|MessagePart|MessagePartArrayShape|File|FunctionCall|FunctionResponse|null
*/
class MessageBuilder
{
/**
* @var MessageRoleEnum|null The role of the message sender.
*/
protected ?MessageRoleEnum $role = null;
/**
* @var list<MessagePart> The parts that make up the message.
*/
protected array $parts = [];
/**
* Constructor.
*
* @since 0.2.0
*
* @param Input $input Optional initial content.
* @param MessageRoleEnum|null $role Optional role.
*/
public function __construct($input = null, ?MessageRoleEnum $role = null)
{
$this->role = $role;
if ($input === null) {
return;
}
// Handle different input types
if ($input instanceof MessagePart) {
$this->parts[] = $input;
} elseif (is_string($input)) {
$this->withText($input);
} elseif ($input instanceof File) {
$this->withFile($input);
} elseif ($input instanceof FunctionCall) {
$this->withFunctionCall($input);
} elseif ($input instanceof FunctionResponse) {
$this->withFunctionResponse($input);
} elseif (is_array($input) && MessagePart::isArrayShape($input)) {
$this->parts[] = MessagePart::fromArray($input);
} else {
throw new InvalidArgumentException('Input must be a string, MessagePart, MessagePartArrayShape, File, FunctionCall, or FunctionResponse.');
}
}
/**
* Creates a deep clone of this builder.
*
* Clones all MessagePart objects in the parts array to ensure
* the cloned builder is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone parts array (MessagePart has __clone)
$clonedParts = [];
foreach ($this->parts as $part) {
$clonedParts[] = clone $part;
}
$this->parts = $clonedParts;
// Note: $role is an enum value object and can be safely shared
}
/**
* Sets the role of the message sender.
*
* @since 0.2.0
*
* @param MessageRoleEnum $role The role to set.
* @return self
*/
public function usingRole(MessageRoleEnum $role): self
{
$this->role = $role;
return $this;
}
/**
* Sets the role to user.
*
* @since 0.2.0
*
* @return self
*/
public function usingUserRole(): self
{
return $this->usingRole(MessageRoleEnum::user());
}
/**
* Sets the role to model.
*
* @since 0.2.0
*
* @return self
*/
public function usingModelRole(): self
{
return $this->usingRole(MessageRoleEnum::model());
}
/**
* Adds text content to the message.
*
* @since 0.2.0
*
* @param string $text The text to add.
* @return self
* @throws InvalidArgumentException If the text is empty.
*/
public function withText(string $text): self
{
if (trim($text) === '') {
throw new InvalidArgumentException('Text content cannot be empty.');
}
$this->parts[] = new MessagePart($text);
return $this;
}
/**
* Adds a file to the message.
*
* Accepts:
* - File object
* - URL string (remote file)
* - Base64-encoded data string
* - Data URI string (data:mime/type;base64,data)
* - Local file path string
*
* @since 0.2.0
*
* @param string|File $file The file to add.
* @param string|null $mimeType Optional MIME type (ignored if File object provided).
* @return self
* @throws InvalidArgumentException If the file is invalid.
*/
public function withFile($file, ?string $mimeType = null): self
{
$file = $file instanceof File ? $file : new File($file, $mimeType);
$this->parts[] = new MessagePart($file);
return $this;
}
/**
* Adds a function call to the message.
*
* @since 0.2.0
*
* @param FunctionCall $functionCall The function call to add.
* @return self
*/
public function withFunctionCall(FunctionCall $functionCall): self
{
$this->parts[] = new MessagePart($functionCall);
return $this;
}
/**
* Adds a function response to the message.
*
* @since 0.2.0
*
* @param FunctionResponse $functionResponse The function response to add.
* @return self
*/
public function withFunctionResponse(FunctionResponse $functionResponse): self
{
$this->parts[] = new MessagePart($functionResponse);
return $this;
}
/**
* Adds multiple message parts to the message.
*
* @since 0.2.0
*
* @param MessagePart ...$parts The message parts to add.
* @return self
*/
public function withMessageParts(MessagePart ...$parts): self
{
foreach ($parts as $part) {
$this->parts[] = $part;
}
return $this;
}
/**
* Builds and returns the Message object.
*
* @since 0.2.0
*
* @return Message The built message.
* @throws InvalidArgumentException If the message validation fails.
*/
public function get(): Message
{
if (empty($this->parts)) {
throw new InvalidArgumentException('Cannot build an empty message. Add content using withText() or similar methods.');
}
if ($this->role === null) {
throw new InvalidArgumentException('Cannot build a message with no role. Set a role using usingRole() or similar methods.');
}
// At this point, we've validated that $this->role is not null
/** @var MessageRoleEnum $role */
$role = $this->role;
return new Message($role, $this->parts);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common;
use JsonSerializable;
use stdClass;
use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface;
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Abstract base class for all Data Value Objects in the AI Client.
*
* This abstract class consolidates the common functionality needed by all
* data transfer objects:
* - Array transformation for data manipulation
* - JSON schema support for validation and documentation
* - JSON serialization with proper empty object handling
*
* All DTOs in the AI Client should extend this class to ensure
* consistent behavior across the codebase.
*
* @since 0.1.0
*
* @template TArrayShape of array<string, mixed>
* @implements WithArrayTransformationInterface<TArrayShape>
*/
abstract class AbstractDataTransferObject implements WithArrayTransformationInterface, WithJsonSchemaInterface, JsonSerializable
{
/**
* Validates that required keys exist in the array data.
*
* @since 0.1.0
*
* @param array<mixed> $data The array data to validate.
* @param string[] $requiredKeys The keys that must be present.
* @throws InvalidArgumentException If any required key is missing.
*/
protected static function validateFromArrayData(array $data, array $requiredKeys): void
{
$missingKeys = [];
foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
$missingKeys[] = $key;
}
}
if (!empty($missingKeys)) {
throw new InvalidArgumentException(sprintf('%s::fromArray() missing required keys: %s', static::class, implode(', ', $missingKeys)));
}
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function isArrayShape(array $array): bool
{
try {
/** @var TArrayShape $array */
static::fromArray($array);
return \true;
} catch (InvalidArgumentException $e) {
return \false;
}
}
/**
* Converts the object to a JSON-serializable format.
*
* This method uses the toArray() method and then processes the result
* based on the JSON schema to ensure proper object representation for
* empty arrays.
*
* @since 0.1.0
*
* @return mixed The JSON-serializable representation.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$data = $this->toArray();
$schema = static::getJsonSchema();
return $this->convertEmptyArraysToObjects($data, $schema);
}
/**
* Recursively converts empty arrays to stdClass objects where the schema expects objects.
*
* @since 0.1.0
*
* @param mixed $data The data to process.
* @param array<mixed, mixed> $schema The JSON schema for the data.
* @return mixed The processed data.
*/
private function convertEmptyArraysToObjects($data, array $schema)
{
// If data is an empty array and schema expects object, convert to stdClass
if (is_array($data) && empty($data) && isset($schema['type']) && $schema['type'] === 'object') {
return new stdClass();
}
// If data is an array with content, recursively process nested structures
if (is_array($data)) {
// Handle object properties
if (isset($schema['properties']) && is_array($schema['properties'])) {
foreach ($data as $key => $value) {
if (isset($schema['properties'][$key]) && is_array($schema['properties'][$key])) {
$data[$key] = $this->convertEmptyArraysToObjects($value, $schema['properties'][$key]);
}
}
}
// Handle array items
if (isset($schema['items']) && is_array($schema['items'])) {
foreach ($data as $index => $item) {
$data[$index] = $this->convertEmptyArraysToObjects($item, $schema['items']);
}
}
// Handle oneOf/anyOf schemas - just use the first one
foreach (['oneOf', 'anyOf'] as $keyword) {
if (isset($schema[$keyword]) && is_array($schema[$keyword])) {
foreach ($schema[$keyword] as $possibleSchema) {
if (is_array($possibleSchema)) {
return $this->convertEmptyArraysToObjects($data, $possibleSchema);
}
}
}
}
}
return $data;
}
}

View File

@@ -0,0 +1,349 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common;
use BadMethodCallException;
use JsonSerializable;
use ReflectionClass;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
/**
* Abstract base class for enum-like behavior in PHP 7.4.
*
* This class provides enum-like functionality for PHP versions that don't support native enums.
* Child classes should define uppercase snake_case constants for enum values.
*
* @example
* class PersonEnum extends AbstractEnum {
* public const FIRST_NAME = 'first';
* public const LAST_NAME = 'last';
* }
*
* // Usage:
* $enum = PersonEnum::from('first'); // Creates instance with value 'first'
* $enum = PersonEnum::tryFrom('invalid'); // Returns null
* $enum = PersonEnum::firstName(); // Creates instance with value 'first'
* $enum->name; // 'FIRST_NAME'
* $enum->value; // 'first'
* $enum->equals('first'); // Returns true
* $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 0.1.0
*/
abstract class AbstractEnum implements JsonSerializable
{
/**
* @var string The value of the enum instance.
*/
private string $value;
/**
* @var string The name of the enum constant.
*/
private string $name;
/**
* @var array<string, array<string, string>> Cache for reflection data.
*/
private static array $cache = [];
/**
* @var array<string, array<string, self>> Cache for enum instances.
*/
private static array $instances = [];
/**
* Constructor is private to ensure instances are created through static methods.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @param string $name The constant name.
*/
final private function __construct(string $value, string $name)
{
$this->value = $value;
$this->name = $name;
}
/**
* Provides read-only access to properties.
*
* @since 0.1.0
*
* @param string $property The property name.
* @return mixed The property value.
* @throws BadMethodCallException If property doesn't exist.
*/
final public function __get(string $property)
{
if ($property === 'value' || $property === 'name') {
return $this->{$property};
}
throw new BadMethodCallException(sprintf('Property %s::%s does not exist', static::class, $property));
}
/**
* Prevents property modification.
*
* @since 0.1.0
*
* @param string $property The property name.
* @param mixed $value The value to set.
* @throws BadMethodCallException Always, as enum properties are read-only.
*/
final public function __set(string $property, $value): void
{
throw new BadMethodCallException(sprintf('Cannot modify property %s::%s - enum properties are read-only', static::class, $property));
}
/**
* Creates an enum instance from a value, throws exception if invalid.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @return static The enum instance.
* @throws InvalidArgumentException If the value is not valid.
*/
final public static function from(string $value): self
{
$instance = self::tryFrom($value);
if ($instance === null) {
throw new InvalidArgumentException(sprintf('%s is not a valid backing value for enum %s', $value, static::class));
}
return $instance;
}
/**
* Tries to create an enum instance from a value, returns null if invalid.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @return static|null The enum instance or null.
*/
final public static function tryFrom(string $value): ?self
{
$constants = static::getConstants();
foreach ($constants as $name => $constantValue) {
if ($constantValue === $value) {
return self::getInstance($constantValue, $name);
}
}
return null;
}
/**
* Gets all enum cases.
*
* @since 0.1.0
*
* @return static[] Array of all enum instances.
*/
final public static function cases(): array
{
$cases = [];
$constants = static::getConstants();
foreach ($constants as $name => $value) {
$cases[] = self::getInstance($value, $name);
}
return $cases;
}
/**
* Checks if this enum has the same value as the given value.
*
* @since 0.1.0
*
* @param string|self $other The value or enum to compare.
* @return bool True if values are equal.
*/
final public function equals($other): bool
{
if ($other instanceof self) {
return $this->is($other);
}
return $this->value === $other;
}
/**
* Checks if this enum is the same instance type and value as another enum.
*
* @since 0.1.0
*
* @param self $other The other enum to compare.
* @return bool True if enums are identical.
*/
final public function is(self $other): bool
{
return $this === $other;
// Since we're using singletons, we can use identity comparison
}
/**
* Gets all valid values for this enum.
*
* @since 0.1.0
*
* @return string[] List of all enum values.
*/
final public static function getValues(): array
{
return array_values(static::getConstants());
}
/**
* Checks if a value is valid for this enum.
*
* @since 0.1.0
*
* @param string $value The value to check.
* @return bool True if value is valid.
*/
final public static function isValidValue(string $value): bool
{
return in_array($value, self::getValues(), \true);
}
/**
* Gets or creates a singleton instance for the given value and name.
*
* @since 0.1.0
*
* @param string $value The enum value.
* @param string $name The constant name.
* @return static The enum instance.
*/
private static function getInstance(string $value, string $name): self
{
$className = static::class;
if (!isset(self::$instances[$className])) {
self::$instances[$className] = [];
}
if (!isset(self::$instances[$className][$name])) {
$instance = new $className($value, $name);
self::$instances[$className][$name] = $instance;
}
/** @var static */
return self::$instances[$className][$name];
}
/**
* Gets all constants for this enum class.
*
* @since 0.1.0
*
* @return array<string, string> Map of constant names to values.
* @throws RuntimeException If invalid constant found.
*/
final protected static function getConstants(): array
{
$className = static::class;
if (!isset(self::$cache[$className])) {
self::$cache[$className] = static::determineClassEnumerations($className);
}
return self::$cache[$className];
}
/**
* Determines the class enumerations by reflecting on class constants.
*
* This method can be overridden by subclasses to customize how
* enumerations are determined (e.g., to add dynamic constants).
*
* @since 0.1.0
*
* @param class-string $className The fully qualified class name.
* @return array<string, string> Map of constant names to values.
* @throws RuntimeException If invalid constant found.
*/
protected static function determineClassEnumerations(string $className): array
{
$reflection = new ReflectionClass($className);
$constants = $reflection->getConstants();
// Validate all constants
$enumConstants = [];
foreach ($constants as $name => $value) {
// Check if constant name follows uppercase snake_case pattern
if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
throw new RuntimeException(sprintf('Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.', $name, $className));
}
// Check if value is valid type
if (!is_string($value)) {
throw new RuntimeException(sprintf('Invalid enum value type for constant %s::%s. ' . 'Only string values are allowed, %s given.', $className, $name, gettype($value)));
}
$enumConstants[$name] = $value;
}
return $enumConstants;
}
/**
* Handles dynamic method calls for enum checking.
*
* @since 0.1.0
*
* @param string $name The method name.
* @param array<mixed> $arguments The method arguments.
* @return bool True if the enum value matches.
* @throws BadMethodCallException If the method doesn't exist.
*/
final public function __call(string $name, array $arguments): bool
{
// Handle is* methods
if (str_starts_with($name, 'is')) {
$constantName = self::camelCaseToConstant(substr($name, 2));
$constants = static::getConstants();
if (isset($constants[$constantName])) {
return $this->value === $constants[$constantName];
}
}
throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
}
/**
* Handles static method calls for enum creation.
*
* @since 0.1.0
*
* @param string $name The method name.
* @param array<mixed> $arguments The method arguments.
* @return static The enum instance.
* @throws BadMethodCallException If the method doesn't exist.
*/
final public static function __callStatic(string $name, array $arguments): self
{
$constantName = self::camelCaseToConstant($name);
$constants = static::getConstants();
if (isset($constants[$constantName])) {
return self::getInstance($constants[$constantName], $constantName);
}
throw new BadMethodCallException(sprintf('Method %s::%s does not exist', static::class, $name));
}
/**
* Converts camelCase to CONSTANT_CASE.
*
* @since 0.1.0
*
* @param string $camelCase The camelCase string.
* @return string The CONSTANT_CASE version.
*/
private static function camelCaseToConstant(string $camelCase): string
{
$snakeCase = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase);
if ($snakeCase === null) {
return strtoupper($camelCase);
}
return strtoupper($snakeCase);
}
/**
* Returns string representation of the enum.
*
* @since 0.1.0
*
* @return string The enum value.
*/
final public function __toString(): string
{
return $this->value;
}
/**
* Converts the enum to a JSON-serializable format.
*
* @since 0.1.0
*
* @return string The enum value.
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return $this->value;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
use Throwable;
/**
* Base interface for all AI Client exceptions.
*
* This interface allows callers to catch all AI Client specific exceptions
* with a single catch statement.
*
* @since 0.2.0
*/
interface AiClientExceptionInterface extends Throwable
{
}

View File

@@ -0,0 +1,21 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* Interface for objects that cache data.
*
* @since 0.4.0
*/
interface CachesDataInterface
{
/**
* Invalidates all caches managed by this object.
*
* @since 0.4.0
*
* @return void
*/
public function invalidateCaches(): void;
}

View File

@@ -0,0 +1,42 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* Interface for objects that support array transformation.
*
* @since 0.1.0
*
* @template TArrayShape of array<string, mixed>
*/
interface WithArrayTransformationInterface
{
/**
* Converts the object to an array representation.
*
* @since 0.1.0
*
* @return TArrayShape The array representation.
*/
public function toArray(): array;
/**
* Creates an instance from array data.
*
* @since 0.1.0
*
* @param TArrayShape $array The array data.
* @return self<TArrayShape> The created instance.
*/
public static function fromArray(array $array): self;
/**
* Checks if the array is a valid shape for this object.
*
* @since 0.1.0
*
* @param array<mixed> $array The array to check.
* @return bool True if the array is a valid shape.
* @phpstan-assert-if-true TArrayShape $array
*/
public static function isArrayShape(array $array): bool;
}

View File

@@ -0,0 +1,24 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Contracts;
/**
* 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.
*
* @since 0.1.0
*/
interface WithJsonSchemaInterface
{
/**
* Gets the JSON schema representation of the object.
*
* @since 0.1.0
*
* @return array<string, mixed> The JSON schema as an associative array.
*/
public static function getJsonSchema(): array;
}

View File

@@ -0,0 +1,17 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
/**
* Exception thrown when an invalid argument is provided.
*
* This extends PHP's built-in InvalidArgumentException while implementing
* the AI Client exception interface for consistent catch handling.
*
* @since 0.2.0
*/
class InvalidArgumentException extends \InvalidArgumentException implements AiClientExceptionInterface
{
}

View File

@@ -0,0 +1,17 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface;
/**
* Exception thrown for runtime errors.
*
* This extends PHP's built-in RuntimeException while implementing
* the AI Client exception interface for consistent catch handling.
*
* @since 0.2.0
*/
class RuntimeException extends \RuntimeException implements AiClientExceptionInterface
{
}

View File

@@ -0,0 +1,50 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Exception;
/**
* Exception thrown when a token limit is reached during prompt fulfillment.
*
* Providers should throw this exception when the token usage for a request
* exceeds the allowed limit, whether that is the model's context window
* or a configured maximum.
*
* @since 1.0.0
*/
class TokenLimitReachedException extends \WordPress\AiClient\Common\Exception\RuntimeException
{
/**
* The token limit that was reached, if known.
*
* @since 1.0.0
*
* @var int|null
*/
private $maxTokens;
/**
* Creates a new TokenLimitReachedException.
*
* @since 1.0.0
*
* @param string $message The exception message.
* @param int|null $maxTokens The token limit that was reached, if known.
* @param \Throwable|null $previous The previous throwable used for exception chaining.
*/
public function __construct(string $message = '', ?int $maxTokens = null, ?\Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
$this->maxTokens = $maxTokens;
}
/**
* Returns the token limit that was reached, if known.
*
* @since 1.0.0
*
* @return int|null The token limit, or null if not provided.
*/
public function getMaxTokens(): ?int
{
return $this->maxTokens;
}
}

View File

@@ -0,0 +1,162 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Common\Traits;
use WordPress\AiClient\AiClient;
/**
* Trait for objects that cache data using PSR-16 cache with in-memory fallback.
*
* When a PSR-16 cache is configured via AiClient::setCache(), data is stored persistently.
* Otherwise, data is cached in-memory for the duration of the request.
*
* @since 0.4.0
*/
trait WithDataCachingTrait
{
/**
* In-memory cache used when no PSR-16 cache is configured.
*
* @since 0.4.0
*
* @var array<string, mixed>
*/
private array $localCache = [];
/**
* Gets the cache key suffixes managed by this object.
*
* @since 0.4.0
*
* @return list<string> The cache key suffixes.
*/
abstract protected function getCachedKeys(): array;
/**
* Gets the base cache key for this object.
*
* The base cache key is used as a prefix for all cache keys managed by this object.
* It should be unique to the implementing class to avoid cache key collisions.
*
* @since 0.4.0
*
* @return string The base cache key.
*/
abstract protected function getBaseCacheKey(): string;
/**
* Checks if a value exists in the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @return bool True if the value exists in cache, false otherwise.
*/
protected function hasCache(string $key): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->has($fullKey);
}
return array_key_exists($fullKey, $this->localCache);
}
/**
* Gets a value from the cache, or computes and caches it if not present.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param callable $callback The callback to compute the value if not cached.
* @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default.
* Ignored for local cache.
* @return mixed The cached or computed value.
*/
protected function cached(string $key, callable $callback, $ttl = null)
{
if ($this->hasCache($key)) {
return $this->getCache($key);
}
$value = $callback();
$this->setCache($key, $value, $ttl);
return $value;
}
/**
* Gets a value from the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param mixed $default The default value to return if the key does not exist.
* @return mixed The cached value or the default value if not found.
*/
protected function getCache(string $key, $default = null)
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->get($fullKey, $default);
}
return $this->localCache[$fullKey] ?? $default;
}
/**
* Sets a value in the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @param mixed $value The value to cache.
* @param int|\DateInterval|null $ttl The TTL for the cache entry, or null for default. Ignored for local cache.
* @return bool True on success, false on failure.
*/
protected function setCache(string $key, $value, $ttl = null): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->set($fullKey, $value, $ttl);
}
$this->localCache[$fullKey] = $value;
return \true;
}
/**
* Invalidates all caches managed by this object.
*
* @since 0.4.0
*
* @return void
*/
public function invalidateCaches(): void
{
foreach ($this->getCachedKeys() as $key) {
$this->clearCache($key);
}
}
/**
* Clears a value from the cache.
*
* @since 0.4.0
*
* @param string $key The cache key suffix (will be appended to the base key).
* @return bool True on success, false on failure.
*/
protected function clearCache(string $key): bool
{
$fullKey = $this->buildCacheKey($key);
$cache = AiClient::getCache();
if ($cache !== null) {
return $cache->delete($fullKey);
}
unset($this->localCache[$fullKey]);
return \true;
}
/**
* Builds the full cache key by combining the base key with the suffix.
*
* @since 0.4.0
*
* @param string $key The cache key suffix.
* @return string The full cache key.
*/
private function buildCacheKey(string $key): string
{
return $this->getBaseCacheKey() . '_' . $key;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Events;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Event dispatched after a prompt has been sent to the AI model and a response received.
*
* This event allows listeners to inspect the result of the model call for logging,
* analytics, or other post-processing purposes. The result object is immutable.
*
* @since 0.4.0
*/
class AfterGenerateResultEvent
{
/**
* @var list<Message> The messages that were sent to the model.
*/
private array $messages;
/**
* @var ModelInterface The model that processed the prompt.
*/
private ModelInterface $model;
/**
* @var CapabilityEnum|null The capability that was used for generation.
*/
private ?CapabilityEnum $capability;
/**
* @var GenerativeAiResult The result from the model.
*/
private GenerativeAiResult $result;
/**
* Constructor.
*
* @since 0.4.0
*
* @param list<Message> $messages The messages that were sent to the model.
* @param ModelInterface $model The model that processed the prompt.
* @param CapabilityEnum|null $capability The capability that was used for generation.
* @param GenerativeAiResult $result The result from the model.
*/
public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability, GenerativeAiResult $result)
{
$this->messages = $messages;
$this->model = $model;
$this->capability = $capability;
$this->result = $result;
}
/**
* Gets the messages that were sent to the model.
*
* @since 0.4.0
*
* @return list<Message> The messages.
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Gets the model that processed the prompt.
*
* @since 0.4.0
*
* @return ModelInterface The model.
*/
public function getModel(): ModelInterface
{
return $this->model;
}
/**
* Gets the capability that was used for generation.
*
* @since 0.4.0
*
* @return CapabilityEnum|null The capability, or null if not specified.
*/
public function getCapability(): ?CapabilityEnum
{
return $this->capability;
}
/**
* Gets the result from the model.
*
* @since 0.4.0
*
* @return GenerativeAiResult The result.
*/
public function getResult(): GenerativeAiResult
{
return $this->result;
}
/**
* Performs a deep clone of the event.
*
* This method ensures that message and result objects are cloned to prevent
* modifications to the cloned event from affecting the original.
* The model object is not cloned as it is a service object.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;
$this->result = clone $this->result;
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Events;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
/**
* Event dispatched before a prompt is sent to the AI model.
*
* This event allows listeners to inspect and modify the messages before they
* are sent to the model. The event is not stoppable, meaning the model call
* will always proceed regardless of listener actions.
*
* @since 0.4.0
*/
class BeforeGenerateResultEvent
{
/**
* @var list<Message> The messages to be sent to the model.
*/
private array $messages;
/**
* @var ModelInterface The model that will process the prompt.
*/
private ModelInterface $model;
/**
* @var CapabilityEnum|null The capability being used for generation.
*/
private ?CapabilityEnum $capability;
/**
* Constructor.
*
* @since 0.4.0
*
* @param list<Message> $messages The messages to be sent to the model.
* @param ModelInterface $model The model that will process the prompt.
* @param CapabilityEnum|null $capability The capability being used for generation.
*/
public function __construct(array $messages, ModelInterface $model, ?CapabilityEnum $capability)
{
$this->messages = $messages;
$this->model = $model;
$this->capability = $capability;
}
/**
* Gets the messages to be sent to the model.
*
* @since 0.4.0
*
* @return list<Message> The messages.
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Gets the model that will process the prompt.
*
* @since 0.4.0
*
* @return ModelInterface The model.
*/
public function getModel(): ModelInterface
{
return $this->model;
}
/**
* Gets the capability being used for generation.
*
* @since 0.4.0
*
* @return CapabilityEnum|null The capability, or null if not specified.
*/
public function getCapability(): ?CapabilityEnum
{
return $this->capability;
}
/**
* Performs a deep clone of the event.
*
* This method ensures that message objects are cloned to prevent
* modifications to the cloned event from affecting the original.
* The model object is not cloned as it is a service object.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedMessages = [];
foreach ($this->messages as $message) {
$clonedMessages[] = clone $message;
}
$this->messages = $clonedMessages;
}
}

View File

@@ -0,0 +1,400 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\ValueObjects\MimeType;
/**
* Represents a file in the AI client.
*
* This DTO automatically detects whether a file is a URL, base64 data, or local file path
* and handles them appropriately.
*
* @since 0.1.0
*
* @phpstan-type FileArrayShape array{
* fileType: string,
* url?: string,
* mimeType: string,
* base64Data?: string
* }
*
* @extends AbstractDataTransferObject<FileArrayShape>
*/
class File extends AbstractDataTransferObject
{
public const KEY_FILE_TYPE = 'fileType';
public const KEY_MIME_TYPE = 'mimeType';
public const KEY_URL = 'url';
public const KEY_BASE64_DATA = 'base64Data';
/**
* @var MimeType The MIME type of the file.
*/
private MimeType $mimeType;
/**
* @var FileTypeEnum The type of file storage.
*/
private FileTypeEnum $fileType;
/**
* @var string|null The URL for remote files.
*/
private ?string $url = null;
/**
* @var string|null The base64 data for inline files.
*/
private ?string $base64Data = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $file The file string (URL, base64 data, or local path).
* @param string|null $mimeType The MIME type of the file (optional).
* @throws InvalidArgumentException If the file format is invalid or MIME type cannot be determined.
*/
public function __construct(string $file, ?string $mimeType = null)
{
// Detect and process the file type (will set MIME type if possible)
$this->detectAndProcessFile($file, $mimeType);
}
/**
* Detects the file type and processes it accordingly.
*
* @since 0.1.0
*
* @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->url = $file;
$this->mimeType = $this->determineMimeType($providedMimeType, null, $file);
return;
}
// Data URI pattern.
$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})$/';
// Check if it's a data URI.
if (preg_match($dataUriPattern, $file, $matches)) {
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $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 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) {
throw new InvalidArgumentException('MIME type is required when providing plain base64 data without data URI format.');
}
$this->fileType = FileTypeEnum::inline();
$this->base64Data = $file;
$this->mimeType = new MimeType($providedMimeType);
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 0.1.0
*
* @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 0.1.0
*
* @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 0.1.0
*
* @return FileTypeEnum The file type.
*/
public function getFileType(): FileTypeEnum
{
return $this->fileType;
}
/**
* Checks if the file is an inline file.
*
* @since 0.1.0
*
* @return bool True if the file is inline (base64/data URI).
*/
public function isInline(): bool
{
return $this->fileType->isInline();
}
/**
* Checks if the file is a remote file.
*
* @since 0.1.0
*
* @return bool True if the file is remote (URL).
*/
public function isRemote(): bool
{
return $this->fileType->isRemote();
}
/**
* Gets the URL for remote files.
*
* @since 0.1.0
*
* @return string|null The URL, or null if not a remote file.
*/
public function getUrl(): ?string
{
return $this->url;
}
/**
* Gets the base64-encoded data for inline files.
*
* @since 0.1.0
*
* @return string|null The plain base64-encoded data (without data URI prefix), or null if not an inline file.
*/
public function getBase64Data(): ?string
{
return $this->base64Data;
}
/**
* Gets the data as a data URI for inline files.
*
* @since 0.1.0
*
* @return string|null The data URI in format: data:[mimeType];base64,[data], or null if not an inline file.
*/
public function getDataUri(): ?string
{
if ($this->base64Data === null) {
return null;
}
return sprintf('data:%s;base64,%s', $this->getMimeType(), $this->base64Data);
}
/**
* Gets the MIME type of the file as a string.
*
* @since 0.1.0
*
* @return string The MIME type string value.
*/
public function getMimeType(): string
{
return (string) $this->mimeType;
}
/**
* Gets the MIME type object.
*
* @since 0.1.0
*
* @return MimeType The MIME type object.
*/
public function getMimeTypeObject(): MimeType
{
return $this->mimeType;
}
/**
* Checks if the file is a video.
*
* @since 0.1.0
*
* @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 0.1.0
*
* @return bool True if the file is an image.
*/
public function isImage(): bool
{
return $this->mimeType->isImage();
}
/**
* Checks if the file is audio.
*
* @since 0.1.0
*
* @return bool True if the file is audio.
*/
public function isAudio(): bool
{
return $this->mimeType->isAudio();
}
/**
* Checks if the file is text.
*
* @since 0.1.0
*
* @return bool True if the file is text.
*/
public function isText(): bool
{
return $this->mimeType->isText();
}
/**
* Checks if the file is a document.
*
* @since 0.1.0
*
* @return bool True if the file is a document.
*/
public function isDocument(): bool
{
return $this->mimeType->isDocument();
}
/**
* Checks if the file is a specific MIME type.
*
* @since 0.1.0
*
* @param string $type The mime type to check (e.g. 'image', 'text', 'video', 'audio').
*
* @return bool True if the file is of the specified type.
*/
public function isMimeType(string $type): bool
{
return $this->mimeType->isType($type);
}
/**
* Determines the MIME type from various sources.
*
* @since 0.1.0
*
* @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 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'oneOf' => [['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::REMOTE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['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!#$&\-\^_+.]*$'], self::KEY_URL => ['type' => 'string', 'format' => 'uri', 'description' => 'The URL to the remote file.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_URL]], ['properties' => [self::KEY_FILE_TYPE => ['type' => 'string', 'const' => FileTypeEnum::INLINE, 'description' => 'The file type.'], self::KEY_MIME_TYPE => ['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!#$&\-\^_+.]*$'], self::KEY_BASE64_DATA => ['type' => 'string', 'description' => 'The base64-encoded file data.']], 'required' => [self::KEY_FILE_TYPE, self::KEY_MIME_TYPE, self::KEY_BASE64_DATA]]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FileArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_FILE_TYPE => $this->fileType->value, self::KEY_MIME_TYPE => $this->getMimeType()];
if ($this->url !== null) {
$data[self::KEY_URL] = $this->url;
} elseif (!$this->fileType->isRemote() && $this->base64Data !== null) {
$data[self::KEY_BASE64_DATA] = $this->base64Data;
} else {
throw new RuntimeException('File requires either url or base64Data. This should not be a possible condition.');
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_FILE_TYPE]);
// Check which properties are set to determine how to construct the File
$mimeType = $array[self::KEY_MIME_TYPE] ?? null;
if (isset($array[self::KEY_URL])) {
return new self($array[self::KEY_URL], $mimeType);
} elseif (isset($array[self::KEY_BASE64_DATA])) {
return new self($array[self::KEY_BASE64_DATA], $mimeType);
} else {
throw new InvalidArgumentException('File requires either url or base64Data.');
}
}
/**
* Performs a deep clone of the file.
*
* This method ensures that the MimeType value object is cloned to prevent
* any shared references between the original and cloned file.
*
* @since 0.4.2
*/
public function __clone()
{
$this->mimeType = clone $this->mimeType;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents the type of file storage.
*
* @method static self inline() Returns the inline file type.
* @method static self remote() Returns the remote file type.
* @method bool isInline() Checks if this is an inline file type.
* @method bool isRemote() Checks if this is a remote file type.
*
* @since 0.1.0
*/
class FileTypeEnum extends AbstractEnum
{
/**
* Inline file with base64-encoded data.
*
* @var string
*/
public const INLINE = 'inline';
/**
* Remote file referenced by URL.
*
* @var string
*/
public const REMOTE = 'remote';
}

View File

@@ -0,0 +1,39 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents the type of file storage.
*
* @method static self square() Returns the square orientation
* @method static self landscape() Returns the landscape orientation.
* @method static self portrait() Returns the portrait orientation.
* @method bool isSquare() Checks if this is an square orientation
* @method bool isLandscape() Checks if this is a landscape orientation.
* @method bool isPortrait() Checks if this is a portrait orientation.
*
* @since 0.1.0
*/
class MediaOrientationEnum extends AbstractEnum
{
/**
* Square orientation.
*
* @var string
*/
public const SQUARE = 'square';
/**
* Landscape orientation.
*
* @var string
*/
public const LANDSCAPE = 'landscape';
/**
* Portrait orientation.
*
* @var string
*/
public const PORTRAIT = 'portrait';
}

View File

@@ -0,0 +1,255 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Files\ValueObjects;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Value object representing a MIME type.
*
* This immutable value object encapsulates MIME type validation and
* provides convenient methods for checking MIME type categories.
*
* @since 0.1.0
*/
final class MimeType
{
/**
* @var string The MIME type value.
*/
private string $value;
/**
* Common MIME type mappings for file extensions.
*
* @var array<string, string>
*/
private static array $extensionMap = [
// 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',
'aac' => 'audio/aac',
// 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',
];
/**
* Document MIME types.
*
* @var array<string>
*/
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.
*
* @since 0.1.0
*
* @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 = strtolower($value);
}
/**
* Gets the primary known file extension for this MIME type.
*
* @since 0.1.0
*
* @return string The file extension (without the dot).
* @throws InvalidArgumentException If no known extension exists for this MIME type.
*/
public function toExtension(): string
{
// Reverse lookup for the MIME type to find the extension.
$extension = array_search($this->value, self::$extensionMap, \true);
if ($extension === \false) {
throw new InvalidArgumentException(sprintf('No known extension for MIME type: %s', $this->value));
}
return $extension;
}
/**
* Creates a MimeType from a file extension.
*
* @since 0.1.0
*
* @param string $extension The file extension (without the dot).
* @return self The MimeType instance.
* @throws InvalidArgumentException If the extension is not recognized.
*/
public static function fromExtension(string $extension): self
{
$extension = strtolower($extension);
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 0.1.0
*
* @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 this MIME type is a specific type.
*
* This method returns true when the stored MIME type begins with the
* given prefix. For example, `"audio"` matches `"audio/mpeg"`.
*
* @since 0.1.0
*
* @param string $mimeType The MIME type prefix to check (e.g., "audio", "image").
* @return bool True if this MIME type is of the specified type.
*/
public function isType(string $mimeType): bool
{
return str_starts_with($this->value, strtolower($mimeType) . '/');
}
/**
* Checks if this is an image MIME type.
*
* @since 0.1.0
*
* @return bool True if this is an image type.
*/
public function isImage(): bool
{
return $this->isType('image');
}
/**
* Checks if this is an audio MIME type.
*
* @since 0.1.0
*
* @return bool True if this is an audio type.
*/
public function isAudio(): bool
{
return $this->isType('audio');
}
/**
* Checks if this is a video MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a video type.
*/
public function isVideo(): bool
{
return $this->isType('video');
}
/**
* Checks if this is a text MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a text type.
*/
public function isText(): bool
{
return $this->isType('text');
}
/**
* Checks if this is a document MIME type.
*
* @since 0.1.0
*
* @return bool True if this is a document type.
*/
public function isDocument(): bool
{
return in_array($this->value, self::$documentTypes, \true);
}
/**
* Checks if this MIME type equals another.
*
* @since 0.1.0
*
* @param self|string $other The other MIME type to compare.
* @return bool True if equal.
* @throws InvalidArgumentException If the other MIME type is invalid.
*/
public function equals($other): bool
{
if ($other instanceof self) {
return $this->value === $other->value;
}
if (is_string($other)) {
return $this->value === strtolower($other);
}
throw new InvalidArgumentException(sprintf('Invalid MIME type comparison: %s', gettype($other)));
}
/**
* Gets the string representation of the MIME type.
*
* @since 0.1.0
*
* @return string The MIME type value.
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* 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.
*
* @since 0.1.0
*
* @phpstan-import-type MessagePartArrayShape from MessagePart
*
* @phpstan-type MessageArrayShape array{
* role: string,
* parts: array<MessagePartArrayShape>
* }
*
* @extends AbstractDataTransferObject<MessageArrayShape>
*/
class Message extends AbstractDataTransferObject
{
public const KEY_ROLE = 'role';
public const KEY_PARTS = 'parts';
/**
* @var MessageRoleEnum The role of the message sender.
*/
protected MessageRoleEnum $role;
/**
* @var MessagePart[] The parts that make up this message.
*/
protected array $parts;
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessageRoleEnum $role The role of the message sender.
* @param MessagePart[] $parts The parts that make up this message.
* @throws InvalidArgumentException If parts contain invalid content for the role.
*/
public function __construct(MessageRoleEnum $role, array $parts)
{
$this->role = $role;
$this->parts = $parts;
$this->validateParts();
}
/**
* Gets the role of the message sender.
*
* @since 0.1.0
*
* @return MessageRoleEnum The role.
*/
public function getRole(): MessageRoleEnum
{
return $this->role;
}
/**
* Gets the message parts.
*
* @since 0.1.0
*
* @return MessagePart[] The message parts.
*/
public function getParts(): array
{
return $this->parts;
}
/**
* Returns a new instance with the given part appended.
*
* @since 0.1.0
*
* @param MessagePart $part The part to append.
* @return Message A new instance with the part appended.
* @throws InvalidArgumentException If the part is invalid for the role.
*/
public function withPart(\WordPress\AiClient\Messages\DTO\MessagePart $part): \WordPress\AiClient\Messages\DTO\Message
{
$newParts = $this->parts;
$newParts[] = $part;
return new \WordPress\AiClient\Messages\DTO\Message($this->role, $newParts);
}
/**
* Validates that the message parts are appropriate for the message role.
*
* @since 0.1.0
*
* @return void
* @throws InvalidArgumentException If validation fails.
*/
private function validateParts(): void
{
foreach ($this->parts as $part) {
$type = $part->getType();
if ($this->role->isUser() && $type->isFunctionCall()) {
throw new InvalidArgumentException('User messages cannot contain function calls.');
}
if ($this->role->isModel() && $type->isFunctionResponse()) {
throw new InvalidArgumentException('Model messages cannot contain function responses.');
}
}
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ROLE => ['type' => 'string', 'enum' => MessageRoleEnum::getValues(), 'description' => 'The role of the message sender.'], self::KEY_PARTS => ['type' => 'array', 'items' => \WordPress\AiClient\Messages\DTO\MessagePart::getJsonSchema(), 'minItems' => 1, 'description' => 'The parts that make up this message.']], 'required' => [self::KEY_ROLE, self::KEY_PARTS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return MessageArrayShape
*/
public function toArray(): array
{
return [self::KEY_ROLE => $this->role->value, self::KEY_PARTS => array_map(function (\WordPress\AiClient\Messages\DTO\MessagePart $part) {
return $part->toArray();
}, $this->parts)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return self The specific message class based on the role.
*/
final public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ROLE, self::KEY_PARTS]);
$role = MessageRoleEnum::from($array[self::KEY_ROLE]);
$partsData = $array[self::KEY_PARTS];
$parts = array_map(function (array $partData) {
return \WordPress\AiClient\Messages\DTO\MessagePart::fromArray($partData);
}, $partsData);
// Determine which concrete class to instantiate based on role
if ($role->isUser()) {
return new \WordPress\AiClient\Messages\DTO\UserMessage($parts);
} elseif ($role->isModel()) {
return new \WordPress\AiClient\Messages\DTO\ModelMessage($parts);
} else {
// Only USER and MODEL roles are supported
throw new InvalidArgumentException('Invalid message role: ' . $role->value);
}
}
/**
* Performs a deep clone of the message.
*
* This method ensures that message part objects are cloned to prevent
* modifications to the cloned message from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedParts = [];
foreach ($this->parts as $part) {
$clonedParts[] = clone $part;
}
$this->parts = $clonedParts;
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum;
use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionResponse;
/**
* 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.
*
* @since 0.1.0
*
* @phpstan-import-type FileArrayShape from File
* @phpstan-import-type FunctionCallArrayShape from FunctionCall
* @phpstan-import-type FunctionResponseArrayShape from FunctionResponse
*
* @phpstan-type MessagePartArrayShape array{
* channel: string,
* type: string,
* thoughtSignature?: string,
* text?: string,
* file?: FileArrayShape,
* functionCall?: FunctionCallArrayShape,
* functionResponse?: FunctionResponseArrayShape
* }
*
* @extends AbstractDataTransferObject<MessagePartArrayShape>
*/
class MessagePart extends AbstractDataTransferObject
{
public const KEY_CHANNEL = 'channel';
public const KEY_TYPE = 'type';
public const KEY_THOUGHT_SIGNATURE = 'thoughtSignature';
public const KEY_TEXT = 'text';
public const KEY_FILE = 'file';
public const KEY_FUNCTION_CALL = 'functionCall';
public const KEY_FUNCTION_RESPONSE = 'functionResponse';
/**
* @var MessagePartChannelEnum The channel this message part belongs to.
*/
private MessagePartChannelEnum $channel;
/**
* @var MessagePartTypeEnum The type of this message part.
*/
private MessagePartTypeEnum $type;
/**
* @var string|null Thought signature for extended thinking.
*/
private ?string $thoughtSignature = null;
/**
* @var string|null Text content (when type is TEXT).
*/
private ?string $text = null;
/**
* @var File|null File data (when type is FILE).
*/
private ?File $file = null;
/**
* @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).
*/
private ?FunctionResponse $functionResponse = null;
/**
* Constructor that accepts various content types and infers the message part type.
*
* @since 0.1.0
*
* @param mixed $content The content of this message part.
* @param MessagePartChannelEnum|null $channel The channel this part belongs to. Defaults to CONTENT.
* @param string|null $thoughtSignature Optional thought signature for extended thinking.
* @throws InvalidArgumentException If an unsupported content type is provided.
*/
public function __construct($content, ?MessagePartChannelEnum $channel = null, ?string $thoughtSignature = null)
{
$this->channel = $channel ?? MessagePartChannelEnum::content();
$this->thoughtSignature = $thoughtSignature;
if (is_string($content)) {
$this->type = MessagePartTypeEnum::text();
$this->text = $content;
} elseif ($content instanceof File) {
$this->type = MessagePartTypeEnum::file();
$this->file = $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, File, ' . 'FunctionCall, or FunctionResponse.', $type));
}
}
/**
* Gets the channel this message part belongs to.
*
* @since 0.1.0
*
* @return MessagePartChannelEnum The channel.
*/
public function getChannel(): MessagePartChannelEnum
{
return $this->channel;
}
/**
* Gets the type of this message part.
*
* @since 0.1.0
*
* @return MessagePartTypeEnum The type.
*/
public function getType(): MessagePartTypeEnum
{
return $this->type;
}
/**
* Gets the thought signature.
*
* @since 1.3.0
*
* @return string|null The thought signature or null if not set.
*/
public function getThoughtSignature(): ?string
{
return $this->thoughtSignature;
}
/**
* Gets the text content.
*
* @since 0.1.0
*
* @return string|null The text content or null if not a text part.
*/
public function getText(): ?string
{
return $this->text;
}
/**
* Gets the file.
*
* @since 0.1.0
*
* @return File|null The file or null if not a file part.
*/
public function getFile(): ?File
{
return $this->file;
}
/**
* Gets the function call.
*
* @since 0.1.0
*
* @return FunctionCall|null The function call or null if not a function call part.
*/
public function getFunctionCall(): ?FunctionCall
{
return $this->functionCall;
}
/**
* Gets the function response.
*
* @since 0.1.0
*
* @return FunctionResponse|null The function response or null if not a function response part.
*/
public function getFunctionResponse(): ?FunctionResponse
{
return $this->functionResponse;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
$channelSchema = ['type' => 'string', 'enum' => MessagePartChannelEnum::getValues(), 'description' => 'The channel this message part belongs to.'];
$thoughtSignatureSchema = ['type' => 'string', 'description' => 'Thought signature for extended thinking.'];
return ['oneOf' => [['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::text()->value], self::KEY_TEXT => ['type' => 'string', 'description' => 'Text content.'], self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_TEXT], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::file()->value], self::KEY_FILE => File::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FILE], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionCall()->value], self::KEY_FUNCTION_CALL => FunctionCall::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_CALL], 'additionalProperties' => \false], ['type' => 'object', 'properties' => [self::KEY_CHANNEL => $channelSchema, self::KEY_TYPE => ['type' => 'string', 'const' => MessagePartTypeEnum::functionResponse()->value], self::KEY_FUNCTION_RESPONSE => FunctionResponse::getJsonSchema(), self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema], 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => \false]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return MessagePartArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_CHANNEL => $this->channel->value, self::KEY_TYPE => $this->type->value];
if ($this->text !== null) {
$data[self::KEY_TEXT] = $this->text;
} elseif ($this->file !== null) {
$data[self::KEY_FILE] = $this->file->toArray();
} elseif ($this->functionCall !== null) {
$data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray();
} elseif ($this->functionResponse !== null) {
$data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray();
} else {
throw new RuntimeException('MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.');
}
if ($this->thoughtSignature !== null) {
$data[self::KEY_THOUGHT_SIGNATURE] = $this->thoughtSignature;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
if (isset($array[self::KEY_CHANNEL])) {
$channel = MessagePartChannelEnum::from($array[self::KEY_CHANNEL]);
} else {
$channel = null;
}
$thoughtSignature = $array[self::KEY_THOUGHT_SIGNATURE] ?? null;
// Check which properties are set to determine how to construct the MessagePart
if (isset($array[self::KEY_TEXT])) {
return new self($array[self::KEY_TEXT], $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FILE])) {
return new self(File::fromArray($array[self::KEY_FILE]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_CALL])) {
return new self(FunctionCall::fromArray($array[self::KEY_FUNCTION_CALL]), $channel, $thoughtSignature);
} elseif (isset($array[self::KEY_FUNCTION_RESPONSE])) {
return new self(FunctionResponse::fromArray($array[self::KEY_FUNCTION_RESPONSE]), $channel, $thoughtSignature);
} else {
throw new InvalidArgumentException('MessagePart requires one of: text, file, functionCall, or functionResponse.');
}
}
/**
* Performs a deep clone of the message part.
*
* This method ensures that nested objects (file, function call, function response)
* are cloned to prevent modifications to the cloned part from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
if ($this->file !== null) {
$this->file = clone $this->file;
}
if ($this->functionCall !== null) {
$this->functionCall = clone $this->functionCall;
}
if ($this->functionResponse !== null) {
$this->functionResponse = clone $this->functionResponse;
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* 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.
*
* Important: Do not rely on `instanceof ModelMessage` to determine the message role.
* This is merely a helper class for construction. Always use `$message->getRole()`
* to check the role of a message.
*
* @since 0.1.0
*/
class ModelMessage extends \WordPress\AiClient\Messages\DTO\Message
{
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessagePart[] $parts The parts that make up this message.
*/
public function __construct(array $parts)
{
parent::__construct(MessageRoleEnum::model(), $parts);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\DTO;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
/**
* Represents a message from a user.
*
* This is a convenience class that automatically sets the role to USER.
*
* Important: Do not rely on `instanceof UserMessage` to determine the message role.
* This is merely a helper class for construction. Always use `$message->getRole()`
* to check the role of a message.
*
* @since 0.1.0
*/
class UserMessage extends \WordPress\AiClient\Messages\DTO\Message
{
/**
* Constructor.
*
* @since 0.1.0
*
* @param MessagePart[] $parts The parts that make up this message.
*/
public function __construct(array $parts)
{
parent::__construct(MessageRoleEnum::user(), $parts);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message part channels.
*
* @since 0.1.0
*
* @method static self content() Creates an instance for CONTENT channel.
* @method static self thought() Creates an instance for THOUGHT channel.
* @method bool isContent() Checks if the channel is CONTENT.
* @method bool isThought() Checks if the channel is THOUGHT.
*/
class MessagePartChannelEnum extends AbstractEnum
{
/**
* Regular (primary) content.
*/
public const CONTENT = 'content';
/**
* Model thinking or reasoning.
*/
public const THOUGHT = 'thought';
}

View File

@@ -0,0 +1,39 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message part types.
*
* @since 0.1.0
*
* @method static self text() Creates an instance for TEXT 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 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.
*/
class MessagePartTypeEnum extends AbstractEnum
{
/**
* Text content.
*/
public const TEXT = 'text';
/**
* File content (inline or remote).
*/
public const FILE = 'file';
/**
* Function call request.
*/
public const FUNCTION_CALL = 'function_call';
/**
* Function response.
*/
public const FUNCTION_RESPONSE = 'function_response';
}

View File

@@ -0,0 +1,27 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for message roles in AI conversations.
*
* @since 0.1.0
*
* @method static self user() Creates an instance for USER role.
* @method static self model() Creates an instance for MODEL role.
* @method bool isUser() Checks if the role is USER.
* @method bool isModel() Checks if the role is MODEL.
*/
class MessageRoleEnum extends AbstractEnum
{
/**
* User role - messages from the user.
*/
public const USER = 'user';
/**
* Model role - messages from the AI model.
*/
public const MODEL = 'model';
}

View File

@@ -0,0 +1,45 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Messages\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for input/output modalities.
*
* @since 0.1.0
*
* @method static self text() Creates an instance for TEXT modality.
* @method static self document() Creates an instance for DOCUMENT modality.
* @method static self image() Creates an instance for IMAGE modality.
* @method static self audio() Creates an instance for AUDIO modality.
* @method static self video() Creates an instance for VIDEO modality.
* @method bool isText() Checks if the modality is TEXT.
* @method bool isDocument() Checks if the modality is DOCUMENT.
* @method bool isImage() Checks if the modality is IMAGE.
* @method bool isAudio() Checks if the modality is AUDIO.
* @method bool isVideo() Checks if the modality is VIDEO.
*/
class ModalityEnum extends AbstractEnum
{
/**
* Text modality.
*/
public const TEXT = 'text';
/**
* Document modality (PDFs, Word docs, etc.).
*/
public const DOCUMENT = 'document';
/**
* Image modality.
*/
public const IMAGE = 'image';
/**
* Audio modality.
*/
public const AUDIO = 'audio';
/**
* Video modality.
*/
public const VIDEO = 'video';
}

View File

@@ -0,0 +1,33 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\Contracts;
use WordPress\AiClient\Operations\Enums\OperationStateEnum;
/**
* 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.
*
* @since 0.1.0
*/
interface OperationInterface
{
/**
* Gets the operation ID.
*
* @since 0.1.0
*
* @return string The unique operation identifier.
*/
public function getId(): string;
/**
* Gets the current state of the operation.
*
* @since 0.1.0
*
* @return OperationStateEnum The operation state.
*/
public function getState(): OperationStateEnum;
}

View File

@@ -0,0 +1,150 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Operations\Contracts\OperationInterface;
use WordPress\AiClient\Operations\Enums\OperationStateEnum;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* 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.
*
* @since 0.1.0
*
* @phpstan-import-type GenerativeAiResultArrayShape from GenerativeAiResult
*
* @phpstan-type GenerativeAiOperationArrayShape array{id: string, state: string, result?: GenerativeAiResultArrayShape}
*
* @extends AbstractDataTransferObject<GenerativeAiOperationArrayShape>
*/
class GenerativeAiOperation extends AbstractDataTransferObject implements OperationInterface
{
public const KEY_ID = 'id';
public const KEY_STATE = 'state';
public const KEY_RESULT = 'result';
/**
* @var string Unique identifier for this operation.
*/
private string $id;
/**
* @var OperationStateEnum The current state of the operation.
*/
private OperationStateEnum $state;
/**
* @var GenerativeAiResult|null The result once the operation completes.
*/
private ?GenerativeAiResult $result;
/**
* Constructor.
*
* @since 0.1.0
*
* @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)
{
$this->id = $id;
$this->state = $state;
$this->result = $result;
}
/**
* Creates a deep clone of this operation.
*
* Clones the result object if present to ensure the cloned
* operation is independent of the original.
* The state enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone the result if present (GenerativeAiResult has __clone)
if ($this->result !== null) {
$this->result = clone $this->result;
}
// Note: $state is an immutable enum and can be safely shared
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getId(): string
{
return $this->id;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getState(): OperationStateEnum
{
return $this->state;
}
/**
* Gets the operation result.
*
* @since 0.1.0
*
* @return GenerativeAiResult|null The result or null if not yet complete.
*/
public function getResult(): ?GenerativeAiResult
{
return $this->result;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['oneOf' => [
// Succeeded state - has result
['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'const' => OperationStateEnum::succeeded()->value], self::KEY_RESULT => GenerativeAiResult::getJsonSchema()], 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], 'additionalProperties' => \false],
// All other states - no result
['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this operation.'], self::KEY_STATE => ['type' => 'string', 'enum' => [OperationStateEnum::starting()->value, OperationStateEnum::processing()->value, OperationStateEnum::failed()->value, OperationStateEnum::canceled()->value], 'description' => 'The current state of the operation.']], 'required' => [self::KEY_ID, self::KEY_STATE], 'additionalProperties' => \false],
]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return GenerativeAiOperationArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_ID => $this->id, self::KEY_STATE => $this->state->value];
if ($this->result !== null) {
$data[self::KEY_RESULT] = $this->result->toArray();
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_STATE]);
$state = OperationStateEnum::from($array[self::KEY_STATE]);
if ($state->isSucceeded()) {
// If the operation has succeeded, it must have a result
static::validateFromArrayData($array, [self::KEY_RESULT]);
}
$result = null;
if (isset($array[self::KEY_RESULT])) {
$result = GenerativeAiResult::fromArray($array[self::KEY_RESULT]);
}
return new self($array[self::KEY_ID], $state, $result);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Operations\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for operation states.
*
* @since 0.1.0
*
* @method static self starting() Creates an instance for STARTING state.
* @method static self processing() Creates an instance for PROCESSING state.
* @method static self succeeded() Creates an instance for SUCCEEDED state.
* @method static self failed() Creates an instance for FAILED state.
* @method static self canceled() Creates an instance for CANCELED state.
* @method bool isStarting() Checks if the state is STARTING.
* @method bool isProcessing() Checks if the state is PROCESSING.
* @method bool isSucceeded() Checks if the state is SUCCEEDED.
* @method bool isFailed() Checks if the state is FAILED.
* @method bool isCanceled() Checks if the state is CANCELED.
*/
class OperationStateEnum extends AbstractEnum
{
/**
* Operation is starting.
*/
public const STARTING = 'starting';
/**
* Operation is processing.
*/
public const PROCESSING = 'processing';
/**
* Operation succeeded.
*/
public const SUCCEEDED = 'succeeded';
/**
* Operation failed.
*/
public const FAILED = 'failed';
/**
* Operation was canceled.
*/
public const CANCELED = 'canceled';
}

View File

@@ -0,0 +1,120 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for a provider.
*
* @since 0.1.0
*/
abstract class AbstractProvider implements ProviderInterface
{
/**
* @var array<string, ProviderMetadata> Cache for provider metadata per class.
*/
private static array $metadataCache = [];
/**
* @var array<string, ProviderAvailabilityInterface> Cache for provider availability per class.
*/
private static array $availabilityCache = [];
/**
* @var array<string, ModelMetadataDirectoryInterface> Cache for model metadata directory per class.
*/
private static array $modelMetadataDirectoryCache = [];
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function metadata(): ProviderMetadata
{
$className = static::class;
if (!isset(self::$metadataCache[$className])) {
self::$metadataCache[$className] = static::createProviderMetadata();
}
return self::$metadataCache[$className];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
{
$providerMetadata = static::metadata();
$modelMetadata = static::modelMetadataDirectory()->getModelMetadata($modelId);
$model = static::createModel($modelMetadata, $providerMetadata);
if ($modelConfig) {
$model->setConfig($modelConfig);
}
return $model;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function availability(): ProviderAvailabilityInterface
{
$className = static::class;
if (!isset(self::$availabilityCache[$className])) {
self::$availabilityCache[$className] = static::createProviderAvailability();
}
return self::$availabilityCache[$className];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface
{
$className = static::class;
if (!isset(self::$modelMetadataDirectoryCache[$className])) {
self::$modelMetadataDirectoryCache[$className] = static::createModelMetadataDirectory();
}
return self::$modelMetadataDirectoryCache[$className];
}
/**
* Creates a model instance based on the given model metadata and provider metadata.
*
* @since 0.1.0
*
* @param ModelMetadata $modelMetadata The model metadata.
* @param ProviderMetadata $providerMetadata The provider metadata.
* @return ModelInterface The new model instance.
*/
abstract protected static function createModel(ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata): ModelInterface;
/**
* Creates the provider metadata instance.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
abstract protected static function createProviderMetadata(): ProviderMetadata;
/**
* Creates the provider availability instance.
*
* @since 0.1.0
*
* @return ProviderAvailabilityInterface The provider availability.
*/
abstract protected static function createProviderAvailability(): ProviderAvailabilityInterface;
/**
* Creates the model metadata directory instance.
*
* @since 0.1.0
*
* @return ModelMetadataDirectoryInterface The model metadata directory.
*/
abstract protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface;
}

View File

@@ -0,0 +1,111 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for an API-based model for a provider.
*
* While this class contains no abstract methods, it is still abstract to ensure that each model class can actually
* perform generative AI tasks by implementing the corresponding interfaces.
*
* @since 0.1.0
*/
abstract class AbstractApiBasedModel implements ApiBasedModelInterface, WithHttpTransporterInterface, WithRequestAuthenticationInterface
{
use WithHttpTransporterTrait;
use WithRequestAuthenticationTrait;
/**
* @var ModelMetadata The metadata for the model.
*/
private ModelMetadata $metadata;
/**
* @var ProviderMetadata The metadata for the model's provider.
*/
private ProviderMetadata $providerMetadata;
/**
* @var ModelConfig The configuration for the model.
*/
private ModelConfig $config;
/**
* @var RequestOptions|null The request options for HTTP transport.
*/
private ?RequestOptions $requestOptions = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelMetadata $metadata The metadata for the model.
* @param ProviderMetadata $providerMetadata The metadata for the model's provider.
*/
public function __construct(ModelMetadata $metadata, ProviderMetadata $providerMetadata)
{
$this->metadata = $metadata;
$this->providerMetadata = $providerMetadata;
$this->config = ModelConfig::fromArray([]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function metadata(): ModelMetadata
{
return $this->metadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function providerMetadata(): ProviderMetadata
{
return $this->providerMetadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function setConfig(ModelConfig $config): void
{
$this->config = $config;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function getConfig(): ModelConfig
{
return $this->config;
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
final public function setRequestOptions(RequestOptions $requestOptions): void
{
$this->requestOptions = $requestOptions;
}
/**
* {@inheritDoc}
*
* @since 0.3.0
*/
final public function getRequestOptions(): ?RequestOptions
{
return $this->requestOptions;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Common\Contracts\CachesDataInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Traits\WithDataCachingTrait;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for an API-based model metadata directory for a provider.
*
* @since 0.1.0
*/
abstract class AbstractApiBasedModelMetadataDirectory implements ModelMetadataDirectoryInterface, WithHttpTransporterInterface, WithRequestAuthenticationInterface, CachesDataInterface
{
use WithHttpTransporterTrait;
use WithRequestAuthenticationTrait;
use WithDataCachingTrait;
/**
* The cache key suffix for the models list.
*
* @since 0.4.0
*
* @var string
*/
private const MODELS_CACHE_KEY = 'models';
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function listModelMetadata(): array
{
$modelsMetadata = $this->getModelMetadataMap();
return array_values($modelsMetadata);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function hasModelMetadata(string $modelId): bool
{
$modelsMetadata = $this->getModelMetadataMap();
return isset($modelsMetadata[$modelId]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function getModelMetadata(string $modelId): ModelMetadata
{
$modelsMetadata = $this->getModelMetadataMap();
if (!isset($modelsMetadata[$modelId])) {
throw new InvalidArgumentException(sprintf('No model with ID %s was found in the provider', $modelId));
}
return $modelsMetadata[$modelId];
}
/**
* Returns the map of model ID to model metadata for all models from the provider.
*
* @since 0.1.0
*
* @return array<string, ModelMetadata> Map of model ID to model metadata.
*/
private function getModelMetadataMap(): array
{
/** @var array<string, ModelMetadata> */
return $this->cached(self::MODELS_CACHE_KEY, fn() => $this->sendListModelsRequest(), 86400);
}
/**
* {@inheritDoc}
*
* @since 0.4.0
*/
protected function getCachedKeys(): array
{
return [self::MODELS_CACHE_KEY];
}
/**
* {@inheritDoc}
*
* @since 0.4.0
*/
protected function getBaseCacheKey(): string
{
return 'ai_client_' . AiClient::VERSION . '_' . md5(static::class);
}
/**
* Sends the API request to list models from the provider and returns the map of model ID to model metadata.
*
* @since 0.1.0
*
* @return array<string, ModelMetadata> Map of model ID to model metadata.
*/
abstract protected function sendListModelsRequest(): array;
}

View File

@@ -0,0 +1,49 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use WordPress\AiClient\Providers\AbstractProvider;
/**
* Base class for API-based providers.
*
* This abstract class provides URL construction utilities for providers that
* communicate with REST APIs. It standardizes the pattern of combining a base
* URL with endpoint paths.
*
* @since 0.2.0
*/
abstract class AbstractApiProvider extends AbstractProvider
{
/**
* Gets the base URL for the provider's API.
*
* The base URL should include the protocol and domain, and may include
* the API version path (e.g., "https://api.example.com/v1").
*
* @since 0.2.0
*
* @return string The base URL for the provider's API.
*/
abstract protected static function baseUrl(): string;
/**
* Constructs a full URL by combining the base URL with an optional path.
*
* This method ensures proper URL construction by:
* - Using the provider's base URL
* - Trimming leading slashes from the path to prevent double-slashes
* - Joining the base URL and path with a single forward slash
*
* @since 0.2.0
*
* @param string $path Optional path to append to the base URL. Default empty string.
* @return string The complete URL.
*/
public static function url(string $path = ''): string
{
if ($path === '') {
return static::baseUrl();
}
return static::baseUrl() . '/' . ltrim($path, '/');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation\Contracts;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
/**
* Interface for API-based AI models that support HTTP transport configuration.
*
* This interface extends ModelInterface to add request options support
* for models that communicate with external APIs via HTTP.
*
* @since 0.3.0
*/
interface ApiBasedModelInterface extends ModelInterface
{
/**
* Sets the request options for HTTP transport.
*
* @since 0.3.0
*
* @param RequestOptions $requestOptions The request options to use.
* @return void
*/
public function setRequestOptions(RequestOptions $requestOptions): void;
/**
* Gets the request options for HTTP transport.
*
* @since 0.3.0
*
* @return RequestOptions|null The request options, or null if not set.
*/
public function getRequestOptions(): ?RequestOptions;
}

View File

@@ -0,0 +1,62 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use Exception;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
/**
* Class to check availability for an API-based provider via a test request to the endpoint to generate text.
*
* This class should be used for cloud-based providers that do not offer a model listing endpoint, but do offer a
* text generation endpoint which requires authentication. A minimal request to this endpoint is used to determine
* if the provider is properly configured with valid credentials.
*
* @since 0.1.0
*/
class GenerateTextApiBasedProviderAvailability implements ProviderAvailabilityInterface
{
/**
* @var ModelInterface&TextGenerationModelInterface The model to use for checking availability.
*/
private ModelInterface $model;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelInterface $model The model to use for checking availability.
*/
public function __construct(ModelInterface $model)
{
if (!$model instanceof TextGenerationModelInterface) {
throw new Exception('The model class to check provider availability must implement TextGenerationModelInterface.');
}
$this->model = $model;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function isConfigured(): bool
{
// Set config to use as few resources as possible for the test.
$modelConfig = ModelConfig::fromArray([ModelConfig::KEY_MAX_TOKENS => 1]);
$this->model->setConfig($modelConfig);
try {
// Attempt to generate text to check if the provider is available.
$this->model->generateTextResult([new Message(MessageRoleEnum::user(), [new MessagePart('a')])]);
return \true;
} catch (Exception $e) {
// If an exception occurs, the provider is not available.
return \false;
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\ApiBasedImplementation;
use Exception;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
/**
* Class to check availability for an API-based provider via a test request to the endpoint to list models.
*
* This class should be used for cloud-based providers that offer a model listing endpoint which requires
* authentication. A request to this endpoint is used to determine if the provider is properly configured
* with valid credentials.
*
* @since 0.1.0
*/
class ListModelsApiBasedProviderAvailability implements ProviderAvailabilityInterface
{
/**
* @var ModelMetadataDirectoryInterface The model metadata directory to use for checking availability.
*/
private ModelMetadataDirectoryInterface $modelMetadataDirectory;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ModelMetadataDirectoryInterface $modelMetadataDirectory The model metadata directory to use for checking
* availability.
*/
public function __construct(ModelMetadataDirectoryInterface $modelMetadataDirectory)
{
$this->modelMetadataDirectory = $modelMetadataDirectory;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function isConfigured(): bool
{
try {
// Attempt to list models to check if the provider is available.
$this->modelMetadataDirectory->listModelMetadata();
return \true;
} catch (Exception $e) {
// If an exception occurs, the provider is not available.
return \false;
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Interface for accessing model metadata within a provider.
*
* Provides methods to list, check, and retrieve model metadata
* for all models supported by a provider.
*
* @since 0.1.0
*/
interface ModelMetadataDirectoryInterface
{
/**
* Lists all available model metadata.
*
* @since 0.1.0
*
* @return list<ModelMetadata> Array of model metadata.
*/
public function listModelMetadata(): array;
/**
* Checks if metadata exists for a specific model.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @return bool True if metadata exists, false otherwise.
*/
public function hasModelMetadata(string $modelId): bool;
/**
* Gets metadata for a specific model.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @return ModelMetadata Model metadata.
* @throws InvalidArgumentException If model metadata not found.
*/
public function getModelMetadata(string $modelId): ModelMetadata;
}

View File

@@ -0,0 +1,24 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
/**
* Interface for checking provider availability.
*
* Determines whether a provider is configured and available
* for use based on API keys, credentials, or other requirements.
*
* @since 0.1.0
*/
interface ProviderAvailabilityInterface
{
/**
* Checks if the provider is configured.
*
* @since 0.1.0
*
* @return bool True if the provider is configured and available, false otherwise.
*/
public function isConfigured(): bool;
}

View File

@@ -0,0 +1,55 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
/**
* Interface for AI providers.
*
* Providers represent AI services (Google, OpenAI, Anthropic, etc.)
* and provide access to models, metadata, and availability information.
*
* @since 0.1.0
*/
interface ProviderInterface
{
/**
* Gets provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata Provider metadata.
*/
public static function metadata(): ProviderMetadata;
/**
* Creates a model instance.
*
* @since 0.1.0
*
* @param string $modelId Model identifier.
* @param ?ModelConfig $modelConfig Model configuration.
* @return ModelInterface Model instance.
* @throws InvalidArgumentException If model not found or configuration invalid.
*/
public static function model(string $modelId, ?ModelConfig $modelConfig = null): ModelInterface;
/**
* Gets provider availability checker.
*
* @since 0.1.0
*
* @return ProviderAvailabilityInterface Provider availability checker.
*/
public static function availability(): \WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
/**
* Gets model metadata directory.
*
* @since 0.1.0
*
* @return ModelMetadataDirectoryInterface Model metadata directory.
*/
public static function modelMetadataDirectory(): \WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
}

View File

@@ -0,0 +1,29 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Operations\Contracts\OperationInterface;
/**
* Interface for handling provider-level operations.
*
* Provides methods to retrieve and manage long-running operations
* across all models within a provider. Operations are tracked at the
* provider level rather than per-model.
*
* @since 0.1.0
*/
interface ProviderOperationsHandlerInterface
{
/**
* Gets an operation by ID.
*
* @since 0.1.0
*
* @param string $operationId Operation identifier.
* @return OperationInterface The operation.
* @throws InvalidArgumentException If operation not found.
*/
public function getOperation(string $operationId): OperationInterface;
}

View File

@@ -0,0 +1,24 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Contracts;
/**
* Interface for providers that support operations handlers.
*
* Providers implementing this interface can return an operations handler
* for managing long-running operations across all their models.
*
* @since 0.1.0
*/
interface ProviderWithOperationsHandlerInterface
{
/**
* Gets the operations handler for this provider.
*
* @since 0.1.0
*
* @return ProviderOperationsHandlerInterface The operations handler.
*/
public static function operationsHandler(): \WordPress\AiClient\Providers\Contracts\ProviderOperationsHandlerInterface;
}

View File

@@ -0,0 +1,215 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod;
/**
* Represents metadata about an AI provider.
*
* This class contains information about an AI provider, including its
* unique identifier, display name, and type (cloud, server, or client).
*
* @since 0.1.0
* @since 1.2.0 Added optional description property.
* @since 1.3.0 Added optional logoPath property.
*
* @phpstan-type ProviderMetadataArrayShape array{
* id: string,
* name: string,
* description?: ?string,
* type: string,
* credentialsUrl?: ?string,
* authenticationMethod?: ?string,
* logoPath?: ?string
* }
*
* @extends AbstractDataTransferObject<ProviderMetadataArrayShape>
*/
class ProviderMetadata extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_DESCRIPTION = 'description';
public const KEY_TYPE = 'type';
public const KEY_CREDENTIALS_URL = 'credentialsUrl';
public const KEY_AUTHENTICATION_METHOD = 'authenticationMethod';
public const KEY_LOGO_PATH = 'logoPath';
/**
* @var string The provider's unique identifier.
*/
protected string $id;
/**
* @var string The provider's display name.
*/
protected string $name;
/**
* @var string|null The provider's description.
*/
protected ?string $description;
/**
* @var ProviderTypeEnum The provider type.
*/
protected ProviderTypeEnum $type;
/**
* @var string|null The URL where users can get credentials.
*/
protected ?string $credentialsUrl;
/**
* @var RequestAuthenticationMethod|null The authentication method.
*/
protected ?RequestAuthenticationMethod $authenticationMethod;
/**
* @var string|null The full path to the provider's logo image file.
*/
protected ?string $logoPath;
/**
* Constructor.
*
* @since 0.1.0
* @since 1.2.0 Added optional $description parameter.
* @since 1.3.0 Added optional $logoPath parameter.
*
* @param string $id The provider's unique identifier.
* @param string $name The provider's display name.
* @param ProviderTypeEnum $type The provider type.
* @param string|null $credentialsUrl The URL where users can get credentials.
* @param RequestAuthenticationMethod|null $authenticationMethod The authentication method.
* @param string|null $description The provider's description.
* @param string|null $logoPath The full path to the provider's logo image file.
* @throws InvalidArgumentException If the provider ID contains invalid characters.
*/
public function __construct(string $id, string $name, ProviderTypeEnum $type, ?string $credentialsUrl = null, ?RequestAuthenticationMethod $authenticationMethod = null, ?string $description = null, ?string $logoPath = null)
{
if (!preg_match('/^[a-z0-9\-_]+$/', $id)) {
throw new InvalidArgumentException(sprintf(
// phpcs:ignore Generic.Files.LineLength.TooLong
'Invalid provider ID "%s". Only lowercase alphanumeric characters, hyphens, and underscores are allowed.',
$id
));
}
$this->id = $id;
$this->name = $name;
$this->description = $description;
$this->type = $type;
$this->credentialsUrl = $credentialsUrl;
$this->authenticationMethod = $authenticationMethod;
$this->logoPath = $logoPath;
}
/**
* Gets the provider's unique identifier.
*
* @since 0.1.0
*
* @return string The provider ID.
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the provider's display name.
*
* @since 0.1.0
*
* @return string The provider name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the provider's description.
*
* @since 1.2.0
*
* @return string|null The provider description.
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* Gets the provider type.
*
* @since 0.1.0
*
* @return ProviderTypeEnum The provider type.
*/
public function getType(): ProviderTypeEnum
{
return $this->type;
}
/**
* Gets the credentials URL.
*
* @since 0.1.0
*
* @return string|null The credentials URL.
*/
public function getCredentialsUrl(): ?string
{
return $this->credentialsUrl;
}
/**
* Gets the authentication method.
*
* @since 0.4.0
*
* @return RequestAuthenticationMethod|null The authentication method.
*/
public function getAuthenticationMethod(): ?RequestAuthenticationMethod
{
return $this->authenticationMethod;
}
/**
* Gets the full path to the provider's logo image file.
*
* @since 1.3.0
*
* @return string|null The full path to the logo image file.
*/
public function getLogoPath(): ?string
{
return $this->logoPath;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to schema.
* @since 1.3.0 Added logoPath to schema.
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The provider\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The provider\'s display name.'], self::KEY_DESCRIPTION => ['type' => 'string', 'description' => 'The provider\'s description.'], self::KEY_TYPE => ['type' => 'string', 'enum' => ProviderTypeEnum::getValues(), 'description' => 'The provider type (cloud, server, or client).'], self::KEY_CREDENTIALS_URL => ['type' => 'string', 'description' => 'The URL where users can get credentials.'], self::KEY_AUTHENTICATION_METHOD => ['type' => ['string', 'null'], 'enum' => array_merge(RequestAuthenticationMethod::getValues(), [null]), 'description' => 'The authentication method.'], self::KEY_LOGO_PATH => ['type' => 'string', 'description' => 'The full path to the provider\'s logo image file.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description to output.
* @since 1.3.0 Added logoPath to output.
*
* @return ProviderMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_DESCRIPTION => $this->description, self::KEY_TYPE => $this->type->value, self::KEY_CREDENTIALS_URL => $this->credentialsUrl, self::KEY_AUTHENTICATION_METHOD => $this->authenticationMethod ? $this->authenticationMethod->value : null, self::KEY_LOGO_PATH => $this->logoPath];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 1.2.0 Added description support.
* @since 1.3.0 Added logoPath support.
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_TYPE]);
return new self($array[self::KEY_ID], $array[self::KEY_NAME], ProviderTypeEnum::from($array[self::KEY_TYPE]), $array[self::KEY_CREDENTIALS_URL] ?? null, isset($array[self::KEY_AUTHENTICATION_METHOD]) ? RequestAuthenticationMethod::from($array[self::KEY_AUTHENTICATION_METHOD]) : null, $array[self::KEY_DESCRIPTION] ?? null, $array[self::KEY_LOGO_PATH] ?? null);
}
}

View File

@@ -0,0 +1,128 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Represents metadata about a provider and its available models.
*
* This class combines provider information with the models that
* the provider offers, facilitating model discovery and selection.
*
* @since 0.1.0
*
* @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata
* @phpstan-import-type ModelMetadataArrayShape from ModelMetadata
*
* @phpstan-type ProviderModelsMetadataArrayShape array{
* provider: ProviderMetadataArrayShape,
* models: list<ModelMetadataArrayShape>
* }
*
* @extends AbstractDataTransferObject<ProviderModelsMetadataArrayShape>
*/
class ProviderModelsMetadata extends AbstractDataTransferObject
{
public const KEY_PROVIDER = 'provider';
public const KEY_MODELS = 'models';
/**
* @var ProviderMetadata The provider metadata.
*/
protected \WordPress\AiClient\Providers\DTO\ProviderMetadata $provider;
/**
* @var list<ModelMetadata> The available models.
*/
protected array $models;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ProviderMetadata $provider The provider metadata.
* @param list<ModelMetadata> $models The available models.
*
* @throws InvalidArgumentException If models is not a list.
*/
public function __construct(\WordPress\AiClient\Providers\DTO\ProviderMetadata $provider, array $models)
{
if (!array_is_list($models)) {
throw new InvalidArgumentException('Models must be a list array.');
}
$this->provider = $provider;
$this->models = $models;
}
/**
* Creates a deep clone of this metadata.
*
* Clones the provider metadata and all model metadata objects
* to ensure the cloned instance is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone provider metadata
$this->provider = clone $this->provider;
// Deep clone models array (ModelMetadata has __clone)
$clonedModels = [];
foreach ($this->models as $model) {
$clonedModels[] = clone $model;
}
$this->models = $clonedModels;
}
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProvider(): \WordPress\AiClient\Providers\DTO\ProviderMetadata
{
return $this->provider;
}
/**
* Gets the available models.
*
* @since 0.1.0
*
* @return list<ModelMetadata> The available models.
*/
public function getModels(): array
{
return $this->models;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_PROVIDER => \WordPress\AiClient\Providers\DTO\ProviderMetadata::getJsonSchema(), self::KEY_MODELS => ['type' => 'array', 'items' => ModelMetadata::getJsonSchema(), 'description' => 'The available models for this provider.']], 'required' => [self::KEY_PROVIDER, self::KEY_MODELS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ProviderModelsMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_PROVIDER => $this->provider->toArray(), self::KEY_MODELS => array_map(static fn(ModelMetadata $model): array => $model->toArray(), $this->models)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_PROVIDER, self::KEY_MODELS]);
return new self(\WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray($array[self::KEY_PROVIDER]), array_map(static fn(array $modelData): ModelMetadata => ModelMetadata::fromArray($modelData), $array[self::KEY_MODELS]));
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for provider types.
*
* @since 0.1.0
*
* @method static self cloud() Creates an instance for CLOUD type.
* @method static self server() Creates an instance for SERVER type.
* @method static self client() Creates an instance for CLIENT type.
* @method bool isCloud() Checks if the type is CLOUD.
* @method bool isServer() Checks if the type is SERVER.
* @method bool isClient() Checks if the type is CLIENT.
*/
class ProviderTypeEnum extends AbstractEnum
{
/**
* Cloud-based AI provider (e.g. models available via external REST APIs).
*/
public const CLOUD = 'cloud';
/**
* Server-side AI provider (e.g. self-hosted models).
*/
public const SERVER = 'server';
/**
* Client-side AI provider (e.g. browser-based models).
*/
public const CLIENT = 'client';
}

View File

@@ -0,0 +1,27 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for tool types.
*
* @since 0.1.0
*
* @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS type.
* @method static self webSearch() Creates an instance for WEB_SEARCH type.
* @method bool isFunctionDeclarations() Checks if the type is FUNCTION_DECLARATIONS.
* @method bool isWebSearch() Checks if the type is WEB_SEARCH.
*/
class ToolTypeEnum extends AbstractEnum
{
/**
* Function declarations tool type.
*/
public const FUNCTION_DECLARATIONS = 'function_declarations';
/**
* Web search tool type.
*/
public const WEB_SEARCH = 'web_search';
}

View File

@@ -0,0 +1,72 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Abstracts;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Strategy\DiscoveryStrategy;
use WordPress\AiClientDependencies\Nyholm\Psr7\Factory\Psr17Factory;
use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
/**
* Abstract discovery strategy for HTTP client implementations.
*
* Provides a base for registering custom HTTP client implementations
* with HTTPlug's discovery mechanism. Subclasses must implement
* the createClient() method to provide their specific PSR-18
* HTTP client instance using the provided Psr17Factory.
*
* @since 1.1.0
*/
abstract class AbstractClientDiscoveryStrategy implements DiscoveryStrategy
{
/**
* Initializes and registers the discovery strategy.
*
* @since 1.1.0
*
* @return void
*/
public static function init(): void
{
if (!class_exists('WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery')) {
return;
}
Psr18ClientDiscovery::prependStrategy(static::class);
}
/**
* {@inheritDoc}
*
* @since 1.1.0
*
* @param string $type The type of discovery.
* @return array<array<string, mixed>> The discovery candidates.
*/
public static function getCandidates($type)
{
if (ClientInterface::class === $type) {
return [['class' => static function () {
$psr17Factory = new Psr17Factory();
return static::createClient($psr17Factory);
}]];
}
$psr17Factories = ['WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ResponseFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\ServerRequestFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UploadedFileFactoryInterface', 'WordPress\AiClientDependencies\Psr\Http\Message\UriFactoryInterface'];
if (in_array($type, $psr17Factories, \true)) {
return [['class' => Psr17Factory::class]];
}
return [];
}
/**
* Creates an instance of the HTTP client.
*
* Subclasses must implement this method to return their specific
* PSR-18 HTTP client instance. The provided Psr17Factory implements
* all PSR-17 interfaces (RequestFactory, ResponseFactory, StreamFactory,
* etc.) and can be used to satisfy client constructor dependencies.
*
* @since 1.1.0
*
* @param Psr17Factory $psr17Factory The PSR-17 factory for creating HTTP messages.
* @return ClientInterface The PSR-18 HTTP client.
*/
abstract protected static function createClient(Psr17Factory $psr17Factory): ClientInterface;
}

View File

@@ -0,0 +1,134 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Collections;
/**
* Simple collection for managing HTTP headers with case-insensitive access.
*
* This class stores HTTP headers while preserving their original casing
* and provides efficient case-insensitive lookups.
*
* @since 0.1.0
*/
class HeadersCollection
{
/**
* @var array<string, list<string>> The headers with original casing.
*/
private array $headers = [];
/**
* @var array<string, string> Map of lowercase header names to actual header names.
*/
private array $headersMap = [];
/**
* Constructor.
*
* @since 0.1.0
*
* @param array<string, string|list<string>> $headers Initial headers.
*/
public function __construct(array $headers = [])
{
foreach ($headers as $name => $value) {
$this->set($name, $value);
}
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function get(string $name): ?array
{
$lowerName = strtolower($name);
if (!isset($this->headersMap[$lowerName])) {
return null;
}
$actualName = $this->headersMap[$lowerName];
return $this->headers[$actualName];
}
/**
* Gets all headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> All headers with their original casing.
*/
public function getAll(): array
{
return $this->headers;
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string or null if not found.
*/
public function getAsString(string $name): ?string
{
$values = $this->get($name);
return $values !== null ? implode(', ', $values) : null;
}
/**
* Checks if a header exists.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return bool True if the header exists, false otherwise.
*/
public function has(string $name): bool
{
return isset($this->headersMap[strtolower($name)]);
}
/**
* Sets a header value, replacing any existing value.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return void
*/
private function set(string $name, $value): void
{
if (is_array($value)) {
$normalizedValues = array_values($value);
} else {
// Split comma-separated string into array
$normalizedValues = array_map('trim', explode(',', $value));
}
$lowerName = strtolower($name);
// If header exists with different casing, remove the old casing
if (isset($this->headersMap[$lowerName])) {
$oldName = $this->headersMap[$lowerName];
if ($oldName !== $name) {
unset($this->headers[$oldName]);
}
}
// Always use the new casing
$this->headers[$name] = $normalizedValues;
$this->headersMap[$lowerName] = $name;
}
/**
* Returns a new instance with the specified header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return self A new instance with the header.
*/
public function withHeader(string $name, $value): self
{
$new = clone $this;
$new->set($name, $value);
return $new;
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
/**
* Interface for HTTP clients that support per-request transport options.
*
* Extends the capabilities of PSR-18 clients by allowing custom transport
* configuration such as timeouts and redirect handling on each request.
*
* @since 0.2.0
*/
interface ClientWithOptionsInterface
{
/**
* Sends an HTTP request with the given transport options.
*
* @since 0.2.0
*
* @param RequestInterface $request The PSR-7 request to send.
* @param RequestOptions $options The request transport options. Must not be null.
* @return ResponseInterface The PSR-7 response received.
*/
public function sendRequestWithOptions(RequestInterface $request, RequestOptions $options): ResponseInterface;
}

View File

@@ -0,0 +1,29 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\DTO\Response;
/**
* Interface for HTTP transport implementations.
*
* Handles sending HTTP requests and receiving responses using
* PSR-7, PSR-17, and PSR-18 standards internally.
*
* @since 0.1.0
*/
interface HttpTransporterInterface
{
/**
* Sends an HTTP request and returns the response.
*
* @since 0.1.0
*
* @param Request $request The request to send.
* @param RequestOptions|null $options Optional transport options for the request.
* @return Response The response received.
*/
public function send(Request $request, ?RequestOptions $options = null): Response;
}

View File

@@ -0,0 +1,24 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
use WordPress\AiClient\Providers\Http\DTO\Request;
/**
* Interface for HTTP request authentication.
*
* @since 0.1.0
*/
interface RequestAuthenticationInterface extends WithJsonSchemaInterface
{
/**
* Authenticates an HTTP request.
*
* @since 0.1.0
*
* @param Request $request The request to authenticate.
* @return Request The authenticated request.
*/
public function authenticateRequest(Request $request): Request;
}

View File

@@ -0,0 +1,30 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
/**
* Interface for models that require HTTP transport capabilities.
*
* @since 0.1.0
*/
interface WithHttpTransporterInterface
{
/**
* Sets the HTTP transporter.
*
* @since 0.1.0
*
* @param HttpTransporterInterface $transporter The HTTP transporter instance.
* @return void
*/
public function setHttpTransporter(\WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface $transporter): void;
/**
* Returns the HTTP transporter.
*
* @since 0.1.0
*
* @return HttpTransporterInterface The HTTP transporter instance.
*/
public function getHttpTransporter(): \WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
}

View File

@@ -0,0 +1,30 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Contracts;
/**
* Interface for models that support request authentication.
*
* @since 0.1.0
*/
interface WithRequestAuthenticationInterface
{
/**
* Sets the request authentication.
*
* @since 0.1.0
*
* @param RequestAuthenticationInterface $authentication The authentication instance.
* @return void
*/
public function setRequestAuthentication(\WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface $authentication): void;
/**
* Returns the request authentication.
*
* @since 0.1.0
*
* @return RequestAuthenticationInterface The authentication instance.
*/
public function getRequestAuthentication(): \WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
}

View File

@@ -0,0 +1,92 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
/**
* Class for HTTP request authentication using an API key.
*
* @since 0.1.0
*
* @phpstan-type ApiKeyRequestAuthenticationArrayShape array{
* apiKey: string
* }
*
* @extends AbstractDataTransferObject<ApiKeyRequestAuthenticationArrayShape>
*/
class ApiKeyRequestAuthentication extends AbstractDataTransferObject implements RequestAuthenticationInterface
{
public const KEY_API_KEY = 'apiKey';
/**
* @var string The API key used for authentication.
*/
protected string $apiKey;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $apiKey The API key used for authentication.
*/
public function __construct(string $apiKey)
{
$this->apiKey = $apiKey;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function authenticateRequest(\WordPress\AiClient\Providers\Http\DTO\Request $request): \WordPress\AiClient\Providers\Http\DTO\Request
{
// Add the API key to the request headers.
return $request->withHeader('Authorization', 'Bearer ' . $this->apiKey);
}
/**
* Gets the API key.
*
* @since 0.1.0
*
* @return string The API key.
*/
public function getApiKey(): string
{
return $this->apiKey;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @since 0.1.0
*
* @return ApiKeyRequestAuthenticationArrayShape
*/
public function toArray(): array
{
return [self::KEY_API_KEY => $this->apiKey];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_API_KEY]);
return new self($array[self::KEY_API_KEY]);
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_API_KEY => ['type' => 'string', 'title' => 'API Key', 'description' => 'The API key used for authentication.']], 'required' => [self::KEY_API_KEY]];
}
}

View File

@@ -0,0 +1,377 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use JsonException;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\Collections\HeadersCollection;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
/**
* Represents an HTTP request.
*
* This class encapsulates HTTP request data that can be converted
* to PSR-7 requests by the HTTP transporter.
*
* @since 0.1.0
*
* @phpstan-import-type RequestOptionsArrayShape from RequestOptions
* @phpstan-type RequestArrayShape array{
* method: string,
* uri: string,
* headers: array<string, list<string>>,
* body?: string|null,
* options?: RequestOptionsArrayShape
* }
*
* @extends AbstractDataTransferObject<RequestArrayShape>
*/
class Request extends AbstractDataTransferObject
{
public const KEY_METHOD = 'method';
public const KEY_URI = 'uri';
public const KEY_HEADERS = 'headers';
public const KEY_BODY = 'body';
public const KEY_OPTIONS = 'options';
/**
* @var HttpMethodEnum The HTTP method.
*/
protected HttpMethodEnum $method;
/**
* @var string The request URI.
*/
protected string $uri;
/**
* @var HeadersCollection The request headers.
*/
protected HeadersCollection $headers;
/**
* @var array<string, mixed>|null The request data (for query params or form data).
*/
protected ?array $data = null;
/**
* @var string|null The request body (raw string content).
*/
protected ?string $body = null;
/**
* @var RequestOptions|null Request transport options.
*/
protected ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null;
/**
* Constructor.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $uri The request URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @param RequestOptions|null $options The request transport options.
*
* @throws InvalidArgumentException If the URI is empty.
*/
public function __construct(HttpMethodEnum $method, string $uri, array $headers = [], $data = null, ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options = null)
{
if (empty($uri)) {
throw new InvalidArgumentException('URI cannot be empty.');
}
$this->method = $method;
$this->uri = $uri;
$this->headers = new HeadersCollection($headers);
// Separate data and body based on type
if (is_string($data)) {
$this->body = $data;
} elseif (is_array($data)) {
$this->data = $data;
}
$this->options = $options;
}
/**
* Creates a deep clone of this request.
*
* Clones the headers collection and request options to ensure
* the cloned request is independent of the original.
* The HTTP method enum is immutable and can be safely shared.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;
// Clone request options if present (contains only primitives)
if ($this->options !== null) {
$this->options = clone $this->options;
}
// Note: $method is an immutable enum and can be safely shared
}
/**
* Gets the HTTP method.
*
* @since 0.1.0
*
* @return HttpMethodEnum The HTTP method.
*/
public function getMethod(): HttpMethodEnum
{
return $this->method;
}
/**
* Gets the request URI.
*
* For GET requests with array data, appends the data as query parameters.
*
* @since 0.1.0
*
* @return string The URI.
*/
public function getUri(): string
{
// If GET request with data, append as query parameters
if ($this->method === HttpMethodEnum::GET() && $this->data !== null && !empty($this->data)) {
$separator = str_contains($this->uri, '?') ? '&' : '?';
return $this->uri . $separator . http_build_query($this->data);
}
return $this->uri;
}
/**
* Gets the request headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> The headers.
*/
public function getHeaders(): array
{
return $this->headers->getAll();
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function getHeader(string $name): ?array
{
return $this->headers->get($name);
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string, or null if not found.
*/
public function getHeaderAsString(string $name): ?string
{
return $this->headers->getAsString($name);
}
/**
* Checks if a header exists.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return bool True if the header exists, false otherwise.
*/
public function hasHeader(string $name): bool
{
return $this->headers->has($name);
}
/**
* Gets the request body.
*
* For GET requests, returns null.
* For POST/PUT/PATCH requests:
* - If body is set, returns it as-is
* - If data is set and Content-Type is JSON, returns JSON-encoded data
* - If data is set and Content-Type is form, returns URL-encoded data
*
* @since 0.1.0
*
* @return string|null The body.
* @throws JsonException If the data cannot be encoded to JSON.
*/
public function getBody(): ?string
{
// GET requests don't have a body
if (!$this->method->hasBody()) {
return null;
}
// If body is set, return it as-is
if ($this->body !== null) {
return $this->body;
}
// If data is set, encode based on content type
if ($this->data !== null) {
$contentType = $this->getContentType();
// JSON encoding
if ($contentType !== null && stripos($contentType, 'application/json') !== \false) {
return json_encode($this->data, \JSON_THROW_ON_ERROR);
}
// Default to URL encoding for forms
return http_build_query($this->data);
}
return null;
}
/**
* Gets the Content-Type header value.
*
* @since 0.1.0
*
* @return string|null The Content-Type header value or null if not set.
*/
private function getContentType(): ?string
{
$values = $this->getHeader('Content-Type');
return $values !== null ? $values[0] : null;
}
/**
* Returns a new instance with the specified header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @param string|list<string> $value The header value(s).
* @return self A new instance with the header.
*/
public function withHeader(string $name, $value): self
{
$newHeaders = $this->headers->withHeader($name, $value);
$new = clone $this;
$new->headers = $newHeaders;
return $new;
}
/**
* Returns a new instance with the specified data.
*
* @since 0.1.0
*
* @param string|array<string, mixed> $data The request data.
* @return self A new instance with the data.
*/
public function withData($data): self
{
$new = clone $this;
if (is_string($data)) {
$new->body = $data;
$new->data = null;
} elseif (is_array($data)) {
$new->data = $data;
$new->body = null;
} else {
$new->data = null;
$new->body = null;
}
return $new;
}
/**
* Gets the request data array.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The request data array.
*/
public function getData(): ?array
{
return $this->data;
}
/**
* Gets the request options.
*
* @since 0.2.0
*
* @return RequestOptions|null Request transport options when configured.
*/
public function getOptions(): ?\WordPress\AiClient\Providers\Http\DTO\RequestOptions
{
return $this->options;
}
/**
* Returns a new instance with the specified request options.
*
* @since 0.2.0
*
* @param RequestOptions|null $options The request options to apply.
* @return self A new instance with the options.
*/
public function withOptions(?\WordPress\AiClient\Providers\Http\DTO\RequestOptions $options): self
{
$new = clone $this;
$new->options = $options;
return $new;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_METHOD => ['type' => 'string', 'description' => 'The HTTP method.'], self::KEY_URI => ['type' => 'string', 'description' => 'The request URI.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The request headers.'], self::KEY_BODY => ['type' => ['string'], 'description' => 'The request body.'], self::KEY_OPTIONS => \WordPress\AiClient\Providers\Http\DTO\RequestOptions::getJsonSchema()], 'required' => [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return RequestArrayShape
*/
public function toArray(): array
{
$array = [
self::KEY_METHOD => $this->method->value,
self::KEY_URI => $this->getUri(),
// Include query params if GET with data
self::KEY_HEADERS => $this->headers->getAll(),
];
// Include body if present (getBody() handles the conversion)
$body = $this->getBody();
if ($body !== null) {
$array[self::KEY_BODY] = $body;
}
if ($this->options !== null) {
$optionsArray = $this->options->toArray();
if (!empty($optionsArray)) {
$array[self::KEY_OPTIONS] = $optionsArray;
}
}
return $array;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_METHOD, self::KEY_URI, self::KEY_HEADERS]);
return new self(HttpMethodEnum::from($array[self::KEY_METHOD]), $array[self::KEY_URI], $array[self::KEY_HEADERS] ?? [], $array[self::KEY_BODY] ?? null, isset($array[self::KEY_OPTIONS]) ? \WordPress\AiClient\Providers\Http\DTO\RequestOptions::fromArray($array[self::KEY_OPTIONS]) : null);
}
/**
* Creates a Request instance from a PSR-7 RequestInterface.
*
* @since 0.2.0
*
* @param RequestInterface $psrRequest The PSR-7 request to convert.
* @return self A new Request instance.
* @throws InvalidArgumentException If the HTTP method is not supported.
*/
public static function fromPsrRequest(RequestInterface $psrRequest): self
{
$method = HttpMethodEnum::from($psrRequest->getMethod());
$uri = (string) $psrRequest->getUri();
// Convert PSR-7 headers to array format expected by our constructor
/** @var array<string, list<string>> $headers */
$headers = $psrRequest->getHeaders();
// Get body content
$body = $psrRequest->getBody()->getContents();
$bodyOrData = !empty($body) ? $body : null;
return new self($method, $uri, $headers, $bodyOrData);
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* Represents optional HTTP transport configuration for a single request.
*
* Provides mutable setters for working with timeouts and redirect handling.
*
* @since 0.2.0
*
* @phpstan-type RequestOptionsArrayShape array{
* timeout?: float|null,
* connectTimeout?: float|null,
* maxRedirects?: int|null
* }
*
* @extends AbstractDataTransferObject<RequestOptionsArrayShape>
*/
class RequestOptions extends AbstractDataTransferObject
{
public const KEY_TIMEOUT = 'timeout';
public const KEY_CONNECT_TIMEOUT = 'connectTimeout';
public const KEY_MAX_REDIRECTS = 'maxRedirects';
/**
* @var float|null Maximum duration in seconds to wait for the full response.
*/
protected ?float $timeout = null;
/**
* @var float|null Maximum duration in seconds to wait for the initial connection.
*/
protected ?float $connectTimeout = null;
/**
* @var int|null Maximum number of redirects to follow. 0 disables redirects, null is unspecified.
*/
protected ?int $maxRedirects = null;
/**
* Sets the request timeout in seconds.
*
* @since 0.2.0
*
* @param float|null $timeout Timeout in seconds.
* @return void
*
* @throws InvalidArgumentException When timeout is negative.
*/
public function setTimeout(?float $timeout): void
{
$this->validateTimeout($timeout, self::KEY_TIMEOUT);
$this->timeout = $timeout;
}
/**
* Sets the connection timeout in seconds.
*
* @since 0.2.0
*
* @param float|null $timeout Connection timeout in seconds.
* @return void
*
* @throws InvalidArgumentException When timeout is negative.
*/
public function setConnectTimeout(?float $timeout): void
{
$this->validateTimeout($timeout, self::KEY_CONNECT_TIMEOUT);
$this->connectTimeout = $timeout;
}
/**
* Sets the maximum number of redirects to follow.
*
* Set to 0 to disable redirects, null for unspecified, or a positive integer
* to enable redirects with a maximum count.
*
* @since 0.2.0
*
* @param int|null $maxRedirects Maximum redirects to follow, or 0 to disable, or null for unspecified.
* @return void
*
* @throws InvalidArgumentException When redirect count is negative.
*/
public function setMaxRedirects(?int $maxRedirects): void
{
if ($maxRedirects !== null && $maxRedirects < 0) {
throw new InvalidArgumentException('Request option "maxRedirects" must be greater than or equal to 0.');
}
$this->maxRedirects = $maxRedirects;
}
/**
* Gets the request timeout in seconds.
*
* @since 0.2.0
*
* @return float|null Timeout in seconds.
*/
public function getTimeout(): ?float
{
return $this->timeout;
}
/**
* Gets the connection timeout in seconds.
*
* @since 0.2.0
*
* @return float|null Connection timeout in seconds.
*/
public function getConnectTimeout(): ?float
{
return $this->connectTimeout;
}
/**
* Checks whether redirects are allowed.
*
* @since 0.2.0
*
* @return bool|null True when redirects are allowed (maxRedirects > 0),
* false when disabled (maxRedirects = 0),
* null when unspecified (maxRedirects = null).
*/
public function allowsRedirects(): ?bool
{
if ($this->maxRedirects === null) {
return null;
}
return $this->maxRedirects > 0;
}
/**
* Gets the maximum number of redirects to follow.
*
* @since 0.2.0
*
* @return int|null Maximum redirects or null when not specified.
*/
public function getMaxRedirects(): ?int
{
return $this->maxRedirects;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*
* @return RequestOptionsArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->timeout !== null) {
$data[self::KEY_TIMEOUT] = $this->timeout;
}
if ($this->connectTimeout !== null) {
$data[self::KEY_CONNECT_TIMEOUT] = $this->connectTimeout;
}
if ($this->maxRedirects !== null) {
$data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*/
public static function fromArray(array $array): self
{
$instance = new self();
if (isset($array[self::KEY_TIMEOUT])) {
$instance->setTimeout((float) $array[self::KEY_TIMEOUT]);
}
if (isset($array[self::KEY_CONNECT_TIMEOUT])) {
$instance->setConnectTimeout((float) $array[self::KEY_CONNECT_TIMEOUT]);
}
if (isset($array[self::KEY_MAX_REDIRECTS])) {
$instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]);
}
return $instance;
}
/**
* {@inheritDoc}
*
* @since 0.2.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the full response.'], self::KEY_CONNECT_TIMEOUT => ['type' => ['number', 'null'], 'minimum' => 0, 'description' => 'Maximum duration in seconds to wait for the initial connection.'], self::KEY_MAX_REDIRECTS => ['type' => ['integer', 'null'], 'minimum' => 0, 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.']], 'additionalProperties' => \false];
}
/**
* Validates timeout values.
*
* @since 0.2.0
*
* @param float|null $value Timeout to validate.
* @param string $fieldName Field name for the error message.
*
* @throws InvalidArgumentException When timeout is negative.
*/
private function validateTimeout(?float $value, string $fieldName): void
{
if ($value !== null && $value < 0) {
throw new InvalidArgumentException(sprintf('Request option "%s" must be greater than or equal to 0.', $fieldName));
}
}
}

View File

@@ -0,0 +1,211 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\Collections\HeadersCollection;
/**
* Represents an HTTP response.
*
* This class encapsulates HTTP response data that has been converted
* from PSR-7 responses by the HTTP transporter.
*
* @since 0.1.0
*
* @phpstan-type ResponseArrayShape array{
* statusCode: int,
* headers: array<string, list<string>>,
* body?: string|null
* }
*
* @extends AbstractDataTransferObject<ResponseArrayShape>
*/
class Response extends AbstractDataTransferObject
{
public const KEY_STATUS_CODE = 'statusCode';
public const KEY_HEADERS = 'headers';
public const KEY_BODY = 'body';
/**
* @var int The HTTP status code.
*/
protected int $statusCode;
/**
* @var HeadersCollection The response headers.
*/
protected HeadersCollection $headers;
/**
* @var string|null The response body.
*/
protected ?string $body;
/**
* Constructor.
*
* @since 0.1.0
*
* @param int $statusCode The HTTP status code.
* @param array<string, string|list<string>> $headers The response headers.
* @param string|null $body The response body.
*
* @throws InvalidArgumentException If the status code is invalid.
*/
public function __construct(int $statusCode, array $headers, ?string $body = null)
{
if ($statusCode < 100 || $statusCode >= 600) {
throw new InvalidArgumentException('Invalid HTTP status code: ' . $statusCode);
}
$this->statusCode = $statusCode;
$this->headers = new HeadersCollection($headers);
$this->body = $body;
}
/**
* Creates a deep clone of this response.
*
* Clones the headers collection to ensure the cloned
* response is independent of the original.
*
* @since 0.4.2
*/
public function __clone()
{
// Clone headers collection
$this->headers = clone $this->headers;
}
/**
* Gets the HTTP status code.
*
* @since 0.1.0
*
* @return int The status code.
*/
public function getStatusCode(): int
{
return $this->statusCode;
}
/**
* Gets the response headers.
*
* @since 0.1.0
*
* @return array<string, list<string>> The headers.
*/
public function getHeaders(): array
{
return $this->headers->getAll();
}
/**
* Gets a specific header value.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return list<string>|null The header value(s) or null if not found.
*/
public function getHeader(string $name): ?array
{
return $this->headers->get($name);
}
/**
* Gets header values as a comma-separated string.
*
* @since 0.1.0
*
* @param string $name The header name (case-insensitive).
* @return string|null The header values as a comma-separated string or null if not found.
*/
public function getHeaderAsString(string $name): ?string
{
return $this->headers->getAsString($name);
}
/**
* Gets the response body.
*
* @since 0.1.0
*
* @return string|null The body.
*/
public function getBody(): ?string
{
return $this->body;
}
/**
* Checks if the response has a header.
*
* @since 0.1.0
*
* @param string $name The header name.
* @return bool True if the header exists, false otherwise.
*/
public function hasHeader(string $name): bool
{
return $this->headers->has($name);
}
/**
* Checks if the response indicates success.
*
* @since 0.1.0
*
* @return bool True if status code is 2xx, false otherwise.
*/
public function isSuccessful(): bool
{
return $this->statusCode >= 200 && $this->statusCode < 300;
}
/**
* Gets the response data as an array.
*
* Attempts to decode the body as JSON. Returns null if the body
* is empty or not valid JSON.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The decoded data or null.
*/
public function getData(): ?array
{
if ($this->body === null || $this->body === '') {
return null;
}
$data = json_decode($this->body, \true);
if (json_last_error() !== \JSON_ERROR_NONE) {
return null;
}
/** @var array<string, mixed>|null $data */
return is_array($data) ? $data : null;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_STATUS_CODE => ['type' => 'integer', 'minimum' => 100, 'maximum' => 599, 'description' => 'The HTTP status code.'], self::KEY_HEADERS => ['type' => 'object', 'additionalProperties' => ['type' => 'array', 'items' => ['type' => 'string']], 'description' => 'The response headers.'], self::KEY_BODY => ['type' => ['string', 'null'], 'description' => 'The response body.']], 'required' => [self::KEY_STATUS_CODE, self::KEY_HEADERS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ResponseArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_STATUS_CODE => $this->statusCode, self::KEY_HEADERS => $this->headers->getAll()];
if ($this->body !== null) {
$data[self::KEY_BODY] = $this->body;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_STATUS_CODE, self::KEY_HEADERS]);
return new self($array[self::KEY_STATUS_CODE], $array[self::KEY_HEADERS], $array[self::KEY_BODY] ?? null);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Represents HTTP request methods.
*
* @since 0.1.0
*
* @method static self GET()
* @method static self POST()
* @method static self PUT()
* @method static self PATCH()
* @method static self DELETE()
* @method static self HEAD()
* @method static self OPTIONS()
* @method static self CONNECT()
* @method static self TRACE()
*
* @method bool isGet()
* @method bool isPost()
* @method bool isPut()
* @method bool isPatch()
* @method bool isDelete()
* @method bool isHead()
* @method bool isOptions()
* @method bool isConnect()
* @method bool isTrace()
*/
final class HttpMethodEnum extends AbstractEnum
{
/**
* GET method for retrieving resources.
*
* @var string
*/
public const GET = 'GET';
/**
* POST method for creating resources.
*
* @var string
*/
public const POST = 'POST';
/**
* PUT method for updating/replacing resources.
*
* @var string
*/
public const PUT = 'PUT';
/**
* PATCH method for partially updating resources.
*
* @var string
*/
public const PATCH = 'PATCH';
/**
* DELETE method for removing resources.
*
* @var string
*/
public const DELETE = 'DELETE';
/**
* HEAD method for retrieving headers only.
*
* @var string
*/
public const HEAD = 'HEAD';
/**
* OPTIONS method for retrieving allowed methods.
*
* @var string
*/
public const OPTIONS = 'OPTIONS';
/**
* CONNECT method for establishing tunnel.
*
* @var string
*/
public const CONNECT = 'CONNECT';
/**
* TRACE method for diagnostic purposes.
*
* @var string
*/
public const TRACE = 'TRACE';
/**
* Checks if this method is idempotent.
*
* @since 0.1.0
*
* @return bool True if the method is idempotent, false otherwise.
*/
public function isIdempotent(): bool
{
return in_array($this->value, [self::GET, self::HEAD, self::OPTIONS, self::TRACE, self::PUT, self::DELETE], \true);
}
/**
* Checks if this method typically has a request body.
*
* @since 0.1.0
*
* @return bool True if the method typically has a body, false otherwise.
*/
public function hasBody(): bool
{
return in_array($this->value, [self::POST, self::PUT, self::PATCH], \true);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Enums;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
/**
* Enum for request authentication methods.
*
* @since 0.4.0
*
* @method static self apiKey() Creates an instance for API_KEY method.
* @method bool isApiKey() Checks if the method is API_KEY.
*/
class RequestAuthenticationMethod extends AbstractEnum
{
/**
* API key authentication.
*/
public const API_KEY = 'api_key';
/**
* Gets the implementation class for the authentication method.
*
* @since 0.4.0
*
* @return class-string<RequestAuthenticationInterface&WithArrayTransformationInterface> The implementation class.
*
* @phpstan-ignore missingType.generics
*/
public function getImplementationClass(): string
{
// At the moment, this is the only supported method.
// Once more methods are available, add conditionals here for each method.
return ApiKeyRequestAuthentication::class;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Util\ErrorMessageExtractor;
/**
* Exception thrown for 4xx HTTP client errors.
*
* This represents errors where the client request was malformed,
* unauthorized, forbidden, or otherwise invalid.
*
* @since 0.2.0
*/
class ClientException extends InvalidArgumentException
{
/**
* The request that failed.
*
* @var Request|null
*/
protected ?Request $request = null;
/**
* Returns the request that failed as our Request DTO.
*
* @since 0.2.0
*
* @return Request
* @throws \RuntimeException If no request is available
*/
public function getRequest(): Request
{
if ($this->request === null) {
throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
}
return $this->request;
}
/**
* Creates a ClientException from a client error response (4xx).
*
* This method extracts error details from common API response formats
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP response that failed.
* @return self
*/
public static function fromClientErrorResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden', 404 => 'Not Found', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Client error (%d): Request was rejected due to client-side issue', $statusCode);
}
// Extract error message from response data using centralized utility
$extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
if ($extractedError !== null) {
$errorMessage .= ' - ' . $extractedError;
}
return new self($errorMessage, $statusCode);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Request;
/**
* Exception thrown for network-related errors.
*
* This includes HTTP transport errors, connection failures,
* timeouts, and other network-related issues.
*
* @since 0.2.0
*/
class NetworkException extends RuntimeException
{
/**
* The request that failed.
*
* @var Request|null
*/
protected ?Request $request = null;
/**
* Returns the request that failed as our Request DTO.
*
* @since 0.2.0
*
* @return Request
* @throws \RuntimeException If no request is available
*/
public function getRequest(): Request
{
if ($this->request === null) {
throw new \RuntimeException('Request object not available. This exception was directly instantiated. ' . 'Use a factory method that provides request context.');
}
return $this->request;
}
/**
* Creates a NetworkException from a PSR-18 network exception.
*
* @since 0.2.0
*
* @param RequestInterface $psrRequest The PSR-7 request that failed.
* @param \Throwable $networkException The PSR-18 network exception.
* @return self
*/
public static function fromPsr18NetworkException(RequestInterface $psrRequest, \Throwable $networkException): self
{
$request = Request::fromPsrRequest($psrRequest);
$message = sprintf('Network error occurred while sending request to %s: %s', $request->getUri(), $networkException->getMessage());
$exception = new self($message, 0, $networkException);
$exception->request = $request;
return $exception;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Response;
/**
* Exception thrown for 3xx HTTP redirect responses.
*
* This represents cases where the server indicates that the request
* should be retried at a different location, but automatic redirect
* handling was not successful or not enabled.
*
* @since 0.2.0
*/
class RedirectException extends RuntimeException
{
/**
* Creates a RedirectException from a redirect response.
*
* This method extracts redirect information from the response headers
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP redirect response.
* @return self
*/
public static function fromRedirectResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [300 => 'Multiple Choices', 301 => 'Moved Permanently', 302 => 'Found', 303 => 'See Other', 304 => 'Not Modified', 307 => 'Temporary Redirect', 308 => 'Permanent Redirect'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Redirect error (%d): Request needs to be retried at a different location', $statusCode);
}
// Try to extract the redirect location from headers
$locationValues = $response->getHeader('Location');
if ($locationValues !== null && !empty($locationValues)) {
$location = $locationValues[0];
$errorMessage .= ' - Location: ' . $location;
}
return new self($errorMessage, $statusCode);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
/**
* Exception class for HTTP response errors.
*
* This is used when response data is unexpected or malformed,
* typically indicating that a provider changed in ways our code
* is not aware of or when parsing response data fails.
*
* @since 0.1.0
*/
class ResponseException extends RuntimeException
{
/**
* Creates a ResponseException for missing expected data.
*
* @since 0.2.0
*
* @param string $apiName The name of the API/provider.
* @param string $fieldName The field that was expected but missing.
* @return self
*/
public static function fromMissingData(string $apiName, string $fieldName): self
{
$message = sprintf('Unexpected %s API response: Missing the "%s" key.', $apiName, $fieldName);
return new self($message);
}
/**
* Creates a ResponseException from invalid data in an API response.
*
* @since 0.2.0
*
* @param string $apiName The name of the API service (e.g., 'OpenAI', 'Anthropic').
* @param string $fieldName The field that was invalid.
* @param string $message The specific error message describing the invalid data.
* @return self
*/
public static function fromInvalidData(string $apiName, string $fieldName, string $message): self
{
return new self(sprintf('Unexpected %s API response: Invalid "%s" key: %s', $apiName, $fieldName, $message));
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Exception;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Util\ErrorMessageExtractor;
/**
* Exception thrown for 5xx HTTP server errors.
*
* This represents errors where the server failed to fulfill
* a valid request due to internal server errors.
*
* @since 0.2.0
*/
class ServerException extends RuntimeException
{
/**
* Creates a ServerException from a server error response.
*
* This method extracts error details from common API response formats
* and creates an exception with a descriptive message and status code.
*
* @since 0.2.0
*
* @param Response $response The HTTP response that failed.
* @return self
*/
public static function fromServerErrorResponse(Response $response): self
{
$statusCode = $response->getStatusCode();
$statusTexts = [500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout', 507 => 'Insufficient Storage', 529 => 'Overloaded'];
if (isset($statusTexts[$statusCode])) {
$errorMessage = sprintf('%s (%d)', $statusTexts[$statusCode], $statusCode);
} else {
$errorMessage = sprintf('Server error (%d): Request was rejected due to server-side issue', $statusCode);
}
// Extract error message from response data using centralized utility
$extractedError = ErrorMessageExtractor::extractFromResponseData($response->getData());
if ($extractedError !== null) {
$errorMessage .= ' - ' . $extractedError;
}
return new self($errorMessage, $response->getStatusCode());
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http;
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClientDependencies\Psr\Http\Client\ClientInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestFactoryInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\RequestInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\ResponseInterface;
use WordPress\AiClientDependencies\Psr\Http\Message\StreamFactoryInterface;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\ClientWithOptionsInterface;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Exception\NetworkException;
/**
* HTTP transporter implementation using HTTPlug.
*
* This class handles the conversion between custom Request/Response
* objects and PSR-7 messages, using HTTPlug for client abstraction
* and PSR-17 factories for message creation.
*
* @since 0.1.0
*/
class HttpTransporter implements HttpTransporterInterface
{
/**
* @var RequestFactoryInterface PSR-17 request factory.
*/
private RequestFactoryInterface $requestFactory;
/**
* @var StreamFactoryInterface PSR-17 stream factory.
*/
private StreamFactoryInterface $streamFactory;
/**
* @var ClientInterface PSR-18 HTTP client.
*/
private ClientInterface $client;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ClientInterface|null $client PSR-18 HTTP client.
* @param RequestFactoryInterface|null $requestFactory PSR-17 request factory.
* @param StreamFactoryInterface|null $streamFactory PSR-17 stream factory.
*/
public function __construct(?ClientInterface $client = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?: Psr18ClientDiscovery::find();
$this->requestFactory = $requestFactory ?: Psr17FactoryDiscovery::findRequestFactory();
$this->streamFactory = $streamFactory ?: Psr17FactoryDiscovery::findStreamFactory();
}
/**
* {@inheritDoc}
*
* @since 0.1.0
* @since 0.2.0 Added optional RequestOptions parameter and ClientWithOptions support.
*/
public function send(Request $request, ?RequestOptions $options = null): Response
{
$psr7Request = $this->convertToPsr7Request($request);
// Merge request options with parameter options, with parameter options taking precedence
$mergedOptions = $this->mergeOptions($request->getOptions(), $options);
try {
$hasOptions = $mergedOptions !== null;
if ($hasOptions && $this->client instanceof ClientWithOptionsInterface) {
$psr7Response = $this->client->sendRequestWithOptions($psr7Request, $mergedOptions);
} elseif ($hasOptions && $this->isGuzzleClient($this->client)) {
$psr7Response = $this->sendWithGuzzle($psr7Request, $mergedOptions);
} else {
$psr7Response = $this->client->sendRequest($psr7Request);
}
} catch (\WordPress\AiClientDependencies\Psr\Http\Client\NetworkExceptionInterface $e) {
throw NetworkException::fromPsr18NetworkException($psr7Request, $e);
} catch (\WordPress\AiClientDependencies\Psr\Http\Client\ClientExceptionInterface $e) {
// Handle other PSR-18 client exceptions that are not network-related
throw new RuntimeException(sprintf('HTTP client error occurred while sending request to %s: %s', $request->getUri(), $e->getMessage()), 0, $e);
}
return $this->convertFromPsr7Response($psr7Response);
}
/**
* Merges request options with parameter options taking precedence.
*
* @since 0.2.0
*
* @param RequestOptions|null $requestOptions Options from the Request object.
* @param RequestOptions|null $parameterOptions Options passed as method parameter.
* @return RequestOptions|null Merged options, or null if both are null.
*/
private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $parameterOptions): ?RequestOptions
{
// If no options at all, return null
if ($requestOptions === null && $parameterOptions === null) {
return null;
}
// If only one set of options exists, return it
if ($requestOptions === null) {
return $parameterOptions;
}
if ($parameterOptions === null) {
return $requestOptions;
}
// Both exist, merge them with parameter options taking precedence
$merged = new RequestOptions();
// Start with request options (lower precedence)
if ($requestOptions->getTimeout() !== null) {
$merged->setTimeout($requestOptions->getTimeout());
}
if ($requestOptions->getConnectTimeout() !== null) {
$merged->setConnectTimeout($requestOptions->getConnectTimeout());
}
if ($requestOptions->getMaxRedirects() !== null) {
$merged->setMaxRedirects($requestOptions->getMaxRedirects());
}
// Override with parameter options (higher precedence)
if ($parameterOptions->getTimeout() !== null) {
$merged->setTimeout($parameterOptions->getTimeout());
}
if ($parameterOptions->getConnectTimeout() !== null) {
$merged->setConnectTimeout($parameterOptions->getConnectTimeout());
}
if ($parameterOptions->getMaxRedirects() !== null) {
$merged->setMaxRedirects($parameterOptions->getMaxRedirects());
}
return $merged;
}
/**
* Determines if the underlying client matches the Guzzle client shape.
*
* @since 0.2.0
*
* @param ClientInterface $client The HTTP client instance.
* @return bool True when the client exposes Guzzle's send signature.
*/
private function isGuzzleClient(ClientInterface $client): bool
{
$reflection = new \ReflectionObject($client);
if (!is_callable([$client, 'send'])) {
return \false;
}
if (!$reflection->hasMethod('send')) {
return \false;
}
$method = $reflection->getMethod('send');
if (!$method->isPublic() || $method->isStatic()) {
return \false;
}
$parameters = $method->getParameters();
if (count($parameters) < 2) {
return \false;
}
$firstParameter = $parameters[0]->getType();
if (!$firstParameter instanceof \ReflectionNamedType || $firstParameter->isBuiltin()) {
return \false;
}
if (!is_a($firstParameter->getName(), RequestInterface::class, \true)) {
return \false;
}
$secondParameter = $parameters[1];
$secondType = $secondParameter->getType();
if (!$secondType instanceof \ReflectionNamedType || $secondType->getName() !== 'array') {
return \false;
}
return \true;
}
/**
* Sends a request using a Guzzle-compatible client.
*
* @since 0.2.0
*
* @param RequestInterface $request The PSR-7 request to send.
* @param RequestOptions $options The request options.
* @return ResponseInterface The PSR-7 response received.
*/
private function sendWithGuzzle(RequestInterface $request, RequestOptions $options): ResponseInterface
{
$guzzleOptions = $this->buildGuzzleOptions($options);
/** @var callable $callable */
$callable = [$this->client, 'send'];
/** @var ResponseInterface $response */
$response = $callable($request, $guzzleOptions);
return $response;
}
/**
* Converts request options to a Guzzle-compatible options array.
*
* @since 0.2.0
*
* @param RequestOptions $options The request options.
* @return array<string, mixed> Guzzle-compatible options.
*/
private function buildGuzzleOptions(RequestOptions $options): array
{
$guzzleOptions = [];
$timeout = $options->getTimeout();
if ($timeout !== null) {
$guzzleOptions['timeout'] = $timeout;
}
$connectTimeout = $options->getConnectTimeout();
if ($connectTimeout !== null) {
$guzzleOptions['connect_timeout'] = $connectTimeout;
}
$allowRedirects = $options->allowsRedirects();
if ($allowRedirects !== null) {
if ($allowRedirects) {
$redirectOptions = [];
$maxRedirects = $options->getMaxRedirects();
if ($maxRedirects !== null) {
$redirectOptions['max'] = $maxRedirects;
}
$guzzleOptions['allow_redirects'] = !empty($redirectOptions) ? $redirectOptions : \true;
} else {
$guzzleOptions['allow_redirects'] = \false;
}
}
return $guzzleOptions;
}
/**
* Converts a custom Request to a PSR-7 request.
*
* @since 0.1.0
*
* @param Request $request The custom request.
* @return RequestInterface The PSR-7 request.
*/
private function convertToPsr7Request(Request $request): RequestInterface
{
$psr7Request = $this->requestFactory->createRequest($request->getMethod()->value, $request->getUri());
// Add headers
foreach ($request->getHeaders() as $name => $values) {
foreach ($values as $value) {
$psr7Request = $psr7Request->withAddedHeader($name, $value);
}
}
// Add body if present
$body = $request->getBody();
if ($body !== null) {
$stream = $this->streamFactory->createStream($body);
$psr7Request = $psr7Request->withBody($stream);
}
return $psr7Request;
}
/**
* Converts a PSR-7 response to a custom Response.
*
* @since 0.1.0
*
* @param ResponseInterface $psr7Response The PSR-7 response.
* @return Response The custom response.
*/
private function convertFromPsr7Response(ResponseInterface $psr7Response): Response
{
$body = (string) $psr7Response->getBody();
// PSR-7 always returns headers as arrays, but HeadersCollection handles this
return new Response(
$psr7Response->getStatusCode(),
$psr7Response->getHeaders(),
// @phpstan-ignore-line
$body === '' ? null : $body
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http;
use WordPress\AiClientDependencies\Http\Discovery\Psr17FactoryDiscovery;
use WordPress\AiClientDependencies\Http\Discovery\Psr18ClientDiscovery;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
/**
* Factory for creating HTTP transporters.
*
* Uses HTTPlug's Discovery component to automatically find
* available HTTP clients and factories.
*
* @since 0.1.0
*/
class HttpTransporterFactory
{
/**
* Creates an HTTP transporter.
*
* Uses HTTPlug Discovery to automatically find PSR-18 client
* and PSR-17 factories if not provided.
*
* @since 0.1.0
*
* @return HttpTransporterInterface The HTTP transporter.
*/
public static function createTransporter(): HttpTransporterInterface
{
return new \WordPress\AiClient\Providers\Http\HttpTransporter(Psr18ClientDiscovery::find(), Psr17FactoryDiscovery::findRequestFactory(), Psr17FactoryDiscovery::findStreamFactory());
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Traits;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
/**
* Trait for a class that implements WithHttpTransporterInterface.
*
* @since 0.1.0
*/
trait WithHttpTransporterTrait
{
/**
* @var HttpTransporterInterface|null The HTTP transporter instance.
*/
private ?HttpTransporterInterface $httpTransporter = null;
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void
{
$this->httpTransporter = $httpTransporter;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getHttpTransporter(): HttpTransporterInterface
{
if ($this->httpTransporter === null) {
throw new RuntimeException('HttpTransporterInterface instance not set. Make sure you use the AiClient class for all requests.');
}
return $this->httpTransporter;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Traits;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
/**
* Trait for a class that implements WithRequestAuthenticationInterface.
*
* @since 0.1.0
*/
trait WithRequestAuthenticationTrait
{
/**
* @var RequestAuthenticationInterface|null The request authentication instance.
*/
private ?RequestAuthenticationInterface $requestAuthentication = null;
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setRequestAuthentication(RequestAuthenticationInterface $requestAuthentication): void
{
$this->requestAuthentication = $requestAuthentication;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getRequestAuthentication(): RequestAuthenticationInterface
{
if ($this->requestAuthentication === null) {
throw new RuntimeException('RequestAuthenticationInterface instance not set. ' . 'Make sure you use the AiClient class for all requests.');
}
return $this->requestAuthentication;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Util;
/**
* Utility for extracting error messages from API response data.
*
* Centralizes the logic for parsing common API error response formats
* to avoid code duplication across exception classes.
*
* @since 0.2.0
* @since 0.4.0 Moved from Utilities namespace to Util namespace.
*/
class ErrorMessageExtractor
{
/**
* Extracts error message from API response data.
*
* Handles common error response formats:
* - { "error": { "message": "Error text" } }
* - { "error": "Error text" }
* - { "message": "Error text" }
*
* @since 0.2.0
*
* @param mixed $data The response data to extract error message from.
* @return string|null The extracted error message, or null if none found.
*/
public static function extractFromResponseData($data): ?string
{
if (!is_array($data)) {
return null;
}
// Handle [ { "error": { "message": "Error text" } } ]
if (isset($data[0]) && is_array($data[0]) && isset($data[0]['error']) && is_array($data[0]['error']) && isset($data[0]['error']['message']) && is_string($data[0]['error']['message'])) {
return $data[0]['error']['message'];
}
// Handle { "error": { "message": "Error text" } }
if (isset($data['error']) && is_array($data['error']) && isset($data['error']['message']) && is_string($data['error']['message'])) {
return $data['error']['message'];
}
// Handle { "error": "Error text" }
if (isset($data['error']) && is_string($data['error'])) {
return $data['error'];
}
// Handle { "message": "Error text" }
if (isset($data['message']) && is_string($data['message'])) {
return $data['message'];
}
return null;
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Http\Util;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Exception\ClientException;
use WordPress\AiClient\Providers\Http\Exception\RedirectException;
use WordPress\AiClient\Providers\Http\Exception\ServerException;
/**
* Class with static utility methods to process HTTP responses.
*
* @since 0.1.0
*/
class ResponseUtil
{
/**
* Throws an appropriate exception if the given response is not successful.
*
* This method checks the HTTP status code of the response and throws
* the appropriate exception type based on the status code range:
* - 3xx: RedirectException (redirect responses)
* - 4xx: ClientException (client errors)
* - 5xx: ServerException (server errors)
* - Other unsuccessful responses: RuntimeException (invalid status codes)
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws RedirectException If the response indicates a redirect (3xx).
* @throws ClientException If the response indicates a client error (4xx).
* @throws ServerException If the response indicates a server error (5xx).
* @throws \RuntimeException If the response has an invalid status code.
*/
public static function throwIfNotSuccessful(Response $response): void
{
if ($response->isSuccessful()) {
return;
}
$statusCode = $response->getStatusCode();
// 3xx Redirect Responses
if ($statusCode >= 300 && $statusCode < 400) {
throw RedirectException::fromRedirectResponse($response);
}
// 4xx Client Errors
if ($statusCode >= 400 && $statusCode < 500) {
throw ClientException::fromClientErrorResponse($response);
}
// 5xx Server Errors
if ($statusCode >= 500 && $statusCode < 600) {
throw ServerException::fromServerErrorResponse($response);
}
throw new \RuntimeException(sprintf('Response returned invalid status code: %s', $response->getStatusCode()));
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Contracts;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Interface for AI models.
*
* Models represent specific AI models from providers and define
* their capabilities, configuration, and execution methods.
*
* @since 0.1.0
*/
interface ModelInterface
{
/**
* Gets model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata Model metadata.
*/
public function metadata(): ModelMetadata;
/**
* Returns the metadata for the model's provider.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function providerMetadata(): ProviderMetadata;
/**
* Sets model configuration.
*
* @since 0.1.0
*
* @param ModelConfig $config Model configuration.
* @return void
*/
public function setConfig(ModelConfig $config): void;
/**
* Gets model configuration.
*
* @since 0.1.0
*
* @return ModelConfig Current model configuration.
*/
public function getConfig(): ModelConfig;
}

View File

@@ -0,0 +1,882 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Files\Enums\FileTypeEnum;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
use WordPress\AiClient\Tools\DTO\WebSearch;
/**
* Represents configuration for an AI model.
*
* This class allows configuring various parameters for model behavior,
* including output modalities, system instructions, generation parameters,
* and tool integrations.
*
* @since 0.1.0
*
* @phpstan-import-type FunctionDeclarationArrayShape from FunctionDeclaration
* @phpstan-import-type WebSearchArrayShape from WebSearch
*
* @phpstan-type ModelConfigArrayShape array{
* outputModalities?: list<string>,
* systemInstruction?: string,
* candidateCount?: int,
* maxTokens?: int,
* temperature?: float,
* topP?: float,
* topK?: int,
* stopSequences?: list<string>,
* presencePenalty?: float,
* frequencyPenalty?: float,
* logprobs?: bool,
* topLogprobs?: int,
* functionDeclarations?: list<FunctionDeclarationArrayShape>,
* webSearch?: WebSearchArrayShape,
* outputFileType?: string,
* outputMimeType?: string,
* outputSchema?: array<string, mixed>,
* outputMediaOrientation?: string,
* outputMediaAspectRatio?: string,
* outputSpeechVoice?: string,
* customOptions?: array<string, mixed>
* }
*
* @extends AbstractDataTransferObject<ModelConfigArrayShape>
*/
class ModelConfig extends AbstractDataTransferObject
{
public const KEY_OUTPUT_MODALITIES = 'outputModalities';
public const KEY_SYSTEM_INSTRUCTION = 'systemInstruction';
public const KEY_CANDIDATE_COUNT = 'candidateCount';
public const KEY_MAX_TOKENS = 'maxTokens';
public const KEY_TEMPERATURE = 'temperature';
public const KEY_TOP_P = 'topP';
public const KEY_TOP_K = 'topK';
public const KEY_STOP_SEQUENCES = 'stopSequences';
public const KEY_PRESENCE_PENALTY = 'presencePenalty';
public const KEY_FREQUENCY_PENALTY = 'frequencyPenalty';
public const KEY_LOGPROBS = 'logprobs';
public const KEY_TOP_LOGPROBS = 'topLogprobs';
public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations';
public const KEY_WEB_SEARCH = 'webSearch';
public const KEY_OUTPUT_FILE_TYPE = 'outputFileType';
public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType';
public const KEY_OUTPUT_SCHEMA = 'outputSchema';
public const KEY_OUTPUT_MEDIA_ORIENTATION = 'outputMediaOrientation';
public const KEY_OUTPUT_MEDIA_ASPECT_RATIO = 'outputMediaAspectRatio';
public const KEY_OUTPUT_SPEECH_VOICE = 'outputSpeechVoice';
public const KEY_CUSTOM_OPTIONS = 'customOptions';
/*
* Note: This key is not an actual model config key, but specified here for convenience.
* It is relevant for model discovery, to determine which models support which input modalities.
* The actual input modalities are part of the message sent to the model, not the model config.
*/
public const KEY_INPUT_MODALITIES = 'inputModalities';
/**
* @var list<ModalityEnum>|null Output modalities for the model.
*/
protected ?array $outputModalities = null;
/**
* @var string|null System instruction for the model.
*/
protected ?string $systemInstruction = null;
/**
* @var int|null Number of response candidates to generate.
*/
protected ?int $candidateCount = null;
/**
* @var int|null Maximum number of tokens to generate.
*/
protected ?int $maxTokens = null;
/**
* @var float|null Temperature for randomness (0.0 to 2.0).
*/
protected ?float $temperature = null;
/**
* @var float|null Top-p nucleus sampling parameter.
*/
protected ?float $topP = null;
/**
* @var int|null Top-k sampling parameter.
*/
protected ?int $topK = null;
/**
* @var list<string>|null Stop sequences.
*/
protected ?array $stopSequences = null;
/**
* @var float|null Presence penalty for reducing repetition.
*/
protected ?float $presencePenalty = null;
/**
* @var float|null Frequency penalty for reducing repetition.
*/
protected ?float $frequencyPenalty = null;
/**
* @var bool|null Whether to return log probabilities.
*/
protected ?bool $logprobs = null;
/**
* @var int|null Number of top log probabilities to return.
*/
protected ?int $topLogprobs = null;
/**
* @var list<FunctionDeclaration>|null Function declarations available to the model.
*/
protected ?array $functionDeclarations = null;
/**
* @var WebSearch|null Web search configuration for the model.
*/
protected ?WebSearch $webSearch = null;
/**
* @var FileTypeEnum|null Output file type.
*/
protected ?FileTypeEnum $outputFileType = null;
/**
* @var string|null Output MIME type.
*/
protected ?string $outputMimeType = null;
/**
* @var array<string, mixed>|null Output schema (JSON schema).
*/
protected ?array $outputSchema = null;
/**
* @var MediaOrientationEnum|null Output media orientation.
*/
protected ?MediaOrientationEnum $outputMediaOrientation = null;
/**
* @var string|null Output media aspect ratio (e.g. 3:2, 16:9).
*/
protected ?string $outputMediaAspectRatio = null;
/**
* @var string|null Output speech voice.
*/
protected ?string $outputSpeechVoice = null;
/**
* @var array<string, mixed> Custom provider-specific options.
*/
protected array $customOptions = [];
/**
* Creates a deep clone of this configuration.
*
* Clones nested objects (functionDeclarations, webSearch) to ensure
* the cloned configuration is independent of the original.
* Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
* are intentionally shared as they are immutable.
*
* @since 0.4.2
*/
public function __clone()
{
// Deep clone function declarations if set
if ($this->functionDeclarations !== null) {
$clonedDeclarations = [];
foreach ($this->functionDeclarations as $declaration) {
$clonedDeclarations[] = clone $declaration;
}
$this->functionDeclarations = $clonedDeclarations;
}
// Clone web search if set
if ($this->webSearch !== null) {
$this->webSearch = clone $this->webSearch;
}
// Note: Enum value objects (outputModalities, outputFileType, outputMediaOrientation)
// are immutable and can be safely shared.
}
/**
* Sets the output modalities.
*
* @since 0.1.0
*
* @param list<ModalityEnum> $outputModalities The output modalities.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setOutputModalities(array $outputModalities): void
{
if (!array_is_list($outputModalities)) {
throw new InvalidArgumentException('Output modalities must be a list array.');
}
$this->outputModalities = $outputModalities;
}
/**
* Gets the output modalities.
*
* @since 0.1.0
*
* @return list<ModalityEnum>|null The output modalities.
*/
public function getOutputModalities(): ?array
{
return $this->outputModalities;
}
/**
* Sets the system instruction.
*
* @since 0.1.0
*
* @param string $systemInstruction The system instruction.
*/
public function setSystemInstruction(string $systemInstruction): void
{
$this->systemInstruction = $systemInstruction;
}
/**
* Gets the system instruction.
*
* @since 0.1.0
*
* @return string|null The system instruction.
*/
public function getSystemInstruction(): ?string
{
return $this->systemInstruction;
}
/**
* Sets the candidate count.
*
* @since 0.1.0
*
* @param int $candidateCount The candidate count.
*/
public function setCandidateCount(int $candidateCount): void
{
$this->candidateCount = $candidateCount;
}
/**
* Gets the candidate count.
*
* @since 0.1.0
*
* @return int|null The candidate count.
*/
public function getCandidateCount(): ?int
{
return $this->candidateCount;
}
/**
* Sets the maximum tokens.
*
* @since 0.1.0
*
* @param int $maxTokens The maximum tokens.
*/
public function setMaxTokens(int $maxTokens): void
{
$this->maxTokens = $maxTokens;
}
/**
* Gets the maximum tokens.
*
* @since 0.1.0
*
* @return int|null The maximum tokens.
*/
public function getMaxTokens(): ?int
{
return $this->maxTokens;
}
/**
* Sets the temperature.
*
* @since 0.1.0
*
* @param float $temperature The temperature.
*/
public function setTemperature(float $temperature): void
{
$this->temperature = $temperature;
}
/**
* Gets the temperature.
*
* @since 0.1.0
*
* @return float|null The temperature.
*/
public function getTemperature(): ?float
{
return $this->temperature;
}
/**
* Sets the top-p parameter.
*
* @since 0.1.0
*
* @param float $topP The top-p parameter.
*/
public function setTopP(float $topP): void
{
$this->topP = $topP;
}
/**
* Gets the top-p parameter.
*
* @since 0.1.0
*
* @return float|null The top-p parameter.
*/
public function getTopP(): ?float
{
return $this->topP;
}
/**
* Sets the top-k parameter.
*
* @since 0.1.0
*
* @param int $topK The top-k parameter.
*/
public function setTopK(int $topK): void
{
$this->topK = $topK;
}
/**
* Gets the top-k parameter.
*
* @since 0.1.0
*
* @return int|null The top-k parameter.
*/
public function getTopK(): ?int
{
return $this->topK;
}
/**
* Sets the stop sequences.
*
* @since 0.1.0
*
* @param list<string> $stopSequences The stop sequences.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setStopSequences(array $stopSequences): void
{
if (!array_is_list($stopSequences)) {
throw new InvalidArgumentException('Stop sequences must be a list array.');
}
$this->stopSequences = $stopSequences;
}
/**
* Gets the stop sequences.
*
* @since 0.1.0
*
* @return list<string>|null The stop sequences.
*/
public function getStopSequences(): ?array
{
return $this->stopSequences;
}
/**
* Sets the presence penalty.
*
* @since 0.1.0
*
* @param float $presencePenalty The presence penalty.
*/
public function setPresencePenalty(float $presencePenalty): void
{
$this->presencePenalty = $presencePenalty;
}
/**
* Gets the presence penalty.
*
* @since 0.1.0
*
* @return float|null The presence penalty.
*/
public function getPresencePenalty(): ?float
{
return $this->presencePenalty;
}
/**
* Sets the frequency penalty.
*
* @since 0.1.0
*
* @param float $frequencyPenalty The frequency penalty.
*/
public function setFrequencyPenalty(float $frequencyPenalty): void
{
$this->frequencyPenalty = $frequencyPenalty;
}
/**
* Gets the frequency penalty.
*
* @since 0.1.0
*
* @return float|null The frequency penalty.
*/
public function getFrequencyPenalty(): ?float
{
return $this->frequencyPenalty;
}
/**
* Sets whether to return log probabilities.
*
* @since 0.1.0
*
* @param bool $logprobs Whether to return log probabilities.
*/
public function setLogprobs(bool $logprobs): void
{
$this->logprobs = $logprobs;
}
/**
* Gets whether to return log probabilities.
*
* @since 0.1.0
*
* @return bool|null Whether to return log probabilities.
*/
public function getLogprobs(): ?bool
{
return $this->logprobs;
}
/**
* Sets the number of top log probabilities to return.
*
* @since 0.1.0
*
* @param int $topLogprobs The number of top log probabilities.
*/
public function setTopLogprobs(int $topLogprobs): void
{
$this->topLogprobs = $topLogprobs;
}
/**
* Gets the number of top log probabilities to return.
*
* @since 0.1.0
*
* @return int|null The number of top log probabilities.
*/
public function getTopLogprobs(): ?int
{
return $this->topLogprobs;
}
/**
* Sets the function declarations.
*
* @since 0.1.0
*
* @param list<FunctionDeclaration> $functionDeclarations The function declarations.
*
* @throws InvalidArgumentException If the array is not a list.
*/
public function setFunctionDeclarations(array $functionDeclarations): void
{
if (!array_is_list($functionDeclarations)) {
throw new InvalidArgumentException('Function declarations must be a list array.');
}
$this->functionDeclarations = $functionDeclarations;
}
/**
* Gets the function declarations.
*
* @since 0.1.0
*
* @return list<FunctionDeclaration>|null The function declarations.
*/
public function getFunctionDeclarations(): ?array
{
return $this->functionDeclarations;
}
/**
* Sets the web search configuration.
*
* @since 0.1.0
*
* @param WebSearch $webSearch The web search configuration.
*/
public function setWebSearch(WebSearch $webSearch): void
{
$this->webSearch = $webSearch;
}
/**
* Gets the web search configuration.
*
* @since 0.1.0
*
* @return WebSearch|null The web search configuration.
*/
public function getWebSearch(): ?WebSearch
{
return $this->webSearch;
}
/**
* Sets the output file type.
*
* @since 0.1.0
*
* @param FileTypeEnum $outputFileType The output file type.
*/
public function setOutputFileType(FileTypeEnum $outputFileType): void
{
$this->outputFileType = $outputFileType;
}
/**
* Gets the output file type.
*
* @since 0.1.0
*
* @return FileTypeEnum|null The output file type.
*/
public function getOutputFileType(): ?FileTypeEnum
{
return $this->outputFileType;
}
/**
* Sets the output MIME type.
*
* @since 0.1.0
*
* @param string $outputMimeType The output MIME type.
*/
public function setOutputMimeType(string $outputMimeType): void
{
$this->outputMimeType = $outputMimeType;
}
/**
* Gets the output MIME type.
*
* @since 0.1.0
*
* @return string|null The output MIME type.
*/
public function getOutputMimeType(): ?string
{
return $this->outputMimeType;
}
/**
* Sets the output schema.
*
* When setting an output schema, this method automatically sets
* the output MIME type to "application/json" if not already set.
*
* @since 0.1.0
*
* @param array<string, mixed> $outputSchema The output schema (JSON schema).
*/
public function setOutputSchema(array $outputSchema): void
{
$this->outputSchema = $outputSchema;
// Automatically set outputMimeType to application/json when schema is provided
if ($this->outputMimeType === null) {
$this->outputMimeType = 'application/json';
}
}
/**
* Gets the output schema.
*
* @since 0.1.0
*
* @return array<string, mixed>|null The output schema.
*/
public function getOutputSchema(): ?array
{
return $this->outputSchema;
}
/**
* Sets the output media orientation.
*
* @since 0.1.0
*
* @param MediaOrientationEnum $outputMediaOrientation The output media orientation.
*/
public function setOutputMediaOrientation(MediaOrientationEnum $outputMediaOrientation): void
{
if ($this->outputMediaAspectRatio) {
$this->validateMediaOrientationAspectRatioCompatibility($outputMediaOrientation, $this->outputMediaAspectRatio);
}
$this->outputMediaOrientation = $outputMediaOrientation;
}
/**
* Gets the output media orientation.
*
* @since 0.1.0
*
* @return MediaOrientationEnum|null The output media orientation.
*/
public function getOutputMediaOrientation(): ?MediaOrientationEnum
{
return $this->outputMediaOrientation;
}
/**
* Sets the output media aspect ratio.
*
* If set, this supersedes the output media orientation, as it is a more specific configuration.
*
* @since 0.1.0
*
* @param string $outputMediaAspectRatio The output media aspect ratio (e.g. 3:2, 16:9).
*/
public function setOutputMediaAspectRatio(string $outputMediaAspectRatio): void
{
if (!preg_match('/^\d+:\d+$/', $outputMediaAspectRatio)) {
throw new InvalidArgumentException('Output media aspect ratio must be in the format "width:height" (e.g. 3:2, 16:9).');
}
if ($this->outputMediaOrientation) {
$this->validateMediaOrientationAspectRatioCompatibility($this->outputMediaOrientation, $outputMediaAspectRatio);
}
$this->outputMediaAspectRatio = $outputMediaAspectRatio;
}
/**
* Gets the output media aspect ratio.
*
* @since 0.1.0
*
* @return string|null The output media aspect ratio (e.g. 3:2, 16:9).
*/
public function getOutputMediaAspectRatio(): ?string
{
return $this->outputMediaAspectRatio;
}
/**
* Validates that the given media orientation and aspect ratio values do not conflict with each other.
*
* @since 0.4.0
*
* @param MediaOrientationEnum $orientation The desired media orientation.
* @param string $aspectRatio The desired media aspect ratio.
*/
protected function validateMediaOrientationAspectRatioCompatibility(MediaOrientationEnum $orientation, string $aspectRatio): void
{
$aspectRatioParts = explode(':', $aspectRatio);
if ($orientation->isSquare() && $aspectRatioParts[0] !== $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the square orientation.');
}
if ($orientation->isLandscape() && $aspectRatioParts[0] <= $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the landscape orientation.');
}
if ($orientation->isPortrait() && $aspectRatioParts[0] >= $aspectRatioParts[1]) {
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not compatible with the portrait orientation.');
}
}
/**
* Sets the output speech voice.
*
* @since 0.1.0
*
* @param string $outputSpeechVoice The output speech voice.
*/
public function setOutputSpeechVoice(string $outputSpeechVoice): void
{
$this->outputSpeechVoice = $outputSpeechVoice;
}
/**
* Gets the output speech voice.
*
* @since 0.1.0
*
* @return string|null The output speech voice.
*/
public function getOutputSpeechVoice(): ?string
{
return $this->outputSpeechVoice;
}
/**
* Sets a single custom option.
*
* @since 0.1.0
*
* @param string $key The option key.
* @param mixed $value The option value.
*/
public function setCustomOption(string $key, $value): void
{
$this->customOptions[$key] = $value;
}
/**
* Sets the custom options.
*
* @since 0.1.0
*
* @param array<string, mixed> $customOptions The custom options.
*/
public function setCustomOptions(array $customOptions): void
{
$this->customOptions = $customOptions;
}
/**
* Gets the custom options.
*
* @since 0.1.0
*
* @return array<string, mixed> The custom options.
*/
public function getCustomOptions(): array
{
return $this->customOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_OUTPUT_MODALITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ModalityEnum::getValues()], 'description' => 'Output modalities for the model.'], self::KEY_SYSTEM_INSTRUCTION => ['type' => 'string', 'description' => 'System instruction for the model.'], self::KEY_CANDIDATE_COUNT => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of response candidates to generate.'], self::KEY_MAX_TOKENS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Maximum number of tokens to generate.'], self::KEY_TEMPERATURE => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 2.0, 'description' => 'Temperature for randomness.'], self::KEY_TOP_P => ['type' => 'number', 'minimum' => 0.0, 'maximum' => 1.0, 'description' => 'Top-p nucleus sampling parameter.'], self::KEY_TOP_K => ['type' => 'integer', 'minimum' => 1, 'description' => 'Top-k sampling parameter.'], self::KEY_STOP_SEQUENCES => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'Stop sequences.'], self::KEY_PRESENCE_PENALTY => ['type' => 'number', 'description' => 'Presence penalty for reducing repetition.'], self::KEY_FREQUENCY_PENALTY => ['type' => 'number', 'description' => 'Frequency penalty for reducing repetition.'], self::KEY_LOGPROBS => ['type' => 'boolean', 'description' => 'Whether to return log probabilities.'], self::KEY_TOP_LOGPROBS => ['type' => 'integer', 'minimum' => 1, 'description' => 'Number of top log probabilities to return.'], self::KEY_FUNCTION_DECLARATIONS => ['type' => 'array', 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.'], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => ['type' => 'string', 'enum' => FileTypeEnum::getValues(), 'description' => 'Output file type.'], self::KEY_OUTPUT_MIME_TYPE => ['type' => 'string', 'description' => 'Output MIME type.'], self::KEY_OUTPUT_SCHEMA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Output schema (JSON schema).'], self::KEY_OUTPUT_MEDIA_ORIENTATION => ['type' => 'string', 'enum' => MediaOrientationEnum::getValues(), 'description' => 'Output media orientation.'], self::KEY_OUTPUT_MEDIA_ASPECT_RATIO => ['type' => 'string', 'pattern' => '^\d+:\d+$', 'description' => 'Output media aspect ratio.'], self::KEY_OUTPUT_SPEECH_VOICE => ['type' => 'string', 'description' => 'Output speech voice.'], self::KEY_CUSTOM_OPTIONS => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Custom provider-specific options.']], 'additionalProperties' => \false];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelConfigArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->outputModalities !== null) {
$data[self::KEY_OUTPUT_MODALITIES] = array_map(static function (ModalityEnum $modality): string {
return $modality->value;
}, $this->outputModalities);
}
if ($this->systemInstruction !== null) {
$data[self::KEY_SYSTEM_INSTRUCTION] = $this->systemInstruction;
}
if ($this->candidateCount !== null) {
$data[self::KEY_CANDIDATE_COUNT] = $this->candidateCount;
}
if ($this->maxTokens !== null) {
$data[self::KEY_MAX_TOKENS] = $this->maxTokens;
}
if ($this->temperature !== null) {
$data[self::KEY_TEMPERATURE] = $this->temperature;
}
if ($this->topP !== null) {
$data[self::KEY_TOP_P] = $this->topP;
}
if ($this->topK !== null) {
$data[self::KEY_TOP_K] = $this->topK;
}
if ($this->stopSequences !== null) {
$data[self::KEY_STOP_SEQUENCES] = $this->stopSequences;
}
if ($this->presencePenalty !== null) {
$data[self::KEY_PRESENCE_PENALTY] = $this->presencePenalty;
}
if ($this->frequencyPenalty !== null) {
$data[self::KEY_FREQUENCY_PENALTY] = $this->frequencyPenalty;
}
if ($this->logprobs !== null) {
$data[self::KEY_LOGPROBS] = $this->logprobs;
}
if ($this->topLogprobs !== null) {
$data[self::KEY_TOP_LOGPROBS] = $this->topLogprobs;
}
if ($this->functionDeclarations !== null) {
$data[self::KEY_FUNCTION_DECLARATIONS] = array_map(static function (FunctionDeclaration $functionDeclaration): array {
return $functionDeclaration->toArray();
}, $this->functionDeclarations);
}
if ($this->webSearch !== null) {
$data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray();
}
if ($this->outputFileType !== null) {
$data[self::KEY_OUTPUT_FILE_TYPE] = $this->outputFileType->value;
}
if ($this->outputMimeType !== null) {
$data[self::KEY_OUTPUT_MIME_TYPE] = $this->outputMimeType;
}
if ($this->outputSchema !== null) {
$data[self::KEY_OUTPUT_SCHEMA] = $this->outputSchema;
}
if ($this->outputMediaOrientation !== null) {
$data[self::KEY_OUTPUT_MEDIA_ORIENTATION] = $this->outputMediaOrientation->value;
}
if ($this->outputMediaAspectRatio !== null) {
$data[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO] = $this->outputMediaAspectRatio;
}
if ($this->outputSpeechVoice !== null) {
$data[self::KEY_OUTPUT_SPEECH_VOICE] = $this->outputSpeechVoice;
}
if (!empty($this->customOptions)) {
$data[self::KEY_CUSTOM_OPTIONS] = $this->customOptions;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
$config = new self();
if (isset($array[self::KEY_OUTPUT_MODALITIES])) {
$config->setOutputModalities(array_map(static fn(string $modality): ModalityEnum => ModalityEnum::from($modality), $array[self::KEY_OUTPUT_MODALITIES]));
}
if (isset($array[self::KEY_SYSTEM_INSTRUCTION])) {
$config->setSystemInstruction($array[self::KEY_SYSTEM_INSTRUCTION]);
}
if (isset($array[self::KEY_CANDIDATE_COUNT])) {
$config->setCandidateCount($array[self::KEY_CANDIDATE_COUNT]);
}
if (isset($array[self::KEY_MAX_TOKENS])) {
$config->setMaxTokens($array[self::KEY_MAX_TOKENS]);
}
if (isset($array[self::KEY_TEMPERATURE])) {
$config->setTemperature($array[self::KEY_TEMPERATURE]);
}
if (isset($array[self::KEY_TOP_P])) {
$config->setTopP($array[self::KEY_TOP_P]);
}
if (isset($array[self::KEY_TOP_K])) {
$config->setTopK($array[self::KEY_TOP_K]);
}
if (isset($array[self::KEY_STOP_SEQUENCES])) {
$config->setStopSequences($array[self::KEY_STOP_SEQUENCES]);
}
if (isset($array[self::KEY_PRESENCE_PENALTY])) {
$config->setPresencePenalty($array[self::KEY_PRESENCE_PENALTY]);
}
if (isset($array[self::KEY_FREQUENCY_PENALTY])) {
$config->setFrequencyPenalty($array[self::KEY_FREQUENCY_PENALTY]);
}
if (isset($array[self::KEY_LOGPROBS])) {
$config->setLogprobs($array[self::KEY_LOGPROBS]);
}
if (isset($array[self::KEY_TOP_LOGPROBS])) {
$config->setTopLogprobs($array[self::KEY_TOP_LOGPROBS]);
}
if (isset($array[self::KEY_FUNCTION_DECLARATIONS])) {
$config->setFunctionDeclarations(array_map(static function (array $functionDeclarationData): FunctionDeclaration {
return FunctionDeclaration::fromArray($functionDeclarationData);
}, $array[self::KEY_FUNCTION_DECLARATIONS]));
}
if (isset($array[self::KEY_WEB_SEARCH])) {
$config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH]));
}
if (isset($array[self::KEY_OUTPUT_FILE_TYPE])) {
$config->setOutputFileType(FileTypeEnum::from($array[self::KEY_OUTPUT_FILE_TYPE]));
}
if (isset($array[self::KEY_OUTPUT_MIME_TYPE])) {
$config->setOutputMimeType($array[self::KEY_OUTPUT_MIME_TYPE]);
}
if (isset($array[self::KEY_OUTPUT_SCHEMA])) {
$config->setOutputSchema($array[self::KEY_OUTPUT_SCHEMA]);
}
if (isset($array[self::KEY_OUTPUT_MEDIA_ORIENTATION])) {
$config->setOutputMediaOrientation(MediaOrientationEnum::from($array[self::KEY_OUTPUT_MEDIA_ORIENTATION]));
}
if (isset($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO])) {
$config->setOutputMediaAspectRatio($array[self::KEY_OUTPUT_MEDIA_ASPECT_RATIO]);
}
if (isset($array[self::KEY_OUTPUT_SPEECH_VOICE])) {
$config->setOutputSpeechVoice($array[self::KEY_OUTPUT_SPEECH_VOICE]);
}
if (isset($array[self::KEY_CUSTOM_OPTIONS])) {
$config->setCustomOptions($array[self::KEY_CUSTOM_OPTIONS]);
}
return $config;
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
/**
* Represents metadata about an AI model.
*
* This class contains information about a specific AI model, including
* its identifier, display name, supported capabilities, and configuration options.
*
* @since 0.1.0
*
* @phpstan-import-type SupportedOptionArrayShape from SupportedOption
*
* @phpstan-type ModelMetadataArrayShape array{
* id: string,
* name: string,
* supportedCapabilities: list<string>,
* supportedOptions: list<SupportedOptionArrayShape>
* }
*
* @extends AbstractDataTransferObject<ModelMetadataArrayShape>
*/
class ModelMetadata extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_SUPPORTED_CAPABILITIES = 'supportedCapabilities';
public const KEY_SUPPORTED_OPTIONS = 'supportedOptions';
/**
* @var string The model's unique identifier.
*/
protected string $id;
/**
* @var string The model's display name.
*/
protected string $name;
/**
* @var list<CapabilityEnum> The model's supported capabilities.
*/
protected array $supportedCapabilities;
/**
* @var list<SupportedOption> The model's supported configuration options.
*/
protected array $supportedOptions;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $id The model's unique identifier.
* @param string $name The model's display name.
* @param list<CapabilityEnum> $supportedCapabilities The model's supported capabilities.
* @param list<SupportedOption> $supportedOptions The model's supported configuration options.
*
* @throws InvalidArgumentException If arrays are not lists.
*/
public function __construct(string $id, string $name, array $supportedCapabilities, array $supportedOptions)
{
if (!array_is_list($supportedCapabilities)) {
throw new InvalidArgumentException('Supported capabilities must be a list array.');
}
if (!array_is_list($supportedOptions)) {
throw new InvalidArgumentException('Supported options must be a list array.');
}
$this->id = $id;
$this->name = $name;
$this->supportedCapabilities = $supportedCapabilities;
$this->supportedOptions = $supportedOptions;
}
/**
* Gets the model's unique identifier.
*
* @since 0.1.0
*
* @return string The model ID.
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the model's display name.
*
* @since 0.1.0
*
* @return string The model name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Gets the model's supported capabilities.
*
* @since 0.1.0
*
* @return list<CapabilityEnum> The supported capabilities.
*/
public function getSupportedCapabilities(): array
{
return $this->supportedCapabilities;
}
/**
* Gets the model's supported configuration options.
*
* @since 0.1.0
*
* @return list<SupportedOption> The supported options.
*/
public function getSupportedOptions(): array
{
return $this->supportedOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'The model\'s unique identifier.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The model\'s display name.'], self::KEY_SUPPORTED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The model\'s supported capabilities.'], self::KEY_SUPPORTED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::getJsonSchema(), 'description' => 'The model\'s supported configuration options.']], 'required' => [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelMetadataArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_NAME => $this->name, self::KEY_SUPPORTED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->supportedCapabilities), self::KEY_SUPPORTED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\SupportedOption $option): array => $option->toArray(), $this->supportedOptions)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_NAME, self::KEY_SUPPORTED_CAPABILITIES, self::KEY_SUPPORTED_OPTIONS]);
return new self($array[self::KEY_ID], $array[self::KEY_NAME], array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_SUPPORTED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\SupportedOption => \WordPress\AiClient\Providers\Models\DTO\SupportedOption::fromArray($optionData), $array[self::KEY_SUPPORTED_OPTIONS]));
}
/**
* Performs a deep clone of the model metadata.
*
* This method ensures that supported option objects are cloned to prevent
* modifications to the cloned metadata from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedOptions = [];
foreach ($this->supportedOptions as $option) {
$clonedOptions[] = clone $option;
}
$this->supportedOptions = $clonedOptions;
}
}

View File

@@ -0,0 +1,315 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents requirements that implementing code has for AI model selection.
*
* This class defines the capabilities and options that a model must support
* in order to be considered suitable for the implementing code's needs.
*
* @since 0.1.0
*
* @phpstan-import-type RequiredOptionArrayShape from RequiredOption
*
* @phpstan-type ModelRequirementsArrayShape array{
* requiredCapabilities: list<string>,
* requiredOptions: list<RequiredOptionArrayShape>
* }
*
* @extends AbstractDataTransferObject<ModelRequirementsArrayShape>
*/
class ModelRequirements extends AbstractDataTransferObject
{
public const KEY_REQUIRED_CAPABILITIES = 'requiredCapabilities';
public const KEY_REQUIRED_OPTIONS = 'requiredOptions';
/**
* @var list<CapabilityEnum> The capabilities that the model must support.
*/
protected array $requiredCapabilities;
/**
* @var list<RequiredOption> The options that the model must support with specific values.
*/
protected array $requiredOptions;
/**
* Constructor.
*
* @since 0.1.0
*
* @param list<CapabilityEnum> $requiredCapabilities The capabilities that the model must support.
* @param list<RequiredOption> $requiredOptions The options that the model must support with specific values.
*
* @throws InvalidArgumentException If arrays are not lists.
*/
public function __construct(array $requiredCapabilities, array $requiredOptions)
{
if (!array_is_list($requiredCapabilities)) {
throw new InvalidArgumentException('Required capabilities must be a list array.');
}
if (!array_is_list($requiredOptions)) {
throw new InvalidArgumentException('Required options must be a list array.');
}
$this->requiredCapabilities = $requiredCapabilities;
$this->requiredOptions = $requiredOptions;
}
/**
* Gets the capabilities that the model must support.
*
* @since 0.1.0
*
* @return list<CapabilityEnum> The required capabilities.
*/
public function getRequiredCapabilities(): array
{
return $this->requiredCapabilities;
}
/**
* Gets the options that the model must support with specific values.
*
* @since 0.1.0
*
* @return list<RequiredOption> The required options.
*/
public function getRequiredOptions(): array
{
return $this->requiredOptions;
}
/**
* Checks whether the given model metadata meets these requirements.
*
* @since 0.2.0
*
* @param ModelMetadata $metadata The model metadata to check against.
* @return bool True if the model meets all requirements, false otherwise.
*/
public function areMetBy(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata $metadata): bool
{
// Create lookup maps for better performance (instead of nested foreach loops)
$capabilitiesMap = [];
foreach ($metadata->getSupportedCapabilities() as $capability) {
$capabilitiesMap[$capability->value] = $capability;
}
$optionsMap = [];
foreach ($metadata->getSupportedOptions() as $option) {
$optionsMap[$option->getName()->value] = $option;
}
// Check if all required capabilities are supported using map lookup
foreach ($this->requiredCapabilities as $requiredCapability) {
if (!isset($capabilitiesMap[$requiredCapability->value])) {
return \false;
}
}
// Check if all required options are supported with the specified values
foreach ($this->requiredOptions as $requiredOption) {
// Use map lookup instead of linear search
if (!isset($optionsMap[$requiredOption->getName()->value])) {
return \false;
}
$supportedOption = $optionsMap[$requiredOption->getName()->value];
// Check if the required value is supported by this option
if (!$supportedOption->isSupportedValue($requiredOption->getValue())) {
return \false;
}
}
return \true;
}
/**
* Creates ModelRequirements from prompt data and model configuration.
*
* @since 0.2.0
*
* @param CapabilityEnum $capability The capability the model must support.
* @param list<Message> $messages The messages in the conversation.
* @param ModelConfig $modelConfig The model configuration.
* @return self The created requirements.
*/
public static function fromPromptData(CapabilityEnum $capability, array $messages, \WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): self
{
// Start with base capability
$capabilities = [$capability];
$inputModalities = [];
// Check if we have chat history (multiple messages)
if (count($messages) > 1) {
$capabilities[] = CapabilityEnum::chatHistory();
}
// Analyze all messages to determine required input modalities
$hasFunctionMessageParts = \false;
foreach ($messages as $message) {
foreach ($message->getParts() as $part) {
// Check for text input
if ($part->getType()->isText()) {
$inputModalities[] = ModalityEnum::text();
}
// Check for file inputs
if ($part->getType()->isFile()) {
$file = $part->getFile();
if ($file !== null) {
if ($file->isImage()) {
$inputModalities[] = ModalityEnum::image();
} elseif ($file->isAudio()) {
$inputModalities[] = ModalityEnum::audio();
} elseif ($file->isVideo()) {
$inputModalities[] = ModalityEnum::video();
} elseif ($file->isDocument() || $file->isText()) {
$inputModalities[] = ModalityEnum::document();
}
}
}
// Check for function calls/responses (these might require special capabilities)
if ($part->getType()->isFunctionCall() || $part->getType()->isFunctionResponse()) {
$hasFunctionMessageParts = \true;
}
}
}
// Convert ModelConfig to RequiredOptions
$requiredOptions = self::toRequiredOptions($modelConfig);
// Add additional options based on message analysis
if ($hasFunctionMessageParts) {
$requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true));
}
// Add input modalities if we have any inputs
if (!empty($inputModalities)) {
// Remove duplicates
$inputModalities = array_unique($inputModalities, \SORT_REGULAR);
$requiredOptions = self::includeInRequiredOptions($requiredOptions, new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::inputModalities(), array_values($inputModalities)));
}
// Step 6: Return new ModelRequirements
return new self($capabilities, $requiredOptions);
}
/**
* Converts ModelConfig to an array of RequiredOptions.
*
* @since 0.2.0
*
* @param ModelConfig $modelConfig The model configuration.
* @return list<RequiredOption> The required options.
*/
private static function toRequiredOptions(\WordPress\AiClient\Providers\Models\DTO\ModelConfig $modelConfig): array
{
$requiredOptions = [];
// Map properties that have corresponding OptionEnum values
if ($modelConfig->getOutputModalities() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputModalities(), $modelConfig->getOutputModalities());
}
if ($modelConfig->getSystemInstruction() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::systemInstruction(), $modelConfig->getSystemInstruction());
}
if ($modelConfig->getCandidateCount() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::candidateCount(), $modelConfig->getCandidateCount());
}
if ($modelConfig->getMaxTokens() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::maxTokens(), $modelConfig->getMaxTokens());
}
if ($modelConfig->getTemperature() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::temperature(), $modelConfig->getTemperature());
}
if ($modelConfig->getTopP() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topP(), $modelConfig->getTopP());
}
if ($modelConfig->getTopK() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topK(), $modelConfig->getTopK());
}
if ($modelConfig->getOutputMimeType() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMimeType(), $modelConfig->getOutputMimeType());
}
if ($modelConfig->getOutputSchema() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputSchema(), $modelConfig->getOutputSchema());
}
// Handle properties without OptionEnum values as custom options
if ($modelConfig->getStopSequences() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::stopSequences(), $modelConfig->getStopSequences());
}
if ($modelConfig->getPresencePenalty() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::presencePenalty(), $modelConfig->getPresencePenalty());
}
if ($modelConfig->getFrequencyPenalty() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::frequencyPenalty(), $modelConfig->getFrequencyPenalty());
}
if ($modelConfig->getLogprobs() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::logprobs(), $modelConfig->getLogprobs());
}
if ($modelConfig->getTopLogprobs() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::topLogprobs(), $modelConfig->getTopLogprobs());
}
if ($modelConfig->getFunctionDeclarations() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::functionDeclarations(), \true);
}
if ($modelConfig->getWebSearch() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::webSearch(), \true);
}
if ($modelConfig->getOutputFileType() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputFileType(), $modelConfig->getOutputFileType());
}
if ($modelConfig->getOutputMediaOrientation() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaOrientation(), $modelConfig->getOutputMediaOrientation());
}
if ($modelConfig->getOutputMediaAspectRatio() !== null) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::outputMediaAspectRatio(), $modelConfig->getOutputMediaAspectRatio());
}
// Add custom options as individual RequiredOptions
foreach ($modelConfig->getCustomOptions() as $key => $value) {
$requiredOptions[] = new \WordPress\AiClient\Providers\Models\DTO\RequiredOption(OptionEnum::customOptions(), [$key => $value]);
}
return $requiredOptions;
}
/**
* Includes a RequiredOption in the array, ensuring no duplicates based on option name.
*
* @since 0.2.0
*
* @param list<RequiredOption> $requiredOptions The existing required options.
* @param RequiredOption $newOption The new option to include.
* @return list<RequiredOption> The updated required options array.
*/
private static function includeInRequiredOptions(array $requiredOptions, \WordPress\AiClient\Providers\Models\DTO\RequiredOption $newOption): array
{
// Check if we already have this option name
foreach ($requiredOptions as $index => $existingOption) {
if ($existingOption->getName()->equals($newOption->getName())) {
// Replace existing option with new one
$requiredOptions[$index] = $newOption;
return $requiredOptions;
}
}
// Option not found, add it
$requiredOptions[] = $newOption;
return $requiredOptions;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_REQUIRED_CAPABILITIES => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => CapabilityEnum::getValues()], 'description' => 'The capabilities that the model must support.'], self::KEY_REQUIRED_OPTIONS => ['type' => 'array', 'items' => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::getJsonSchema(), 'description' => 'The options that the model must support with specific values.']], 'required' => [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return ModelRequirementsArrayShape
*/
public function toArray(): array
{
return [self::KEY_REQUIRED_CAPABILITIES => array_map(static fn(CapabilityEnum $capability): string => $capability->value, $this->requiredCapabilities), self::KEY_REQUIRED_OPTIONS => array_map(static fn(\WordPress\AiClient\Providers\Models\DTO\RequiredOption $option): array => $option->toArray(), $this->requiredOptions)];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_REQUIRED_CAPABILITIES, self::KEY_REQUIRED_OPTIONS]);
return new self(array_map(static fn(string $capability): CapabilityEnum => CapabilityEnum::from($capability), $array[self::KEY_REQUIRED_CAPABILITIES]), array_map(static fn(array $optionData): \WordPress\AiClient\Providers\Models\DTO\RequiredOption => \WordPress\AiClient\Providers\Models\DTO\RequiredOption::fromArray($optionData), $array[self::KEY_REQUIRED_OPTIONS]));
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents an option that the implementing code requires the model to support.
*
* This class defines an option that the model must support with a specific value
* for it to be considered suitable for the implementing code's requirements.
*
* @since 0.1.0
*
* @phpstan-type RequiredOptionArrayShape array{
* name: string,
* value: mixed
* }
*
* @extends AbstractDataTransferObject<RequiredOptionArrayShape>
*/
class RequiredOption extends AbstractDataTransferObject
{
public const KEY_NAME = 'name';
public const KEY_VALUE = 'value';
/**
* @var OptionEnum The option name.
*/
protected OptionEnum $name;
/**
* @var mixed The value that the model must support for this option.
*/
protected $value;
/**
* Constructor.
*
* @since 0.1.0
*
* @param OptionEnum $name The option name.
* @param mixed $value The value that the model must support for this option.
*/
public function __construct(OptionEnum $name, $value)
{
$this->name = $name;
$this->value = $value;
}
/**
* Gets the option name.
*
* @since 0.1.0
*
* @return OptionEnum The option name.
*/
public function getName(): OptionEnum
{
return $this->name;
}
/**
* Gets the value that the model must support for this option.
*
* @since 0.1.0
*
* @return mixed The value that the model must support.
*/
public function getValue()
{
return $this->value;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_VALUE => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']], 'description' => 'The value that the model must support for this option.']], 'required' => [self::KEY_NAME, self::KEY_VALUE]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return RequiredOptionArrayShape
*/
public function toArray(): array
{
return [self::KEY_NAME => $this->name->value, self::KEY_VALUE => $this->value];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_NAME, self::KEY_VALUE]);
return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_VALUE]);
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
/**
* Represents a supported configuration option for an AI model.
*
* This class defines an option that a model supports, including its name
* and the values that are valid for that option.
*
* @since 0.1.0
*
* @phpstan-type SupportedOptionArrayShape array{
* name: string,
* supportedValues?: list<mixed>
* }
*
* @extends AbstractDataTransferObject<SupportedOptionArrayShape>
*/
class SupportedOption extends AbstractDataTransferObject
{
public const KEY_NAME = 'name';
public const KEY_SUPPORTED_VALUES = 'supportedValues';
/**
* @var OptionEnum The option name.
*/
protected OptionEnum $name;
/**
* @var list<mixed>|null The supported values for this option.
*/
protected ?array $supportedValues;
/**
* Constructor.
*
* @since 0.1.0
*
* @param OptionEnum $name The option name.
* @param list<mixed>|null $supportedValues The supported values for this option, or null if any value is supported.
*
* @throws InvalidArgumentException If supportedValues is not null and not a list.
*/
public function __construct(OptionEnum $name, ?array $supportedValues = null)
{
if ($supportedValues !== null && !array_is_list($supportedValues)) {
throw new InvalidArgumentException('Supported values must be a list array.');
}
$this->name = $name;
$this->supportedValues = $supportedValues;
}
/**
* Gets the option name.
*
* @since 0.1.0
*
* @return OptionEnum The option name.
*/
public function getName(): OptionEnum
{
return $this->name;
}
/**
* Checks if a value is supported for this option.
*
* @since 0.1.0
*
* @param mixed $value The value to check.
* @return bool True if the value is supported, false otherwise.
*/
public function isSupportedValue($value): bool
{
// If supportedValues is null, any value is supported
if ($this->supportedValues === null) {
return \true;
}
// If the value is an array, consider it a set (i.e. order doesn't matter).
if (is_array($value)) {
$normalizedValue = self::normalizeArrayForComparison($value);
foreach ($this->supportedValues as $supportedValue) {
if (!is_array($supportedValue)) {
continue;
}
$normalizedSupported = self::normalizeArrayForComparison($supportedValue);
if ($normalizedValue === $normalizedSupported) {
return \true;
}
}
return \false;
}
$normalizedValue = self::normalizeValue($value);
foreach ($this->supportedValues as $supportedValue) {
if (self::normalizeValue($supportedValue) === $normalizedValue) {
return \true;
}
}
return \false;
}
/**
* Normalizes an AbstractEnum instance to its string value.
*
* This ensures comparisons work correctly even after deserialization
* (e.g. Redis/Memcached object cache), where AbstractEnum singletons
* are reconstructed as separate instances.
*
* @since 1.2.1
*
* @param mixed $value The value to normalize.
* @return mixed The normalized value.
*/
private static function normalizeValue($value)
{
if ($value instanceof AbstractEnum) {
return $value->value;
}
return $value;
}
/**
* Normalizes and sorts an array for comparison.
*
* Maps each element through normalizeValue() and sorts the result,
* ensuring consistent comparison regardless of element order or
* AbstractEnum instance identity.
*
* @since 1.2.1
*
* @param array<mixed> $items The array to normalize.
* @return array<mixed> The normalized, sorted array.
*/
private static function normalizeArrayForComparison(array $items): array
{
$normalized = array_map([self::class, 'normalizeValue'], $items);
sort($normalized);
return $normalized;
}
/**
* Gets the supported values for this option.
*
* @since 0.1.0
*
* @return list<mixed>|null The supported values, or null if any value is supported.
*/
public function getSupportedValues(): ?array
{
return $this->supportedValues;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_NAME => ['type' => 'string', 'enum' => OptionEnum::getValues(), 'description' => 'The option name.'], self::KEY_SUPPORTED_VALUES => ['type' => 'array', 'items' => ['oneOf' => [['type' => 'string'], ['type' => 'number'], ['type' => 'boolean'], ['type' => 'null'], ['type' => 'array'], ['type' => 'object']]], 'description' => 'The supported values for this option.']], 'required' => [self::KEY_NAME]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return SupportedOptionArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_NAME => $this->name->value];
if ($this->supportedValues !== null) {
/** @var list<mixed> $supportedValues */
$supportedValues = $this->supportedValues;
$data[self::KEY_SUPPORTED_VALUES] = $supportedValues;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_NAME]);
return new self(OptionEnum::from($array[self::KEY_NAME]), $array[self::KEY_SUPPORTED_VALUES] ?? null);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for model capabilities.
*
* @since 0.1.0
*
* @method static self textGeneration() Creates an instance for TEXT_GENERATION capability.
* @method static self imageGeneration() Creates an instance for IMAGE_GENERATION capability.
* @method static self textToSpeechConversion() Creates an instance for TEXT_TO_SPEECH_CONVERSION capability.
* @method static self speechGeneration() Creates an instance for SPEECH_GENERATION capability.
* @method static self musicGeneration() Creates an instance for MUSIC_GENERATION capability.
* @method static self videoGeneration() Creates an instance for VIDEO_GENERATION capability.
* @method static self embeddingGeneration() Creates an instance for EMBEDDING_GENERATION capability.
* @method static self chatHistory() Creates an instance for CHAT_HISTORY capability.
* @method bool isTextGeneration() Checks if the capability is TEXT_GENERATION.
* @method bool isImageGeneration() Checks if the capability is IMAGE_GENERATION.
* @method bool isTextToSpeechConversion() Checks if the capability is TEXT_TO_SPEECH_CONVERSION.
* @method bool isSpeechGeneration() Checks if the capability is SPEECH_GENERATION.
* @method bool isMusicGeneration() Checks if the capability is MUSIC_GENERATION.
* @method bool isVideoGeneration() Checks if the capability is VIDEO_GENERATION.
* @method bool isEmbeddingGeneration() Checks if the capability is EMBEDDING_GENERATION.
* @method bool isChatHistory() Checks if the capability is CHAT_HISTORY.
*/
class CapabilityEnum extends AbstractEnum
{
/**
* Text generation capability.
*/
public const TEXT_GENERATION = 'text_generation';
/**
* Image generation capability.
*/
public const IMAGE_GENERATION = 'image_generation';
/**
* Text to speech conversion capability.
*/
public const TEXT_TO_SPEECH_CONVERSION = 'text_to_speech_conversion';
/**
* Speech generation capability.
*/
public const SPEECH_GENERATION = 'speech_generation';
/**
* Music generation capability.
*/
public const MUSIC_GENERATION = 'music_generation';
/**
* Video generation capability.
*/
public const VIDEO_GENERATION = 'video_generation';
/**
* Embedding generation capability.
*/
public const EMBEDDING_GENERATION = 'embedding_generation';
/**
* Chat history support capability.
*/
public const CHAT_HISTORY = 'chat_history';
}

View File

@@ -0,0 +1,107 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\Enums;
use ReflectionClass;
use WordPress\AiClient\Common\AbstractEnum;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
/**
* Enum for model options.
*
* This enum dynamically includes all options from ModelConfig KEY_* constants
* in addition to the explicitly defined constants below.
*
* Explicitly defined option (not in ModelConfig):
* @method static self inputModalities() Creates an instance for INPUT_MODALITIES option.
* @method bool isInputModalities() Checks if the option is INPUT_MODALITIES.
*
* Dynamically loaded from ModelConfig KEY_* constants:
* @method static self candidateCount() Creates an instance for CANDIDATE_COUNT option.
* @method static self customOptions() Creates an instance for CUSTOM_OPTIONS option.
* @method static self frequencyPenalty() Creates an instance for FREQUENCY_PENALTY option.
* @method static self functionDeclarations() Creates an instance for FUNCTION_DECLARATIONS option.
* @method static self logprobs() Creates an instance for LOGPROBS option.
* @method static self maxTokens() Creates an instance for MAX_TOKENS option.
* @method static self outputFileType() Creates an instance for OUTPUT_FILE_TYPE option.
* @method static self outputMediaAspectRatio() Creates an instance for OUTPUT_MEDIA_ASPECT_RATIO option.
* @method static self outputMediaOrientation() Creates an instance for OUTPUT_MEDIA_ORIENTATION option.
* @method static self outputMimeType() Creates an instance for OUTPUT_MIME_TYPE option.
* @method static self outputModalities() Creates an instance for OUTPUT_MODALITIES option.
* @method static self outputSchema() Creates an instance for OUTPUT_SCHEMA option.
* @method static self outputSpeechVoice() Creates an instance for OUTPUT_SPEECH_VOICE option.
* @method static self presencePenalty() Creates an instance for PRESENCE_PENALTY option.
* @method static self stopSequences() Creates an instance for STOP_SEQUENCES option.
* @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option.
* @method static self temperature() Creates an instance for TEMPERATURE option.
* @method static self topK() Creates an instance for TOP_K option.
* @method static self topLogprobs() Creates an instance for TOP_LOGPROBS option.
* @method static self topP() Creates an instance for TOP_P option.
* @method static self webSearch() Creates an instance for WEB_SEARCH option.
* @method bool isCandidateCount() Checks if the option is CANDIDATE_COUNT.
* @method bool isCustomOptions() Checks if the option is CUSTOM_OPTIONS.
* @method bool isFrequencyPenalty() Checks if the option is FREQUENCY_PENALTY.
* @method bool isFunctionDeclarations() Checks if the option is FUNCTION_DECLARATIONS.
* @method bool isLogprobs() Checks if the option is LOGPROBS.
* @method bool isMaxTokens() Checks if the option is MAX_TOKENS.
* @method bool isOutputFileType() Checks if the option is OUTPUT_FILE_TYPE.
* @method bool isOutputMediaAspectRatio() Checks if the option is OUTPUT_MEDIA_ASPECT_RATIO.
* @method bool isOutputMediaOrientation() Checks if the option is OUTPUT_MEDIA_ORIENTATION.
* @method bool isOutputMimeType() Checks if the option is OUTPUT_MIME_TYPE.
* @method bool isOutputModalities() Checks if the option is OUTPUT_MODALITIES.
* @method bool isOutputSchema() Checks if the option is OUTPUT_SCHEMA.
* @method bool isOutputSpeechVoice() Checks if the option is OUTPUT_SPEECH_VOICE.
* @method bool isPresencePenalty() Checks if the option is PRESENCE_PENALTY.
* @method bool isStopSequences() Checks if the option is STOP_SEQUENCES.
* @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION.
* @method bool isTemperature() Checks if the option is TEMPERATURE.
* @method bool isTopK() Checks if the option is TOP_K.
* @method bool isTopLogprobs() Checks if the option is TOP_LOGPROBS.
* @method bool isTopP() Checks if the option is TOP_P.
* @method bool isWebSearch() Checks if the option is WEB_SEARCH.
*
* @since 0.1.0
*/
class OptionEnum extends AbstractEnum
{
/**
* Input modalities option.
*
* This constant is not in ModelConfig as it's derived from message content,
* not configured directly.
*/
public const INPUT_MODALITIES = 'input_modalities';
/**
* Determines the class enumerations by reflecting on class constants.
*
* Overrides the parent method to dynamically add constants from ModelConfig
* that are prefixed with KEY_. These are transformed to remove the KEY_ prefix
* and converted to snake_case values.
*
* @since 0.1.0
*
* @param class-string $className The fully qualified class name.
* @return array<string, string> The enum constants.
*/
protected static function determineClassEnumerations(string $className): array
{
// Start with the constants defined in this class using parent method
$constants = parent::determineClassEnumerations($className);
// Use reflection to get all constants from ModelConfig
$modelConfigReflection = new ReflectionClass(ModelConfig::class);
$modelConfigConstants = $modelConfigReflection->getConstants();
// Add ModelConfig constants that start with KEY_
foreach ($modelConfigConstants as $constantName => $constantValue) {
if (str_starts_with($constantName, 'KEY_')) {
// Remove KEY_ prefix to get the enum constant name
$enumConstantName = substr($constantName, 4);
// The value is the snake_case version stored in ModelConfig
// ModelConfig already stores these as snake_case strings
if (is_string($constantValue)) {
$constants[$enumConstantName] = $constantValue;
}
}
}
return $constants;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\ImageGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support image generation.
*
* Provides synchronous methods for generating images from text prompts.
*
* @since 0.1.0
*/
interface ImageGenerationModelInterface
{
/**
* Generates images from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the image generation prompt.
* @return GenerativeAiResult Result containing generated images.
*/
public function generateImageResult(array $prompt): GenerativeAiResult;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\ImageGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous image generation operations.
*
* Provides methods for initiating long-running image generation tasks.
*
* @since 0.1.0
*/
interface ImageGenerationOperationModelInterface
{
/**
* Creates an image generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the image generation prompt.
* @return GenerativeAiOperation The initiated image generation operation.
*/
public function generateImageOperation(array $prompt): GenerativeAiOperation;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support speech generation.
*
* Provides synchronous methods for generating speech from prompts.
*
* @since 0.1.0
*/
interface SpeechGenerationModelInterface
{
/**
* Generates speech from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the speech generation prompt.
* @return GenerativeAiResult Result containing generated speech audio.
*/
public function generateSpeechResult(array $prompt): GenerativeAiResult;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous speech generation operations.
*
* Provides methods for initiating long-running speech generation tasks.
*
* @since 0.1.0
*/
interface SpeechGenerationOperationModelInterface
{
/**
* Creates a speech generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the speech generation prompt.
* @return GenerativeAiOperation The initiated speech generation operation.
*/
public function generateSpeechOperation(array $prompt): GenerativeAiOperation;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support text generation.
*
* Provides synchronous and streaming methods for generating text from prompts.
*
* @since 0.1.0
*/
interface TextGenerationModelInterface
{
/**
* Generates text from a prompt.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text generation prompt.
* @return GenerativeAiResult Result containing generated text.
*/
public function generateTextResult(array $prompt): GenerativeAiResult;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous text generation operations.
*
* Provides methods for initiating long-running text generation tasks.
*
* @since 0.1.0
*/
interface TextGenerationOperationModelInterface
{
/**
* Creates a text generation operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text generation prompt.
* @return GenerativeAiOperation The initiated text generation operation.
*/
public function generateTextOperation(array $prompt): GenerativeAiOperation;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support text-to-speech conversion.
*
* Provides synchronous methods for converting text to speech audio.
*
* @since 0.1.0
*/
interface TextToSpeechConversionModelInterface
{
/**
* Converts text to speech.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text to convert to speech.
* @return GenerativeAiResult Result containing generated speech audio.
*/
public function convertTextToSpeechResult(array $prompt): GenerativeAiResult;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous text-to-speech conversion operations.
*
* Provides methods for initiating long-running text-to-speech conversion tasks.
*
* @since 0.1.0
*/
interface TextToSpeechConversionOperationModelInterface
{
/**
* Creates a text-to-speech conversion operation.
*
* @since 0.1.0
*
* @param list<Message> $prompt Array of messages containing the text to convert to speech.
* @return GenerativeAiOperation The initiated text-to-speech conversion operation.
*/
public function convertTextToSpeechOperation(array $prompt): GenerativeAiOperation;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\VideoGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
/**
* Interface for models that support video generation.
*
* Provides synchronous methods for generating videos from prompts.
*
* @since 1.3.0
*/
interface VideoGenerationModelInterface
{
/**
* Generates videos from a prompt.
*
* @since 1.3.0
*
* @param list<Message> $prompt Array of messages containing the video generation prompt.
* @return GenerativeAiResult Result containing generated videos.
*/
public function generateVideoResult(array $prompt): GenerativeAiResult;
}

View File

@@ -0,0 +1,26 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\Models\VideoGeneration\Contracts;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Operations\DTO\GenerativeAiOperation;
/**
* Interface for models that support asynchronous video generation operations.
*
* Provides methods for initiating long-running video generation tasks.
*
* @since 1.3.0
*/
interface VideoGenerationOperationModelInterface
{
/**
* Creates a video generation operation.
*
* @since 1.3.0
*
* @param list<Message> $prompt Array of messages containing the video generation prompt.
* @return GenerativeAiOperation The initiated video generation operation.
*/
public function generateVideoOperation(array $prompt): GenerativeAiOperation;
}

View File

@@ -0,0 +1,298 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Files\Enums\MediaOrientationEnum;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
/**
* Base class for an image generation model for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* API endpoint for image generation, including but not limited to Anthropic, Google, and other
* providers that have adopted OpenAI's image generation API specification as a standard interface.
*
* @since 0.1.0
*
* @phpstan-type ImageGenerationParams array{
* model: string,
* prompt: string,
* n?: int,
* response_format?: string,
* output_format?: string|null,
* size?: string,
* ...
* }
* @phpstan-type ChoiceData array{
* url?: string,
* b64_json?: string
* }
* @phpstan-type UsageData array{
* input_tokens?: int,
* output_tokens?: int,
* total_tokens?: int
* }
* @phpstan-type ResponseData array{
* id?: string,
* data?: list<ChoiceData>,
* usage?: UsageData
* }
*/
abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function generateImageResult(array $prompt): GenerativeAiResult
{
$httpTransporter = $this->getHttpTransporter();
$params = $this->prepareGenerateImageParams($prompt);
$request = $this->createRequest(HttpMethodEnum::POST(), 'images/generations', ['Content-Type' => 'application/json'], $params);
// Add authentication credentials to the request.
$request = $this->getRequestAuthentication()->authenticateRequest($request);
// Send and process the request.
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
return $this->parseResponseToGenerativeAiResult($response, isset($params['output_format']) && is_string($params['output_format']) ? "image/{$params['output_format']}" : 'image/png');
}
/**
* Prepares the given prompt and the model configuration into parameters for the API request.
*
* @since 0.1.0
*
* @param list<Message> $prompt The prompt to generate an image for. Either a single message or a list of messages
* from a chat. However as of today, OpenAI compatible image generation endpoints only
* support a single user message.
* @return ImageGenerationParams The parameters for the API request.
*/
protected function prepareGenerateImageParams(array $prompt): array
{
$config = $this->getConfig();
$params = ['model' => $this->metadata()->getId(), 'prompt' => $this->preparePromptParam($prompt)];
$candidateCount = $config->getCandidateCount();
if ($candidateCount !== null) {
$params['n'] = $candidateCount;
}
$outputFileType = $config->getOutputFileType();
if ($outputFileType !== null) {
$params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json';
} else {
// The 'response_format' parameter is required, so we default to 'b64_json' if not set.
$params['response_format'] = 'b64_json';
}
$outputMimeType = $config->getOutputMimeType();
if ($outputMimeType !== null) {
$params['output_format'] = preg_replace('/^image\//', '', $outputMimeType);
}
$outputMediaOrientation = $config->getOutputMediaOrientation();
$outputMediaAspectRatio = $config->getOutputMediaAspectRatio();
if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) {
$params['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio);
}
/*
* Any custom options are added to the parameters as well.
* This allows developers to pass other options that may be more niche or not yet supported by the SDK.
*/
$customOptions = $config->getCustomOptions();
foreach ($customOptions as $key => $value) {
if (isset($params[$key])) {
throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
}
$params[$key] = $value;
}
/** @var ImageGenerationParams $params */
return $params;
}
/**
* Prepares the prompt parameter for the API request.
*
* @since 0.1.0
*
* @param list<Message> $messages The messages to prepare. However as of today, OpenAI compatible image generation
* endpoints only support a single user message.
* @return string The prepared prompt parameter.
*/
protected function preparePromptParam(array $messages): string
{
if (count($messages) !== 1) {
throw new InvalidArgumentException('The API requires a single user message as prompt.');
}
$message = $messages[0];
if (!$message->getRole()->isUser()) {
throw new InvalidArgumentException('The API requires a user message as prompt.');
}
$text = null;
foreach ($message->getParts() as $part) {
$text = $part->getText();
if ($text !== null) {
break;
}
}
if ($text === null) {
throw new InvalidArgumentException('The API requires a single text message part as prompt.');
}
return $text;
}
/**
* Prepares the size parameter for the API request.
*
* @since 0.1.0
*
* @param MediaOrientationEnum|null $orientation The desired media orientation.
* @param string|null $aspectRatio The desired media aspect ratio.
* @return string The prepared size parameter.
*/
protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string
{
// Use aspect ratio if set, as it is more specific.
if ($aspectRatio !== null) {
switch ($aspectRatio) {
case '1:1':
return '1024x1024';
case '3:2':
return '1536x1024';
case '7:4':
return '1792x1024';
case '2:3':
return '1024x1536';
case '4:7':
return '1024x1792';
default:
throw new InvalidArgumentException('The aspect ratio "' . $aspectRatio . '" is not supported.');
}
}
// This should always have a value, as the method is only called if at least one or the other is set.
if ($orientation !== null) {
if ($orientation->isLandscape()) {
return '1536x1024';
}
if ($orientation->isPortrait()) {
return '1024x1536';
}
}
return '1024x1024';
}
/**
* Creates a request object for the provider's API.
*
* Implementations should use $this->getRequestOptions() to attach any
* configured request options to the Request.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to a generative AI result.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint.
* @param string $expectedMimeType The expected MIME type the response is in.
* @return GenerativeAiResult The parsed generative AI result.
*/
protected function parseResponseToGenerativeAiResult(Response $response, string $expectedMimeType = 'image/png'): GenerativeAiResult
{
/** @var ResponseData $responseData */
$responseData = $response->getData();
if (!isset($responseData['data']) || !$responseData['data']) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'data');
}
if (!is_array($responseData['data'])) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'data', 'The value must be an array.');
}
$candidates = [];
foreach ($responseData['data'] as $index => $choiceData) {
if (!is_array($choiceData) || array_is_list($choiceData)) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "data[{$index}]", 'The value must be an associative array.');
}
$candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index, $expectedMimeType);
}
$id = $this->getResultId($responseData);
if (isset($responseData['usage']) && is_array($responseData['usage'])) {
$usage = $responseData['usage'];
$tokenUsage = new TokenUsage($usage['input_tokens'] ?? 0, $usage['output_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
} else {
$tokenUsage = new TokenUsage(0, 0, 0);
}
// Use any other data from the response as provider-specific response metadata.
$providerMetadata = $responseData;
unset($providerMetadata['id'], $providerMetadata['data'], $providerMetadata['usage']);
return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $providerMetadata);
}
/**
* Parses a single choice from the API response into a Candidate object.
*
* @since 0.1.0
*
* @param ChoiceData $choiceData The choice data from the API response.
* @param int $index The index of the choice in the choices array.
* @param string $expectedMimeType The expected MIME type the response is in.
* @return Candidate The parsed candidate.
* @throws RuntimeException If the choice data is invalid.
*/
protected function parseResponseChoiceToCandidate(array $choiceData, int $index, string $expectedMimeType = 'image/png'): Candidate
{
if (isset($choiceData['url']) && is_string($choiceData['url'])) {
$imageFile = new File($choiceData['url'], $expectedMimeType);
} elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) {
$imageFile = new File($choiceData['b64_json'], $expectedMimeType);
} else {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must contain either a url or b64_json key with a string value.');
}
$parts = [new MessagePart($imageFile)];
$message = new Message(MessageRoleEnum::model(), $parts);
return new Candidate($message, FinishReasonEnum::stop());
}
/**
* Extracts the result ID from the API response data.
*
* @since 0.4.0
*
* @param array<string, mixed> $responseData The response data from the API.
* @return string The result ID.
*/
protected function getResultId(array $responseData): string
{
return isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModelMetadataDirectory;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
/**
* Base class for a model metadata directory for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* models listing endpoint, including but not limited to Anthropic, Google, and other
* providers that have adopted OpenAI's models API specification as a standard interface.
*
* @since 0.1.0
*/
abstract class AbstractOpenAiCompatibleModelMetadataDirectory extends AbstractApiBasedModelMetadataDirectory
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
protected function sendListModelsRequest(): array
{
$httpTransporter = $this->getHttpTransporter();
$request = $this->createRequest(HttpMethodEnum::GET(), 'models');
$request = $this->getRequestAuthentication()->authenticateRequest($request);
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
$modelsMetadataList = $this->parseResponseToModelMetadataList($response);
$modelMetadataMap = [];
foreach ($modelsMetadataList as $modelMetadata) {
$modelMetadataMap[$modelMetadata->getId()] = $modelMetadata;
}
return $modelMetadataMap;
}
/**
* Creates a request object for the provider's API.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to list models into a list of model metadata objects.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint to list models.
* @return list<ModelMetadata> List of model metadata objects.
*/
abstract protected function parseResponseToModelMetadataList(Response $response): array;
}

View File

@@ -0,0 +1,557 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
use WordPress\AiClient\Tools\DTO\FunctionCall;
use WordPress\AiClient\Tools\DTO\FunctionDeclaration;
/**
* Base class for a text generation model for providers that implement OpenAI's API format.
*
* This abstract class is designed to work with any AI provider that offers an OpenAI-compatible
* API endpoint, including but not limited to Anthropic, Google, and other providers
* that have adopted OpenAI's API specification as a standard interface.
*
* @since 0.1.0
*
* @phpstan-type ToolCallData array{
* type?: string,
* id?: string,
* function?: array{
* name?: string,
* arguments: string|array<string, mixed>
* }
* }
* @phpstan-type MessageData array{
* role?: string,
* reasoning_content?: string,
* content?: string,
* tool_calls?: list<ToolCallData>
* }
* @phpstan-type ChoiceData array{
* message?: MessageData,
* finish_reason?: string
* }
* @phpstan-type UsageData array{
* prompt_tokens?: int,
* completion_tokens?: int,
* total_tokens?: int
* }
* @phpstan-type ResponseData array{
* id?: string,
* choices?: list<ChoiceData>,
* usage?: UsageData
* }
*/
abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements TextGenerationModelInterface
{
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
final public function generateTextResult(array $prompt): GenerativeAiResult
{
$httpTransporter = $this->getHttpTransporter();
$params = $this->prepareGenerateTextParams($prompt);
$request = $this->createRequest(HttpMethodEnum::POST(), 'chat/completions', ['Content-Type' => 'application/json'], $params);
// Add authentication credentials to the request.
$request = $this->getRequestAuthentication()->authenticateRequest($request);
// Send and process the request.
$response = $httpTransporter->send($request);
$this->throwIfNotSuccessful($response);
return $this->parseResponseToGenerativeAiResult($response);
}
/**
* Prepares the given prompt and the model configuration into parameters for the API request.
*
* @since 0.1.0
*
* @param list<Message> $prompt The prompt to generate text for. Either a single message or a list of messages
* from a chat.
* @return array<string, mixed> The parameters for the API request.
*/
protected function prepareGenerateTextParams(array $prompt): array
{
$config = $this->getConfig();
$params = ['model' => $this->metadata()->getId(), 'messages' => $this->prepareMessagesParam($prompt, $config->getSystemInstruction())];
$outputModalities = $config->getOutputModalities();
if (is_array($outputModalities)) {
$this->validateOutputModalities($outputModalities);
if (count($outputModalities) > 1) {
$params['modalities'] = $this->prepareOutputModalitiesParam($outputModalities);
}
}
$candidateCount = $config->getCandidateCount();
if ($candidateCount !== null) {
$params['n'] = $candidateCount;
}
$maxTokens = $config->getMaxTokens();
if ($maxTokens !== null) {
$params['max_tokens'] = $maxTokens;
}
$temperature = $config->getTemperature();
if ($temperature !== null) {
$params['temperature'] = $temperature;
}
$topP = $config->getTopP();
if ($topP !== null) {
$params['top_p'] = $topP;
}
$stopSequences = $config->getStopSequences();
if (is_array($stopSequences)) {
$params['stop'] = $stopSequences;
}
$presencePenalty = $config->getPresencePenalty();
if ($presencePenalty !== null) {
$params['presence_penalty'] = $presencePenalty;
}
$frequencyPenalty = $config->getFrequencyPenalty();
if ($frequencyPenalty !== null) {
$params['frequency_penalty'] = $frequencyPenalty;
}
$logprobs = $config->getLogprobs();
if ($logprobs !== null) {
$params['logprobs'] = $logprobs;
}
$topLogprobs = $config->getTopLogprobs();
if ($topLogprobs !== null) {
$params['top_logprobs'] = $topLogprobs;
}
$functionDeclarations = $config->getFunctionDeclarations();
if (is_array($functionDeclarations)) {
$params['tools'] = $this->prepareToolsParam($functionDeclarations);
}
$outputMimeType = $config->getOutputMimeType();
if ('application/json' === $outputMimeType) {
$outputSchema = $config->getOutputSchema();
$params['response_format'] = $this->prepareResponseFormatParam($outputSchema);
}
/*
* Any custom options are added to the parameters as well.
* This allows developers to pass other options that may be more niche or not yet supported by the SDK.
*/
$customOptions = $config->getCustomOptions();
foreach ($customOptions as $key => $value) {
if (isset($params[$key])) {
throw new InvalidArgumentException(sprintf('The custom option "%s" conflicts with an existing parameter.', $key));
}
$params[$key] = $value;
}
return $params;
}
/**
* Prepares the messages parameter for the API request.
*
* @since 0.1.0
*
* @param list<Message> $messages The messages to prepare.
* @param string|null $systemInstruction An optional system instruction to prepend to the messages.
* @return list<array<string, mixed>> The prepared messages parameter.
*/
protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array
{
$messagesParam = array_map(function (Message $message): array {
// Special case: Function response.
$messageParts = $message->getParts();
if (count($messageParts) === 1 && $messageParts[0]->getType()->isFunctionResponse()) {
$functionResponse = $messageParts[0]->getFunctionResponse();
if (!$functionResponse) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The function response typed message part must contain a function response.');
}
return ['role' => 'tool', 'content' => json_encode($functionResponse->getResponse()), 'tool_call_id' => $functionResponse->getId()];
}
$messageData = ['role' => $this->getMessageRoleString($message->getRole()), 'content' => array_values(array_filter(array_map([$this, 'getMessagePartContentData'], $messageParts)))];
// Only include tool_calls if there are any (OpenAI rejects empty arrays).
$toolCalls = array_values(array_filter(array_map([$this, 'getMessagePartToolCallData'], $messageParts)));
if (!empty($toolCalls)) {
$messageData['tool_calls'] = $toolCalls;
}
return $messageData;
}, $messages);
if ($systemInstruction) {
array_unshift($messagesParam, [
/*
* TODO: Replace this with 'developer' in the future.
* See https://platform.openai.com/docs/api-reference/chat/create#chat_create-messages
*/
'role' => 'system',
'content' => [['type' => 'text', 'text' => $systemInstruction]],
]);
}
return $messagesParam;
}
/**
* Returns the OpenAI API specific role string for the given message role.
*
* @since 0.1.0
*
* @param MessageRoleEnum $role The message role.
* @return string The role for the API request.
*/
protected function getMessageRoleString(MessageRoleEnum $role): string
{
if ($role === MessageRoleEnum::model()) {
return 'assistant';
}
return 'user';
}
/**
* Returns the OpenAI API specific content data for a message part.
*
* @since 0.1.0
*
* @param MessagePart $part The message part to get the data for.
* @return ?array<string, mixed> The data for the message content part, or null if not applicable.
* @throws InvalidArgumentException If the message part type or data is unsupported.
*/
protected function getMessagePartContentData(MessagePart $part): ?array
{
$type = $part->getType();
if ($type->isText()) {
/*
* The OpenAI Chat Completions API spec does not support annotating thought parts as input,
* so we instead skip them.
*/
if ($part->getChannel()->isThought()) {
return null;
}
return ['type' => 'text', 'text' => $part->getText()];
}
if ($type->isFile()) {
$file = $part->getFile();
if (!$file) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The file typed message part must contain a file.');
}
if ($file->isRemote()) {
if ($file->isImage()) {
return ['type' => 'image_url', 'image_url' => ['url' => $file->getUrl()]];
}
throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for remote file message part.', $file->getMimeType()));
}
// Else, it is an inline file.
if ($file->isImage()) {
return ['type' => 'image_url', 'image_url' => ['url' => $file->getDataUri()]];
}
if ($file->isAudio()) {
return ['type' => 'input_audio', 'input_audio' => ['data' => $file->getBase64Data(), 'format' => $file->getMimeTypeObject()->toExtension()]];
}
throw new InvalidArgumentException(sprintf('Unsupported MIME type "%s" for inline file message part.', $file->getMimeType()));
}
if ($type->isFunctionCall()) {
// Skip, as this is separately included. See `getMessagePartToolCallData()`.
return null;
}
if ($type->isFunctionResponse()) {
// Special case: Function response.
throw new InvalidArgumentException('The API only allows a single function response, as the only content of the message.');
}
throw new InvalidArgumentException(sprintf('Unsupported message part type "%s".', $type));
}
/**
* Returns the OpenAI API specific tool calls data for a message part.
*
* @since 0.1.0
*
* @param MessagePart $part The message part to get the data for.
* @return ?array<string, mixed> The data for the message tool call part, or null if not applicable.
* @throws InvalidArgumentException If the message part type or data is unsupported.
*/
protected function getMessagePartToolCallData(MessagePart $part): ?array
{
$type = $part->getType();
if ($type->isFunctionCall()) {
$functionCall = $part->getFunctionCall();
if (!$functionCall) {
// This should be impossible due to class internals, but still needs to be checked.
throw new RuntimeException('The function call typed message part must contain a function call.');
}
$args = $functionCall->getArgs();
/*
* Ensure null or empty arrays become empty objects for JSON encoding.
* While in theory the JSON schema could also dictate a type of
* 'array', in practice function arguments are typically of type
* 'object'. More importantly, the OpenAI API specification seems
* to expect that, and does not support passing arrays as the root
* value. The null check handles the case where FunctionCall normalizes
* empty arrays to null.
*/
if ($args === null || is_array($args) && count($args) === 0) {
$args = new \stdClass();
}
return ['type' => 'function', 'id' => $functionCall->getId(), 'function' => ['name' => $functionCall->getName(), 'arguments' => json_encode($args)]];
}
// All other types are handled in `getMessagePartContentData()`.
return null;
}
/**
* Validates that the given output modalities to ensure that at least one output modality is text.
*
* @since 0.1.0
*
* @param array<ModalityEnum> $outputModalities The output modalities to validate.
* @throws InvalidArgumentException If no text output modality is present.
*/
protected function validateOutputModalities(array $outputModalities): void
{
// If no output modalities are set, it's fine, as we can assume text.
if (count($outputModalities) === 0) {
return;
}
foreach ($outputModalities as $modality) {
if ($modality->isText()) {
return;
}
}
throw new InvalidArgumentException('A text output modality must be present when generating text.');
}
/**
* Prepares the output modalities parameter for the API request.
*
* @since 0.1.0
*
* @param array<ModalityEnum> $modalities The modalities to prepare.
* @return list<string> The prepared modalities parameter.
*/
protected function prepareOutputModalitiesParam(array $modalities): array
{
$prepared = [];
foreach ($modalities as $modality) {
if ($modality->isText()) {
$prepared[] = 'text';
} elseif ($modality->isImage()) {
$prepared[] = 'image';
} elseif ($modality->isAudio()) {
$prepared[] = 'audio';
} else {
throw new InvalidArgumentException(sprintf('Unsupported output modality "%s".', $modality));
}
}
return $prepared;
}
/**
* Prepares the tools parameter for the API request.
*
* @since 0.1.0
*
* @param list<FunctionDeclaration> $functionDeclarations The function declarations.
* @return list<array<string, mixed>> The prepared tools parameter.
*/
protected function prepareToolsParam(array $functionDeclarations): array
{
$tools = [];
foreach ($functionDeclarations as $functionDeclaration) {
$tools[] = ['type' => 'function', 'function' => $functionDeclaration->toArray()];
}
return $tools;
}
/**
* Prepares the response format parameter for the API request.
*
* This is only called if the output MIME type is `application/json`.
*
* @since 0.1.0
*
* @param array<string, mixed>|null $outputSchema The output schema.
* @return array<string, mixed> The prepared response format parameter.
*/
protected function prepareResponseFormatParam(?array $outputSchema): array
{
if (is_array($outputSchema)) {
return ['type' => 'json_schema', 'json_schema' => $outputSchema];
}
return ['type' => 'json_object'];
}
/**
* Creates a request object for the provider's API.
*
* Implementations should use $this->getRequestOptions() to attach any
* configured request options to the Request.
*
* @since 0.1.0
*
* @param HttpMethodEnum $method The HTTP method.
* @param string $path The API endpoint path, relative to the base URI.
* @param array<string, string|list<string>> $headers The request headers.
* @param string|array<string, mixed>|null $data The request data.
* @return Request The request object.
*/
abstract protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request;
/**
* Throws an exception if the response is not successful.
*
* @since 0.1.0
*
* @param Response $response The HTTP response to check.
* @throws ResponseException If the response is not successful.
*/
protected function throwIfNotSuccessful(Response $response): void
{
/*
* While this method only calls the utility method, it's important to have it here as a protected method so
* that child classes can override it if needed.
*/
ResponseUtil::throwIfNotSuccessful($response);
}
/**
* Parses the response from the API endpoint to a generative AI result.
*
* @since 0.1.0
*
* @param Response $response The response from the API endpoint.
* @return GenerativeAiResult The parsed generative AI result.
*/
protected function parseResponseToGenerativeAiResult(Response $response): GenerativeAiResult
{
/** @var ResponseData $responseData */
$responseData = $response->getData();
if (!isset($responseData['choices']) || !$responseData['choices']) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'choices');
}
if (!is_array($responseData['choices'])) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), 'choices', 'The value must be an array.');
}
$candidates = [];
foreach ($responseData['choices'] as $index => $choiceData) {
if (!is_array($choiceData) || array_is_list($choiceData)) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}]", 'The value must be an associative array.');
}
$candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $index);
}
$id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : '';
if (isset($responseData['usage']) && is_array($responseData['usage'])) {
$usage = $responseData['usage'];
$tokenUsage = new TokenUsage($usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, $usage['total_tokens'] ?? 0);
} else {
$tokenUsage = new TokenUsage(0, 0, 0);
}
// Use any other data from the response as provider-specific response metadata.
$additionalData = $responseData;
unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']);
return new GenerativeAiResult($id, $candidates, $tokenUsage, $this->providerMetadata(), $this->metadata(), $additionalData);
}
/**
* Parses a single choice from the API response into a Candidate object.
*
* @since 0.1.0
*
* @param ChoiceData $choiceData The choice data from the API response.
* @param int $index The index of the choice in the choices array.
* @return Candidate The parsed candidate.
* @throws RuntimeException If the choice data is invalid.
*/
protected function parseResponseChoiceToCandidate(array $choiceData, int $index): Candidate
{
if (!isset($choiceData['message']) || !is_array($choiceData['message']) || array_is_list($choiceData['message'])) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].message");
}
if (!isset($choiceData['finish_reason']) || !is_string($choiceData['finish_reason'])) {
throw ResponseException::fromMissingData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason");
}
$messageData = $choiceData['message'];
$message = $this->parseResponseChoiceMessage($messageData, $index);
switch ($choiceData['finish_reason']) {
case 'stop':
$finishReason = FinishReasonEnum::stop();
break;
case 'length':
$finishReason = FinishReasonEnum::length();
break;
case 'content_filter':
$finishReason = FinishReasonEnum::contentFilter();
break;
case 'tool_calls':
$finishReason = FinishReasonEnum::toolCalls();
break;
default:
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].finish_reason", sprintf('Invalid finish reason "%s".', $choiceData['finish_reason']));
}
return new Candidate($message, $finishReason);
}
/**
* Parses the message from a choice in the API response.
*
* @since 0.1.0
*
* @param MessageData $messageData The message data from the API response.
* @param int $index The index of the choice in the choices array.
* @return Message The parsed message.
*/
protected function parseResponseChoiceMessage(array $messageData, int $index): Message
{
$role = isset($messageData['role']) && 'user' === $messageData['role'] ? MessageRoleEnum::user() : MessageRoleEnum::model();
$parts = $this->parseResponseChoiceMessageParts($messageData, $index);
return new Message($role, $parts);
}
/**
* Parses the message parts from a choice in the API response.
*
* @since 0.1.0
*
* @param MessageData $messageData The message data from the API response.
* @param int $index The index of the choice in the choices array.
* @return MessagePart[] The parsed message parts.
*/
protected function parseResponseChoiceMessageParts(array $messageData, int $index): array
{
$parts = [];
if (isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content'])) {
$parts[] = new MessagePart($messageData['reasoning_content'], MessagePartChannelEnum::thought());
}
if (isset($messageData['content']) && is_string($messageData['content'])) {
$parts[] = new MessagePart($messageData['content']);
}
if (isset($messageData['tool_calls']) && is_array($messageData['tool_calls'])) {
foreach ($messageData['tool_calls'] as $toolCallIndex => $toolCallData) {
$toolCallPart = $this->parseResponseChoiceMessageToolCallPart($toolCallData);
if (!$toolCallPart) {
throw ResponseException::fromInvalidData($this->providerMetadata()->getName(), "choices[{$index}].message.tool_calls[{$toolCallIndex}]", 'The response includes a tool call of an unexpected type.');
}
$parts[] = $toolCallPart;
}
}
return $parts;
}
/**
* Parses a tool call part from the API response.
*
* @since 0.1.0
*
* @param ToolCallData $toolCallData The tool call data from the API response.
* @return MessagePart|null The parsed message part for the tool call, or null if not applicable.
*/
protected function parseResponseChoiceMessageToolCallPart(array $toolCallData): ?MessagePart
{
/*
* For now, only function calls are supported.
*
* Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set.
*/
if (isset($toolCallData['type']) && 'function' !== $toolCallData['type'] || !isset($toolCallData['function']) || !is_array($toolCallData['function'])) {
return null;
}
$functionArguments = is_string($toolCallData['function']['arguments']) ? json_decode($toolCallData['function']['arguments'], \true) : $toolCallData['function']['arguments'];
$functionCall = new FunctionCall(isset($toolCallData['id']) && is_string($toolCallData['id']) ? $toolCallData['id'] : null, isset($toolCallData['function']['name']) && is_string($toolCallData['function']['name']) ? $toolCallData['function']['name'] : null, $functionArguments);
return new MessagePart($functionCall);
}
}

View File

@@ -0,0 +1,520 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Providers;
use WordPress\AiClientDependencies\Http\Discovery\Exception\NotFoundException as DiscoveryNotFoundException;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Providers\Contracts\ProviderInterface;
use WordPress\AiClient\Providers\Contracts\ProviderWithOperationsHandlerInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata;
use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface;
use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface;
use WordPress\AiClient\Providers\Http\HttpTransporterFactory;
use WordPress\AiClient\Providers\Http\Traits\WithHttpTransporterTrait;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelRequirements;
/**
* Registry for managing AI providers and their models.
*
* This class provides a centralized way to register AI providers, discover
* their capabilities, and find suitable models based on requirements.
*
* @since 0.1.0
*/
class ProviderRegistry implements WithHttpTransporterInterface
{
use WithHttpTransporterTrait {
setHttpTransporter as setHttpTransporterOriginal;
}
/**
* @var array<string, class-string<ProviderInterface>> Mapping of provider IDs to class names.
*/
private array $registeredIdsToClassNames = [];
/**
* @var array<class-string<ProviderInterface>, string> Mapping of provider class names to IDs.
*/
private array $registeredClassNamesToIds = [];
/**
* @var array<class-string<ProviderInterface>, RequestAuthenticationInterface> Mapping of provider class names to
* authentication instances.
*/
private array $providerAuthenticationInstances = [];
/**
* Registers a provider class with the registry.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The fully qualified provider class name implementing the
* ProviderInterface
* @throws InvalidArgumentException If the class doesn't exist or implement the required interface.
*/
public function registerProvider(string $className): void
{
if (!class_exists($className)) {
throw new InvalidArgumentException(sprintf('Provider class does not exist: %s', $className));
}
// Validate that class implements ProviderInterface
if (!is_subclass_of($className, ProviderInterface::class)) {
throw new InvalidArgumentException(sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className));
}
$metadata = $className::metadata();
if (!$metadata instanceof ProviderMetadata) {
throw new InvalidArgumentException(sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className));
}
// If there is already a HTTP transporter instance set, hook it up to the provider as needed.
try {
$httpTransporter = $this->getHttpTransporter();
} catch (RuntimeException $e) {
/*
* If this fails, it's okay. There is no defined sequence between setting the HTTP transporter in the
* registry and registering providers in it, so it might be that the transporter is set later. It will be
* hooked up then.
* But for now we can ignore this exception and attempt to set the default HTTP transporter, if possible.
*/
try {
$this->setHttpTransporter(HttpTransporterFactory::createTransporter());
$httpTransporter = $this->getHttpTransporter();
} catch (DiscoveryNotFoundException $e) {
/*
* If no HTTP client implementation can be discovered yet, we can ignore this for now.
* It might be set later, so it's not a hard error at this point.
* We'll try again the next time a provider is registered, or maybe by that time an explicit
* HTTP transporter will have been set.
*/
}
}
if (isset($httpTransporter)) {
$this->setHttpTransporterForProvider($className, $httpTransporter);
}
// Hook up the request authentication instance, using a default if not set.
if (!isset($this->providerAuthenticationInstances[$className])) {
$defaultProviderAuthentication = $this->createDefaultProviderRequestAuthentication($className);
if ($defaultProviderAuthentication !== null) {
$this->providerAuthenticationInstances[$className] = $defaultProviderAuthentication;
}
}
if (isset($this->providerAuthenticationInstances[$className])) {
$this->setRequestAuthenticationForProvider($className, $this->providerAuthenticationInstances[$className]);
}
$this->registeredIdsToClassNames[$metadata->getId()] = $className;
$this->registeredClassNamesToIds[$className] = $metadata->getId();
}
/**
* Gets a list of all registered provider IDs.
*
* @since 0.1.0
*
* @return list<string> List of registered provider IDs.
*/
public function getRegisteredProviderIds(): array
{
return array_keys($this->registeredIdsToClassNames);
}
/**
* Checks if a provider is registered.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name to check.
* @return bool True if the provider is registered.
*/
public function hasProvider(string $idOrClassName): bool
{
return $this->isRegisteredId($idOrClassName) || $this->isRegisteredClassName($idOrClassName);
}
/**
* Gets the class name for a registered provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return class-string<ProviderInterface> The provider class name.
* @throws InvalidArgumentException If the provider is not registered.
*/
public function getProviderClassName(string $idOrClassName): string
{
// If it's already a class name, return it
if ($this->isRegisteredClassName($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered ID, return its class name
if ($this->isRegisteredId($idOrClassName)) {
return $this->registeredIdsToClassNames[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* Gets the provider ID for a registered provider.
*
* @since 0.2.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return string The provider ID.
* @throws InvalidArgumentException If the provider is not registered.
*/
public function getProviderId(string $idOrClassName): string
{
// If it's already an ID, return it
if ($this->isRegisteredId($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered class name, return its ID
if ($this->isRegisteredClassName($idOrClassName)) {
return $this->registeredClassNamesToIds[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* Checks if a provider is properly configured.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return bool True if the provider is configured and ready to use.
*/
public function isProviderConfigured(string $idOrClassName): bool
{
try {
$className = $this->resolveProviderClassName($idOrClassName);
// Use static method from ProviderInterface
/** @var class-string<ProviderInterface> $className */
$availability = $className::availability();
return $availability->isConfigured();
} catch (InvalidArgumentException $e) {
return \false;
}
}
/**
* Finds models across all available providers that support the given requirements.
*
* @since 0.1.0
*
* @param ModelRequirements $modelRequirements The requirements to match against.
* @return list<ProviderModelsMetadata> List of provider models metadata that match requirements.
*/
public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array
{
$results = [];
foreach ($this->registeredIdsToClassNames as $providerId => $className) {
$providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements);
if (!empty($providerResults)) {
// Use static method from ProviderInterface
/** @var class-string<ProviderInterface> $className */
$providerMetadata = $className::metadata();
$results[] = new ProviderModelsMetadata($providerMetadata, $providerResults);
}
}
return $results;
}
/**
* Finds models within a specific available provider that support the given requirements.
*
* @since 0.1.0
*
* @param string $idOrClassName The provider ID or class name.
* @param ModelRequirements $modelRequirements The requirements to match against.
* @return list<ModelMetadata> List of model metadata that match requirements.
*/
public function findProviderModelsMetadataForSupport(string $idOrClassName, ModelRequirements $modelRequirements): array
{
$className = $this->resolveProviderClassName($idOrClassName);
// If the provider is not configured, there is no way to use it, so it is considered unavailable.
if (!$this->isProviderConfigured($className)) {
return [];
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
// Filter models that meet requirements
$matchingModels = [];
foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) {
if ($modelRequirements->areMetBy($modelMetadata)) {
$matchingModels[] = $modelMetadata;
}
}
return $matchingModels;
}
/**
* Gets a configured model instance from a provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @param string $modelId The model identifier.
* @param ModelConfig|null $modelConfig The model configuration.
* @return ModelInterface The configured model instance.
* @throws InvalidArgumentException If provider or model is not found.
*/
public function getProviderModel(string $idOrClassName, string $modelId, ?ModelConfig $modelConfig = null): ModelInterface
{
$className = $this->resolveProviderClassName($idOrClassName);
$modelInstance = $className::model($modelId, $modelConfig);
$this->bindModelDependencies($modelInstance);
return $modelInstance;
}
/**
* Binds dependencies to a model instance.
*
* This method injects required dependencies such as HTTP transporter
* and authentication into model instances that need them.
*
* @since 0.1.0
*
* @param ModelInterface $modelInstance The model instance to bind dependencies to.
* @return void
*/
public function bindModelDependencies(ModelInterface $modelInstance): void
{
$className = $this->resolveProviderClassName($modelInstance->providerMetadata()->getId());
if ($modelInstance instanceof WithHttpTransporterInterface) {
$modelInstance->setHttpTransporter($this->getHttpTransporter());
}
if ($modelInstance instanceof WithRequestAuthenticationInterface) {
$requestAuthentication = $this->getProviderRequestAuthentication($className);
if ($requestAuthentication !== null) {
$modelInstance->setRequestAuthentication($requestAuthentication);
}
}
}
/**
* Gets the class name for a registered provider (handles both ID and class name input).
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return class-string<ProviderInterface> The provider class name.
* @throws InvalidArgumentException If provider is not registered.
*/
private function resolveProviderClassName(string $idOrClassName): string
{
// If it's already a class name, return it
if ($this->isRegisteredClassName($idOrClassName)) {
return $idOrClassName;
}
// If it's a registered ID, return its class name
if ($this->isRegisteredId($idOrClassName)) {
return $this->registeredIdsToClassNames[$idOrClassName];
}
// Not found
throw new InvalidArgumentException(sprintf('Provider not registered: %s', $idOrClassName));
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function setHttpTransporter(HttpTransporterInterface $httpTransporter): void
{
$this->setHttpTransporterOriginal($httpTransporter);
// Make sure all registered providers have the HTTP transporter hooked up as needed.
foreach ($this->registeredIdsToClassNames as $className) {
$this->setHttpTransporterForProvider($className, $httpTransporter);
}
}
/**
* Sets the request authentication instance for the given provider.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @param RequestAuthenticationInterface $requestAuthentication The request authentication instance.
*/
public function setProviderRequestAuthentication(string $idOrClassName, RequestAuthenticationInterface $requestAuthentication): void
{
$className = $this->resolveProviderClassName($idOrClassName);
$this->providerAuthenticationInstances[$className] = $requestAuthentication;
$this->setRequestAuthenticationForProvider($className, $requestAuthentication);
}
/**
* Gets the request authentication instance for the given provider, if set.
*
* @since 0.1.0
*
* @param string|class-string<ProviderInterface> $idOrClassName The provider ID or class name.
* @return ?RequestAuthenticationInterface The request authentication instance, or null if not set.
*/
public function getProviderRequestAuthentication(string $idOrClassName): ?RequestAuthenticationInterface
{
$className = $this->resolveProviderClassName($idOrClassName);
if (!isset($this->providerAuthenticationInstances[$className])) {
return null;
}
return $this->providerAuthenticationInstances[$className];
}
/**
* Sets the HTTP transporter for a specific provider, hooking up its class instances.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @param HttpTransporterInterface $httpTransporter The HTTP transporter instance.
*/
private function setHttpTransporterForProvider(string $className, HttpTransporterInterface $httpTransporter): void
{
$availability = $className::availability();
if ($availability instanceof WithHttpTransporterInterface) {
$availability->setHttpTransporter($httpTransporter);
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
if ($modelMetadataDirectory instanceof WithHttpTransporterInterface) {
$modelMetadataDirectory->setHttpTransporter($httpTransporter);
}
if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
$operationsHandler = $className::operationsHandler();
if ($operationsHandler instanceof WithHttpTransporterInterface) {
$operationsHandler->setHttpTransporter($httpTransporter);
}
}
}
/**
* Sets the request authentication for a specific provider, hooking up its class instances.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @param RequestAuthenticationInterface $requestAuthentication The authentication instance.
*
* @throws InvalidArgumentException If the authentication instance is not of the expected type.
*/
private function setRequestAuthenticationForProvider(string $className, RequestAuthenticationInterface $requestAuthentication): void
{
$authenticationMethod = $className::metadata()->getAuthenticationMethod();
if ($authenticationMethod === null) {
throw new InvalidArgumentException(sprintf('Provider %s does not expect any authentication, but got %s.', $className, get_class($requestAuthentication)));
}
$expectedClass = $authenticationMethod->getImplementationClass();
if (!$requestAuthentication instanceof $expectedClass) {
throw new InvalidArgumentException(sprintf('Provider %s expects authentication of type %s, but got %s.', $className, $expectedClass, get_class($requestAuthentication)));
}
$availability = $className::availability();
if ($availability instanceof WithRequestAuthenticationInterface) {
$availability->setRequestAuthentication($requestAuthentication);
}
$modelMetadataDirectory = $className::modelMetadataDirectory();
if ($modelMetadataDirectory instanceof WithRequestAuthenticationInterface) {
$modelMetadataDirectory->setRequestAuthentication($requestAuthentication);
}
if (is_subclass_of($className, ProviderWithOperationsHandlerInterface::class)) {
$operationsHandler = $className::operationsHandler();
if ($operationsHandler instanceof WithRequestAuthenticationInterface) {
$operationsHandler->setRequestAuthentication($requestAuthentication);
}
}
}
/**
* Creates a default request authentication instance for a provider.
*
* @since 0.1.0
*
* @param class-string<ProviderInterface> $className The provider class name.
* @return ?RequestAuthenticationInterface The default request authentication instance, or null if not required or
* if no credential data can be found.
*/
private function createDefaultProviderRequestAuthentication(string $className): ?RequestAuthenticationInterface
{
$providerMetadata = $className::metadata();
$providerId = $providerMetadata->getId();
$authenticationMethod = $providerMetadata->getAuthenticationMethod();
if ($authenticationMethod === null) {
return null;
}
$authenticationClass = $authenticationMethod->getImplementationClass();
if ($authenticationClass === null) {
return null;
}
$authenticationSchema = $authenticationClass::getJsonSchema();
// Iterate over all JSON schema object properties to try to determine the necessary authentication data.
$authenticationData = [];
if (isset($authenticationSchema['properties']) && is_array($authenticationSchema['properties'])) {
/** @var array<string, mixed> $details */
foreach ($authenticationSchema['properties'] as $property => $details) {
$envVarName = $this->getEnvVarName($providerId, $property);
// Try to get the value from environment variable or constant.
$envValue = getenv($envVarName);
if ($envValue === \false) {
if (!defined($envVarName)) {
continue;
// Skip if neither environment variable nor constant is defined.
}
$envValue = constant($envVarName);
if (!is_scalar($envValue)) {
continue;
}
}
if (isset($details['type'])) {
switch ($details['type']) {
case 'boolean':
$authenticationData[$property] = filter_var($envValue, \FILTER_VALIDATE_BOOLEAN);
break;
case 'number':
$authenticationData[$property] = (int) $envValue;
break;
case 'string':
default:
$authenticationData[$property] = (string) $envValue;
}
} else {
// Default to string if no type is specified.
$authenticationData[$property] = (string) $envValue;
}
}
// If any required fields are missing, return null to avoid immediate errors.
if (isset($authenticationSchema['required']) && is_array($authenticationSchema['required'])) {
/** @var list<string> $requiredProperties */
$requiredProperties = $authenticationSchema['required'];
if (array_diff_key(array_flip($requiredProperties), $authenticationData)) {
return null;
}
}
}
/** @var RequestAuthenticationInterface */
/** @var array<string, mixed> $authenticationData */
return $authenticationClass::fromArray($authenticationData);
}
/**
* Checks if the given value is a registered provider class name.
*
* @since 0.4.0
*
* @param string $idOrClassName The value to check.
* @return bool True if it's a registered class name.
* @phpstan-assert-if-true class-string<ProviderInterface> $idOrClassName
*/
private function isRegisteredClassName(string $idOrClassName): bool
{
return isset($this->registeredClassNamesToIds[$idOrClassName]);
}
/**
* Checks if the given value is a registered provider ID.
*
* @since 0.4.0
*
* @param string $idOrClassName The value to check.
* @return bool True if it's a registered provider ID.
*/
private function isRegisteredId(string $idOrClassName): bool
{
return isset($this->registeredIdsToClassNames[$idOrClassName]);
}
/**
* Converts a provider ID and field name to a constant case environment variable name.
*
* @since 0.1.0
*
* @param string $providerId The provider ID.
* @param string $field The field name.
* @return string The environment variable name in CONSTANT_CASE.
*/
private function getEnvVarName(string $providerId, string $field): string
{
// Convert camelCase or kebab-case or snake_case to CONSTANT_CASE.
$constantCaseProviderId = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $providerId)));
$constantCaseField = strtoupper((string) preg_replace('/([a-z])([A-Z])/', '$1_$2', str_replace('-', '_', $field)));
return "{$constantCaseProviderId}_{$constantCaseField}";
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\Contracts;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Results\DTO\TokenUsage;
/**
* Interface for AI operation results.
*
* Results contain the output from AI operations along with metadata
* such as token usage and provider-specific information.
*
* @since 0.1.0
*/
interface ResultInterface
{
/**
* Gets the result ID.
*
* @since 0.1.0
*
* @return string The unique result identifier.
*/
public function getId(): string;
/**
* Gets token usage information.
*
* @since 0.1.0
*
* @return TokenUsage Token usage statistics.
*/
public function getTokenUsage(): TokenUsage;
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProviderMetadata(): ProviderMetadata;
/**
* Gets the model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata The model metadata.
*/
public function getModelMetadata(): ModelMetadata;
/**
* Gets provider-specific metadata.
*
* @since 0.1.0
*
* @return array<string, mixed> Provider metadata.
*/
public function getAdditionalData(): array;
}

View File

@@ -0,0 +1,117 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
/**
* 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.
*
* @since 0.1.0
*
* @phpstan-import-type MessageArrayShape from Message
*
* @phpstan-type CandidateArrayShape array{message: MessageArrayShape, finishReason: string}
*
* @extends AbstractDataTransferObject<CandidateArrayShape>
*/
class Candidate extends AbstractDataTransferObject
{
public const KEY_MESSAGE = 'message';
public const KEY_FINISH_REASON = 'finishReason';
/**
* @var Message The generated message.
*/
private Message $message;
/**
* @var FinishReasonEnum The reason generation stopped.
*/
private FinishReasonEnum $finishReason;
/**
* Constructor.
*
* @since 0.1.0
*
* @param Message $message The generated message.
* @param FinishReasonEnum $finishReason The reason generation stopped.
*/
public function __construct(Message $message, FinishReasonEnum $finishReason)
{
if (!$message->getRole()->isModel()) {
throw new InvalidArgumentException('Message must be a model message.');
}
$this->message = $message;
$this->finishReason = $finishReason;
}
/**
* Gets the generated message.
*
* @since 0.1.0
*
* @return Message The message.
*/
public function getMessage(): Message
{
return $this->message;
}
/**
* Gets the finish reason.
*
* @since 0.1.0
*
* @return FinishReasonEnum The finish reason.
*/
public function getFinishReason(): FinishReasonEnum
{
return $this->finishReason;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_MESSAGE => Message::getJsonSchema(), self::KEY_FINISH_REASON => ['type' => 'string', 'enum' => FinishReasonEnum::getValues(), 'description' => 'The reason generation stopped.']], 'required' => [self::KEY_MESSAGE, self::KEY_FINISH_REASON]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return CandidateArrayShape
*/
public function toArray(): array
{
return [self::KEY_MESSAGE => $this->message->toArray(), self::KEY_FINISH_REASON => $this->finishReason->value];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_MESSAGE, self::KEY_FINISH_REASON]);
$messageData = $array[self::KEY_MESSAGE];
return new self(Message::fromArray($messageData), FinishReasonEnum::from($array[self::KEY_FINISH_REASON]));
}
/**
* Performs a deep clone of the candidate.
*
* This method ensures that the message object is cloned to prevent
* modifications to the cloned candidate from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$this->message = clone $this->message;
}
}

View File

@@ -0,0 +1,420 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Files\DTO\File;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Results\Contracts\ResultInterface;
/**
* Represents the result of a generative AI operation.
*
* This DTO contains the generated candidates along with usage statistics
* and metadata from the AI provider.
*
* @since 0.1.0
*
* @phpstan-import-type CandidateArrayShape from Candidate
* @phpstan-import-type TokenUsageArrayShape from TokenUsage
* @phpstan-import-type ProviderMetadataArrayShape from ProviderMetadata
* @phpstan-import-type ModelMetadataArrayShape from ModelMetadata
*
* @phpstan-type GenerativeAiResultArrayShape array{
* id: string,
* candidates: array<CandidateArrayShape>,
* tokenUsage: TokenUsageArrayShape,
* providerMetadata: ProviderMetadataArrayShape,
* modelMetadata: ModelMetadataArrayShape,
* additionalData?: array<string, mixed>
* }
*
* @extends AbstractDataTransferObject<GenerativeAiResultArrayShape>
*/
class GenerativeAiResult extends AbstractDataTransferObject implements ResultInterface
{
public const KEY_ID = 'id';
public const KEY_CANDIDATES = 'candidates';
public const KEY_TOKEN_USAGE = 'tokenUsage';
public const KEY_PROVIDER_METADATA = 'providerMetadata';
public const KEY_MODEL_METADATA = 'modelMetadata';
public const KEY_ADDITIONAL_DATA = 'additionalData';
/**
* @var string Unique identifier for this result.
*/
private string $id;
/**
* @var Candidate[] The generated candidates.
*/
private array $candidates;
/**
* @var TokenUsage Token usage statistics.
*/
private \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage;
/**
* @var ProviderMetadata Provider metadata.
*/
private ProviderMetadata $providerMetadata;
/**
* @var ModelMetadata Model metadata.
*/
private ModelMetadata $modelMetadata;
/**
* @var array<string, mixed> Additional data.
*/
private array $additionalData;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $id Unique identifier for this result.
* @param Candidate[] $candidates The generated candidates.
* @param TokenUsage $tokenUsage Token usage statistics.
* @param ProviderMetadata $providerMetadata Provider metadata.
* @param ModelMetadata $modelMetadata Model metadata.
* @param array<string, mixed> $additionalData Additional data.
* @throws InvalidArgumentException If no candidates provided.
*/
public function __construct(string $id, array $candidates, \WordPress\AiClient\Results\DTO\TokenUsage $tokenUsage, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata, array $additionalData = [])
{
if (empty($candidates)) {
throw new InvalidArgumentException('At least one candidate must be provided');
}
$this->id = $id;
$this->candidates = $candidates;
$this->tokenUsage = $tokenUsage;
$this->providerMetadata = $providerMetadata;
$this->modelMetadata = $modelMetadata;
$this->additionalData = $additionalData;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getId(): string
{
return $this->id;
}
/**
* Gets the generated candidates.
*
* @since 0.1.0
*
* @return Candidate[] The candidates.
*/
public function getCandidates(): array
{
return $this->candidates;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getTokenUsage(): \WordPress\AiClient\Results\DTO\TokenUsage
{
return $this->tokenUsage;
}
/**
* Gets the provider metadata.
*
* @since 0.1.0
*
* @return ProviderMetadata The provider metadata.
*/
public function getProviderMetadata(): ProviderMetadata
{
return $this->providerMetadata;
}
/**
* Gets the model metadata.
*
* @since 0.1.0
*
* @return ModelMetadata The model metadata.
*/
public function getModelMetadata(): ModelMetadata
{
return $this->modelMetadata;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public function getAdditionalData(): array
{
return $this->additionalData;
}
/**
* Gets the total number of candidates.
*
* @since 0.1.0
*
* @return int The total number of candidates.
*/
public function getCandidateCount(): int
{
return count($this->candidates);
}
/**
* Checks if the result has multiple candidates.
*
* @since 0.1.0
*
* @return bool True if there are multiple candidates, false otherwise.
*/
public function hasMultipleCandidates(): bool
{
return $this->getCandidateCount() > 1;
}
/**
* Converts the first candidate to text.
*
* Only text from the content channel is considered. Text within model thought or reasoning is ignored.
*
* @since 0.1.0
*
* @return string The text content.
* @throws RuntimeException If no text content.
*/
public function toText(): string
{
$message = $this->candidates[0]->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$text = $part->getText();
if ($channel->isContent() && $text !== null) {
return $text;
}
}
throw new RuntimeException('No text content found in first candidate');
}
/**
* Converts the first candidate to a file.
*
* Only files from the content channel are considered. Files within model thought or reasoning are ignored.
*
* @since 0.1.0
*
* @return File The file.
* @throws RuntimeException If no file content.
*/
public function toFile(): File
{
$message = $this->candidates[0]->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$file = $part->getFile();
if ($channel->isContent() && $file !== null) {
return $file;
}
}
throw new RuntimeException('No file content found in first candidate');
}
/**
* Converts the first candidate to an image file.
*
* @since 0.1.0
*
* @return File The image file.
* @throws RuntimeException If no image content.
*/
public function toImageFile(): File
{
$file = $this->toFile();
if (!$file->isImage()) {
throw new RuntimeException(sprintf('File is not an image. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to an audio file.
*
* @since 0.1.0
*
* @return File The audio file.
* @throws RuntimeException If no audio content.
*/
public function toAudioFile(): File
{
$file = $this->toFile();
if (!$file->isAudio()) {
throw new RuntimeException(sprintf('File is not an audio file. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to a video file.
*
* @since 0.1.0
*
* @return File The video file.
* @throws RuntimeException If no video content.
*/
public function toVideoFile(): File
{
$file = $this->toFile();
if (!$file->isVideo()) {
throw new RuntimeException(sprintf('File is not a video file. MIME type: %s', $file->getMimeType()));
}
return $file;
}
/**
* Converts the first candidate to a message.
*
* @since 0.1.0
*
* @return Message The message.
*/
public function toMessage(): Message
{
return $this->candidates[0]->getMessage();
}
/**
* Converts all candidates to text.
*
* @since 0.1.0
*
* @return list<string> Array of text content.
*/
public function toTexts(): array
{
$texts = [];
foreach ($this->candidates as $candidate) {
$message = $candidate->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$text = $part->getText();
if ($channel->isContent() && $text !== null) {
$texts[] = $text;
break;
}
}
}
return $texts;
}
/**
* Converts all candidates to files.
*
* @since 0.1.0
*
* @return list<File> Array of files.
*/
public function toFiles(): array
{
$files = [];
foreach ($this->candidates as $candidate) {
$message = $candidate->getMessage();
foreach ($message->getParts() as $part) {
$channel = $part->getChannel();
$file = $part->getFile();
if ($channel->isContent() && $file !== null) {
$files[] = $file;
break;
}
}
}
return $files;
}
/**
* Converts all candidates to image files.
*
* @since 0.1.0
*
* @return list<File> Array of image files.
*/
public function toImageFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isImage()));
}
/**
* Converts all candidates to audio files.
*
* @since 0.1.0
*
* @return list<File> Array of audio files.
*/
public function toAudioFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isAudio()));
}
/**
* Converts all candidates to video files.
*
* @since 0.1.0
*
* @return list<File> Array of video files.
*/
public function toVideoFiles(): array
{
return array_values(array_filter($this->toFiles(), fn(File $file) => $file->isVideo()));
}
/**
* Converts all candidates to messages.
*
* @since 0.1.0
*
* @return list<Message> Array of messages.
*/
public function toMessages(): array
{
return array_values(array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->getMessage(), $this->candidates));
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this result.'], self::KEY_CANDIDATES => ['type' => 'array', 'items' => \WordPress\AiClient\Results\DTO\Candidate::getJsonSchema(), 'minItems' => 1, 'description' => 'The generated candidates.'], self::KEY_TOKEN_USAGE => \WordPress\AiClient\Results\DTO\TokenUsage::getJsonSchema(), self::KEY_PROVIDER_METADATA => ProviderMetadata::getJsonSchema(), self::KEY_MODEL_METADATA => ModelMetadata::getJsonSchema(), self::KEY_ADDITIONAL_DATA => ['type' => 'object', 'additionalProperties' => \true, 'description' => 'Additional data included in the API response.']], 'required' => [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return GenerativeAiResultArrayShape
*/
public function toArray(): array
{
return [self::KEY_ID => $this->id, self::KEY_CANDIDATES => array_map(fn(\WordPress\AiClient\Results\DTO\Candidate $candidate) => $candidate->toArray(), $this->candidates), self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), self::KEY_PROVIDER_METADATA => $this->providerMetadata->toArray(), self::KEY_MODEL_METADATA => $this->modelMetadata->toArray(), self::KEY_ADDITIONAL_DATA => $this->additionalData];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_ID, self::KEY_CANDIDATES, self::KEY_TOKEN_USAGE, self::KEY_PROVIDER_METADATA, self::KEY_MODEL_METADATA]);
$candidates = array_map(fn(array $candidateData) => \WordPress\AiClient\Results\DTO\Candidate::fromArray($candidateData), $array[self::KEY_CANDIDATES]);
return new self($array[self::KEY_ID], $candidates, \WordPress\AiClient\Results\DTO\TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), ProviderMetadata::fromArray($array[self::KEY_PROVIDER_METADATA]), ModelMetadata::fromArray($array[self::KEY_MODEL_METADATA]), $array[self::KEY_ADDITIONAL_DATA] ?? []);
}
/**
* Performs a deep clone of the result.
*
* This method ensures that all nested objects (candidates, token usage, metadata)
* are cloned to prevent modifications to the cloned result from affecting the original.
*
* @since 0.4.2
*/
public function __clone()
{
$clonedCandidates = [];
foreach ($this->candidates as $candidate) {
$clonedCandidates[] = clone $candidate;
}
$this->candidates = $clonedCandidates;
$this->tokenUsage = clone $this->tokenUsage;
$this->providerMetadata = clone $this->providerMetadata;
$this->modelMetadata = clone $this->modelMetadata;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
/**
* 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.
*
* Note that thought tokens are a subset of completion tokens, not additive.
* In other words: completionTokens - thoughtTokens = tokens of actual output content.
*
* @since 0.1.0
*
* @phpstan-type TokenUsageArrayShape array{
* promptTokens: int,
* completionTokens: int,
* totalTokens: int,
* thoughtTokens?: int
* }
*
* @extends AbstractDataTransferObject<TokenUsageArrayShape>
*/
class TokenUsage extends AbstractDataTransferObject
{
public const KEY_PROMPT_TOKENS = 'promptTokens';
public const KEY_COMPLETION_TOKENS = 'completionTokens';
public const KEY_TOTAL_TOKENS = 'totalTokens';
public const KEY_THOUGHT_TOKENS = 'thoughtTokens';
/**
* @var int Number of tokens in the prompt.
*/
private int $promptTokens;
/**
* @var int Number of tokens in the completion, including any thought tokens.
*/
private int $completionTokens;
/**
* @var int Total number of tokens used.
*/
private int $totalTokens;
/**
* @var int|null Number of tokens used for thinking, as a subset of completion tokens.
*/
private ?int $thoughtTokens;
/**
* Constructor.
*
* @since 0.1.0
*
* @param int $promptTokens Number of tokens in the prompt.
* @param int $completionTokens Number of tokens in the completion, including any thought tokens.
* @param int $totalTokens Total number of tokens used.
* @param int|null $thoughtTokens Number of tokens used for thinking, as a subset of completion tokens.
*/
public function __construct(int $promptTokens, int $completionTokens, int $totalTokens, ?int $thoughtTokens = null)
{
$this->promptTokens = $promptTokens;
$this->completionTokens = $completionTokens;
$this->totalTokens = $totalTokens;
$this->thoughtTokens = $thoughtTokens;
}
/**
* Gets the number of prompt tokens.
*
* @since 0.1.0
*
* @return int The prompt token count.
*/
public function getPromptTokens(): int
{
return $this->promptTokens;
}
/**
* Gets the number of completion tokens, including any thought tokens.
*
* @since 0.1.0
*
* @return int The completion token count.
*/
public function getCompletionTokens(): int
{
return $this->completionTokens;
}
/**
* Gets the total number of tokens.
*
* @since 0.1.0
*
* @return int The total token count.
*/
public function getTotalTokens(): int
{
return $this->totalTokens;
}
/**
* Gets the number of thought tokens, which is a subset of the completion token count.
*
* @since 1.3.0
*
* @return int|null The thought token count or null if not available.
*/
public function getThoughtTokens(): ?int
{
return $this->thoughtTokens;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_PROMPT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the prompt.'], self::KEY_COMPLETION_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens in the completion, including any thought tokens.'], self::KEY_TOTAL_TOKENS => ['type' => 'integer', 'description' => 'Total number of tokens used.'], self::KEY_THOUGHT_TOKENS => ['type' => 'integer', 'description' => 'Number of tokens used for thinking, as a subset of completion tokens.']], 'required' => [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return TokenUsageArrayShape
*/
public function toArray(): array
{
$data = [self::KEY_PROMPT_TOKENS => $this->promptTokens, self::KEY_COMPLETION_TOKENS => $this->completionTokens, self::KEY_TOTAL_TOKENS => $this->totalTokens];
if ($this->thoughtTokens !== null) {
$data[self::KEY_THOUGHT_TOKENS] = $this->thoughtTokens;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
static::validateFromArrayData($array, [self::KEY_PROMPT_TOKENS, self::KEY_COMPLETION_TOKENS, self::KEY_TOTAL_TOKENS]);
return new self($array[self::KEY_PROMPT_TOKENS], $array[self::KEY_COMPLETION_TOKENS], $array[self::KEY_TOTAL_TOKENS], $array[self::KEY_THOUGHT_TOKENS] ?? null);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Results\Enums;
use WordPress\AiClient\Common\AbstractEnum;
/**
* Enum for finish reasons of AI generation.
*
* @since 0.1.0
*
* @method static self stop() Creates an instance for STOP reason.
* @method static self length() Creates an instance for LENGTH reason.
* @method static self contentFilter() Creates an instance for CONTENT_FILTER reason.
* @method static self toolCalls() Creates an instance for TOOL_CALLS reason.
* @method static self error() Creates an instance for ERROR reason.
* @method bool isStop() Checks if the reason is STOP.
* @method bool isLength() Checks if the reason is LENGTH.
* @method bool isContentFilter() Checks if the reason is CONTENT_FILTER.
* @method bool isToolCalls() Checks if the reason is TOOL_CALLS.
* @method bool isError() Checks if the reason is ERROR.
*/
class FinishReasonEnum extends AbstractEnum
{
/**
* Generation stopped naturally.
*/
public const STOP = 'stop';
/**
* Generation stopped due to max length.
*/
public const LENGTH = 'length';
/**
* Generation stopped due to content filter.
*/
public const CONTENT_FILTER = 'content_filter';
/**
* Generation stopped to make tool calls.
*/
public const TOOL_CALLS = 'tool_calls';
/**
* Generation stopped due to error.
*/
public const ERROR = 'error';
}

View File

@@ -0,0 +1,128 @@
<?php
declare (strict_types=1);
namespace WordPress\AiClient\Tools\DTO;
use WordPress\AiClient\Common\AbstractDataTransferObject;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
/**
* 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.
*
* @since 0.1.0
*
* @phpstan-type FunctionCallArrayShape array{id?: string, name?: string, args?: mixed}
*
* @extends AbstractDataTransferObject<FunctionCallArrayShape>
*/
class FunctionCall extends AbstractDataTransferObject
{
public const KEY_ID = 'id';
public const KEY_NAME = 'name';
public const KEY_ARGS = 'args';
/**
* @var string|null Unique identifier for this function call.
*/
private ?string $id;
/**
* @var string|null The name of the function to call.
*/
private ?string $name;
/**
* @var mixed The arguments to pass to the function.
*/
private $args;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string|null $id Unique identifier for this function call.
* @param string|null $name The name of the function to call.
* @param mixed $args The arguments to pass to the function.
* @throws InvalidArgumentException If neither id nor name is provided.
*/
public function __construct(?string $id = null, ?string $name = null, $args = null)
{
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;
}
/**
* Gets the function call ID.
*
* @since 0.1.0
*
* @return string|null The function call ID.
*/
public function getId(): ?string
{
return $this->id;
}
/**
* Gets the function name.
*
* @since 0.1.0
*
* @return string|null The function name.
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Gets the function arguments.
*
* @since 0.1.0
*
* @return mixed The function arguments.
*/
public function getArgs()
{
return $this->args;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function getJsonSchema(): array
{
return ['type' => 'object', 'properties' => [self::KEY_ID => ['type' => 'string', 'description' => 'Unique identifier for this function call.'], self::KEY_NAME => ['type' => 'string', 'description' => 'The name of the function to call.'], self::KEY_ARGS => ['type' => ['string', 'number', 'boolean', 'object', 'array', 'null'], 'description' => 'The arguments to pass to the function.']], 'anyOf' => [['required' => [self::KEY_ID]], ['required' => [self::KEY_NAME]]]];
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*
* @return FunctionCallArrayShape
*/
public function toArray(): array
{
$data = [];
if ($this->id !== null) {
$data[self::KEY_ID] = $this->id;
}
if ($this->name !== null) {
$data[self::KEY_NAME] = $this->name;
}
if ($this->args !== null) {
$data[self::KEY_ARGS] = $this->args;
}
return $data;
}
/**
* {@inheritDoc}
*
* @since 0.1.0
*/
public static function fromArray(array $array): self
{
return new self($array[self::KEY_ID] ?? null, $array[self::KEY_NAME] ?? null, $array[self::KEY_ARGS] ?? null);
}
}

Some files were not shown because too many files have changed in this diff Show More