Add PSR HTTP Message Interfaces and Dependencies

- Implemented StreamInterface, UploadedFileInterface, and UriInterface as per PSR standards.
- Added getallheaders function to retrieve HTTP headers in a compatible manner.
- Included LICENSE files for ralouphie/getallheaders and symfony/deprecation-contracts.
- Introduced function for triggering deprecation notices in Symfony.
This commit is contained in:
2025-12-28 12:44:00 +01:00
parent cf600ae727
commit cd264483f8
410 changed files with 60841 additions and 16 deletions

View File

@@ -0,0 +1,98 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Base\Abstract_AI_Model
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Base;
use ATFPP\AI_Translate\Services\API\Types\Model_Metadata;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Model;
use RuntimeException;
/**
* Base class for an AI model.
*
* @since 0.5.0
*/
abstract class Abstract_AI_Model implements Generative_AI_Model {
/**
* The model metadata.
*
* @since 0.7.0
* @var Model_Metadata
*/
private $metadata;
/**
* The request options.
*
* @since 0.5.0
* @var array<string, mixed>
*/
private $request_options;
/**
* Gets the model slug.
*
* @since 0.5.0
*
* @return string The model slug.
*/
final public function get_model_slug(): string {
return $this->get_model_metadata()->get_slug();
}
/**
* Gets the model metadata.
*
* @since 0.7.0
*
* @return Model_Metadata The model metadata.
*
* @throws RuntimeException Thrown if the model metadata is not set.
*/
final public function get_model_metadata(): Model_Metadata {
if ( ! $this->metadata instanceof Model_Metadata ) {
throw new RuntimeException( 'Model metadata must be set in the constructor.' );
}
return $this->metadata;
}
/**
* Gets the request options.
*
* @since 0.5.0
*
* @return array<string, mixed> The request options.
*/
final protected function get_request_options(): array {
return $this->request_options;
}
/**
* Sets the model metadata.
*
* @since 0.7.0
*
* @param Model_Metadata $metadata The model metadata.
*/
final protected function set_model_metadata( Model_Metadata $metadata ): void {
$this->metadata = $metadata;
}
/**
* Sets the request options.
*
* @since 0.7.0
*
* @param array<string, mixed> $request_options The request options.
*/
final protected function set_request_options( array $request_options ): void {
$this->request_options = $request_options;
}
}

View File

@@ -0,0 +1,213 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Base\Abstract_AI_Service
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Base;
use ATFPP\AI_Translate\Services\API\Enums\Service_Type;
use ATFPP\AI_Translate\Services\API\Types\Model_Metadata;
use ATFPP\AI_Translate\Services\API\Types\Service_Metadata;
use ATFPP\AI_Translate\Services\Cache\Service_Request_Cache;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Model;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Service;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use ATFPP\AI_Translate\Services\Util\AI_Capabilities;
use InvalidArgumentException;
use RuntimeException;
/**
* Base class for an AI service.
*
* @since 0.7.0
*/
abstract class Abstract_AI_Service implements Generative_AI_Service {
/**
* The service metadata.
*
* @since 0.7.0
* @var Service_Metadata
*/
private $metadata;
/**
* Gets the service slug.
*
* @since 0.7.0
*
* @return string The service slug.
*/
final public function get_service_slug(): string {
return $this->get_service_metadata()->get_slug();
}
/**
* Gets the service metadata.
*
* @since 0.7.0
*
* @return Service_Metadata The service metadata.
*
* @throws RuntimeException Thrown if the service metadata is not set.
*/
final public function get_service_metadata(): Service_Metadata {
if ( ! $this->metadata instanceof Service_Metadata ) {
throw new RuntimeException( 'Service metadata must be set in the constructor.' );
}
return $this->metadata;
}
/**
* Checks whether the service is connected.
*
* In case of a cloud based service, this is typically used to check whether the current service credentials are
* valid. For other service types, this may check other requirements, or simply return true.
*
* @since 0.7.0
*
* @return bool True if the service is connected, false otherwise.
*
* @throws RuntimeException Thrown if the connection check cannot be performed.
*/
public function is_connected(): bool {
if ( Service_Type::CLOUD !== $this->get_service_metadata()->get_type() ) {
return true;
}
try {
$this->list_models();
return true;
} catch ( Generative_AI_Exception $e ) {
return false;
}
}
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Gets a generative model instance for the provided model parameters.
*
* @since 0.7.0
*
* @param array<string, mixed> $model_params {
* Optional. Model parameters. Default empty array.
*
* @type string $feature Required. Unique identifier of the feature that the model
* will be used for. Must only contain lowercase letters,
* numbers, hyphens.
* @type string $model The model slug. By default, the model will be determined
* based on heuristics such as the requested capabilities.
* @type string[] $capabilities Capabilities requested for the model to support. It is
* recommended to specify this if you do not explicitly specify
* a model slug.
* @type Tools|null $tools The tools to use for the model. Default none.
* @type Tool_Config|null $toolConfig Tool configuration options. Default none.
* @type Generation_Config|null $generationConfig Model generation configuration options. Default none.
* @type string|Parts|Content $systemInstruction The system instruction for the model. Default none.
* }
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Generative_AI_Model The generative model.
*
* @throws InvalidArgumentException Thrown if the model slug or parameters are invalid.
* @throws Generative_AI_Exception Thrown if getting the model fails.
*/
final public function get_model( array $model_params = array(), array $request_options = array() ): Generative_AI_Model {
$models_metadata = $this->cached_list_models( $request_options );
if ( isset( $model_params['model'] ) ) {
$model = $model_params['model'];
unset( $model_params['model'] );
} else {
if ( isset( $model_params['capabilities'] ) ) {
$model_slugs = AI_Capabilities::get_model_slugs_for_capabilities(
$models_metadata,
$model_params['capabilities']
);
} else {
$model_slugs = array_keys( $models_metadata );
}
$model = $this->sort_models_by_preference( $model_slugs )[0];
}
if ( ! isset( $models_metadata[ $model ] ) ) {
throw new InvalidArgumentException(
sprintf(
'Invalid model slug "%1$s" for the service "%2$s".',
htmlspecialchars( $model ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
htmlspecialchars( $this->get_service_slug() ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$model_metadata = $models_metadata[ $model ];
return $this->create_model_instance( $model_metadata, $model_params, $request_options );
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Creates a new model instance for the provided model metadata and parameters.
*
* @since 0.7.0
*
* @param Model_Metadata $model_metadata The model metadata.
* @param array<string, mixed> $model_params Model parameters. See {@see Generative_AI_Service::get_model()} for
* a list of available parameters.
* @param array<string, mixed> $request_options The request options.
* @return Generative_AI_Model The new model instance.
*/
abstract protected function create_model_instance( Model_Metadata $model_metadata, array $model_params, array $request_options ): Generative_AI_Model;
/**
* Sorts model slugs by preference.
*
* @since 0.7.0
*
* @param string[] $model_slugs The model slugs to sort.
* @return string[] The model slugs, sorted by preference.
*/
protected function sort_models_by_preference( array $model_slugs ): array {
// By default, no sorting is applied.
return $model_slugs;
}
/**
* Sets the service metadata.
*
* @since 0.7.0
*
* @param Service_Metadata $metadata The service metadata.
*/
final protected function set_service_metadata( Service_Metadata $metadata ): void {
$this->metadata = $metadata;
}
/**
* Lists the available generative model slugs and their metadata, wrapped in a transient cache.
*
* @since 0.7.0
*
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return array<string, Model_Metadata> Metadata for each model, mapped by model slug.
*
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
private function cached_list_models( array $request_options = array() ): array {
if ( ! function_exists( 'get_transient' ) ) {
// If the transient function is not available, we cannot cache the result.
return $this->list_models( $request_options );
}
return Service_Request_Cache::wrap_transient(
$this->get_service_slug(),
array( $this, 'list_models' ),
array( $request_options )
);
}
}

View File

@@ -0,0 +1,271 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Base\Abstract_Generation_Config
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Base;
use ATFPP\AI_Translate\Services\Contracts\Generation_Config;
use ATFPP\AI_Translate\Services\Util\Strings;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Base class representing configuration options for a generative AI model.
*
* @since 0.2.0 Originally implemented as non-abstract class `Types\Generation_Config`.
* @since 0.7.0
*/
abstract class Abstract_Generation_Config implements Generation_Config {
/**
* The sanitized configuration arguments.
*
* @since 0.2.0
* @var array<string, mixed>
*/
private $sanitized_args;
/**
* Any additional arguments, unsanitized.
*
* These are not used directly by the class, but are passed through to the API.
*
* @since 0.2.0
* @var array<string, mixed>
*/
private $additional_args;
/**
* Default values for the sanitized configuration arguments.
*
* @since 0.7.0
* @var array<string, mixed>
*/
private $defaults;
/**
* Constructor.
*
* @since 0.2.0
*
* @param array<string, mixed> $args The configuration arguments.
*/
final public function __construct( array $args ) {
$args_definition = $this->get_supported_args_definition();
$args = $this->sanitize_args( $args, $args_definition );
$this->sanitized_args = $args['sanitized'];
$this->additional_args = $args['additional'];
$this->defaults = $this->get_defaults( $args_definition );
}
/**
* Returns the value for the given supported argument.
*
* @since 0.7.0
*
* @param string $name The argument name.
* @return mixed The argument value, or its default value if not set.
*/
final public function get_arg( string $name ) {
if ( ! isset( $this->sanitized_args[ $name ] ) ) {
return $this->defaults[ $name ] ?? null;
}
return $this->sanitized_args[ $name ];
}
/**
* Returns all formally supported arguments.
*
* Only includes arguments that have an explicit value set, i.e. not defaults.
*
* @since 0.7.0
*
* @return array<string, mixed> The arguments.
*/
final public function get_args(): array {
return $this->sanitized_args;
}
/**
* Returns the additional arguments.
*
* @since 0.2.0
*
* @return array<string, mixed> The additional arguments.
*/
final public function get_additional_args(): array {
return $this->additional_args;
}
/**
* Returns the array representation.
*
* @since 0.2.0
*
* @return mixed[] Array representation.
*/
final public function to_array(): array {
return $this->sanitized_args + $this->additional_args;
}
/**
* Creates a Generation_Config instance from an array of content data.
*
* @since 0.2.0
*
* @param array<string, mixed> $data The content data.
* @return static Generation_Config instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): static {
return new static( $data );
}
/**
* Gets the definition for the supported arguments.
*
* @since 0.7.0
*
* @return array<string, mixed> The supported arguments definition.
*/
abstract protected function get_supported_args_definition(): array;
/**
* Gets the default values for the supported arguments.
*
* @since 0.7.0
*
* @param array<string, mixed> $args_definition The arguments definition.
* @return array<string, mixed> The default values.
*/
private function get_defaults( array $args_definition ): array {
$defaults = array();
foreach ( $args_definition as $key => $definition ) {
if ( isset( $definition['default'] ) ) {
$defaults[ $key ] = $definition['default'];
} elseif ( isset( $definition['type'] ) ) {
// Set default to type-safe value that is considered false-y.
switch ( $definition['type'] ) {
case 'array':
$defaults[ $key ] = array();
break;
case 'string':
$defaults[ $key ] = '';
break;
case 'object':
$defaults[ $key ] = array();
break;
case 'integer':
$defaults[ $key ] = 0;
break;
case 'number':
case 'float':
$defaults[ $key ] = 0.0;
break;
case 'boolean':
$defaults[ $key ] = false;
break;
}
}
}
return $defaults;
}
/**
* Sanitizes the given arguments.
*
* @since 0.2.0
* @since 0.7.0 The $args_definition parameter was added.
*
* @param array<string, mixed> $args The arguments to sanitize.
* @param array<string, mixed> $args_definition The arguments definition.
* @return array<string, array<string, mixed>> Associative array with keys 'sanitized' and 'additional', each
* containing an array of arguments. The 'sanitized' array contains
* the supported sanitized arguments, while the 'additional' array
* contains any additional arguments that are not supported, but can
* be passed through to the API.
*/
private function sanitize_args( array $args, array $args_definition ): array {
$sanitized = array();
$additional = array();
foreach ( $args as $key => $value ) {
if ( isset( $args_definition[ $key ] ) ) {
$sanitized[ $key ] = $this->sanitize_arg( $value, $args_definition[ $key ]['type'] ?? 'string', $key );
continue;
}
if ( str_contains( $key, '_' ) ) {
$camelcase_key = Strings::snake_case_to_camel_case( $key );
if ( isset( $args_definition[ $camelcase_key ] ) ) {
$sanitized[ $camelcase_key ] = $this->sanitize_arg( $value, $args_definition[ $camelcase_key ]['type'] ?? 'string', $camelcase_key );
continue;
}
}
$additional[ $key ] = $value;
}
return array(
'sanitized' => $sanitized,
'additional' => $additional,
);
}
/**
* Sanitizes the given value based on the given type.
*
* @since 0.2.0
*
* @param mixed $value The value to sanitize.
* @param string $type The type to sanitize the value to. Must be one of 'array', 'string', 'object',
* 'integer', 'float', or 'boolean'.
* @param string $arg_name The name of the argument being sanitized.
* @return mixed The sanitized value.
*
* @throws InvalidArgumentException Thrown if the type is not supported or the value is invalid.
*/
protected function sanitize_arg( $value, string $type, string $arg_name ) {
switch ( $type ) {
case 'array':
if ( ! is_array( $value ) ) {
if ( ! $value ) {
return array();
}
return array( $value );
}
return array_values( $value );
case 'string':
return (string) $value;
case 'object':
if ( ! is_array( $value ) ) {
if ( is_object( $value ) ) {
if ( $value instanceof Arrayable ) {
return $value->to_array();
}
return (array) $value;
}
return array();
}
return $value;
case 'integer':
return (int) $value;
case 'float':
return (float) $value;
case 'boolean':
return (bool) $value;
default:
throw new InvalidArgumentException( 'Unsupported type.' );
}
}
}

View File

@@ -0,0 +1,250 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Base\Generic_AI_API_Client
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Base;
use ATFPP\AI_Translate\Services\Contracts\Authentication;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_API_Client;
use ATFPP\AI_Translate\Services\Traits\Generative_AI_API_Client_Trait;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request_Handler;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Get_Request;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\JSON_Post_Request;
/**
* Generic implementation of an AI API client, configured via constructor parameters.
*
* @since 0.7.0
*/
class Generic_AI_API_Client implements Generative_AI_API_Client {
use Generative_AI_API_Client_Trait;
/**
* The base URL for the API.
*
* @since 0.7.0
* @var string
*/
private $default_base_url;
/**
* The API version.
*
* @since 0.7.0
* @var string
*/
private $default_api_version;
/**
* The (human-readable) API name.
*
* @since 0.7.0
* @var string
*/
private $api_name;
/**
* The request handler instance.
*
* @since 0.7.0
* @var Request_Handler
*/
private $request_handler;
/**
* The authentication instance.
*
* @since 0.7.0
* @var Authentication|null
*/
private $authentication;
/**
* Constructor.
*
* @since 0.7.0
*
* @param string $default_base_url The default base URL for the API.
* @param string $default_api_version The default API version.
* @param string $api_name The (human-readable) API name.
* @param Request_Handler $request_handler The request handler instance.
* @param Authentication|null $authentication Optional. The authentication instance. Default null.
*/
public function __construct(
string $default_base_url,
string $default_api_version,
string $api_name,
Request_Handler $request_handler,
?Authentication $authentication = null
) {
$this->default_base_url = $default_base_url;
$this->default_api_version = $default_api_version;
$this->api_name = $api_name;
$this->request_handler = $request_handler;
$this->authentication = $authentication;
}
/**
* Creates a GET request instance for the given parameters.
*
* @since 0.7.0
*
* @param string $path The path to the API endpoint, relative to the base URL and version.
* @param array<string, mixed> $params The request parameters.
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Request The request instance.
*/
public function create_get_request( string $path, array $params, array $request_options = array() ): Request {
$request_url = $this->get_request_url( $path, $request_options );
$request_options = $this->filter_request_options(
$this->add_default_options( $request_options ),
$request_url
);
$request = new Get_Request(
$request_url,
$params,
$request_options
);
$this->authenticate_request( $request );
return $request;
}
/**
* Creates a POST request instance for the given parameters.
*
* @since 0.7.0
*
* @param string $path The path to the API endpoint, relative to the base URL and version.
* @param array<string, mixed> $params The request parameters.
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Request The request instance.
*/
public function create_post_request( string $path, array $params, array $request_options = array() ): Request {
$request_url = $this->get_request_url( $path, $request_options );
$request_options = $this->filter_request_options(
$this->add_default_options( $request_options ),
$request_url
);
$request = new JSON_Post_Request(
$request_url,
$params,
$request_options
);
$this->authenticate_request( $request );
return $request;
}
/**
* Gets the request URL for the specified model and task.
*
* @since 0.7.0
*
* @param string $path The path to the API endpoint, relative to the base URL and version.
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return string The request URL.
*/
protected function get_request_url( string $path, array $request_options = array() ): string {
$base_url = $request_options['base_url'] ?? $this->default_base_url;
$api_version = $request_options['api_version'] ?? $this->default_api_version;
$path = ltrim( $path, '/' );
if ( isset( $request_options['stream'] ) && $request_options['stream'] && ! str_ends_with( $path, '?alt=sse' ) ) {
$path .= '?alt=sse';
}
if ( '' === $api_version ) {
return "{$base_url}/{$path}";
}
return "{$base_url}/{$api_version}/{$path}";
}
/**
* Adds additional default request options to the given request options.
*
* @since 0.7.0
*
* @param array<string, mixed> $request_options The request options.
* @return array<string, mixed> The updated request options.
*/
protected function add_default_options( array $request_options ): array {
if ( ! isset( $request_options['timeout'] ) ) {
$request_options['timeout'] = 120;
}
return $request_options;
}
/**
* Authenticates the request, if an authentication instance is set.
*
* @since 0.7.0
*
* @param Request $request The request to authenticate.
*/
final protected function authenticate_request( Request $request ): void {
if ( $this->authentication ) {
$this->authentication->authenticate( $request );
}
}
/**
* Returns the human readable API name (without the "API" suffix).
*
* @since 0.7.0
*
* @return string The API name.
*/
final protected function get_api_name(): string {
return $this->api_name;
}
/**
* Returns the request handler instance to use for requests.
*
* @since 0.7.0
*
* @return Request_Handler The request handler instance.
*/
final protected function get_request_handler(): Request_Handler {
return $this->request_handler;
}
/**
* Filters the request options, with awareness of the request URL.
*
* @since 0.7.0
*
* @param array<string, mixed> $request_options The request options.
* @param string $request_url The request URL.
* @return array<string, mixed> The filtered request options.
*/
private function filter_request_options( array $request_options, string $request_url ): array {
if ( isset( $request_options['timeout'] ) ) {
$timeout = $request_options['timeout'];
/**
* Filters the request timeout to use for an API request to an AI service.
*
* @since 0.7.0
*
* @param int $timeout The request timeout in seconds.
* @param string $request_url The request URL.
*/
$request_options['timeout'] = (int) apply_filters( 'ai_services_request_timeout', $timeout, $request_url );
// If the filtered timeout is invalid, use the original value.
if ( $request_options['timeout'] <= 0 ) {
$request_options['timeout'] = $timeout;
}
}
return $request_options;
}
}

View File

@@ -0,0 +1,422 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Base\OpenAI_Compatible_AI_Text_Generation_Model
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Base;
use ATFPP\AI_Translate\Services\API\Enums\Content_Role;
use ATFPP\AI_Translate\Services\API\Types\Candidate;
use ATFPP\AI_Translate\Services\API\Types\Candidates;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Model_Metadata;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\API\Types\Parts\Text_Part;
use ATFPP\AI_Translate\Services\API\Types\Text_Generation_Config;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_API_Client;
use ATFPP\AI_Translate\Services\Contracts\With_API_Client;
use ATFPP\AI_Translate\Services\Contracts\With_Text_Generation;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use ATFPP\AI_Translate\Services\Traits\Model_Param_System_Instruction_Trait;
use ATFPP\AI_Translate\Services\Traits\Model_Param_Text_Generation_Config_Trait;
use ATFPP\AI_Translate\Services\Traits\With_API_Client_Trait;
use ATFPP\AI_Translate\Services\Traits\With_Text_Generation_Trait;
use ATFPP\AI_Translate\Services\Util\Transformer;
use Generator;
use InvalidArgumentException;
/**
* Generic implementation of an OpenAI API compatible text generation AI model.
*
* @since 0.7.0
*/
class OpenAI_Compatible_AI_Text_Generation_Model extends Abstract_AI_Model implements With_API_Client, With_Text_Generation {
use With_API_Client_Trait;
use With_Text_Generation_Trait;
use Model_Param_Text_Generation_Config_Trait;
use Model_Param_System_Instruction_Trait;
/**
* Constructor.
*
* @since 0.7.0
*
* @param Generative_AI_API_Client $api_client The AI API client instance.
* @param Model_Metadata $metadata The model metadata.
* @param array<string, mixed> $model_params Optional. Additional model parameters. See
* {@see OpenAI_AI_Service::get_model()} for the list of available
* parameters. Default empty array.
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
*
* @throws InvalidArgumentException Thrown if the model parameters are invalid.
*/
public function __construct( Generative_AI_API_Client $api_client, Model_Metadata $metadata, array $model_params = array(), array $request_options = array() ) {
$this->set_api_client( $api_client );
$this->set_model_metadata( $metadata );
$this->set_text_generation_config_from_model_params( $model_params );
$this->set_system_instruction_from_model_params( $model_params );
$this->set_request_options( $request_options );
}
/**
* Sends a request to generate text content.
*
* @since 0.7.0
*
* @param Content[] $contents Prompts for the content to generate.
* @param array<string, mixed> $request_options The request options.
* @return Candidates The response candidates with generated text content - usually just one.
*
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
final protected function send_generate_text_request( array $contents, array $request_options ): Candidates {
$api = $this->get_api_client();
$params = $this->prepare_generate_text_params( $contents );
$params['model'] = $this->get_model_slug();
$request = $api->create_post_request(
'chat/completions',
$params,
array_merge(
$this->get_request_options(),
$request_options
)
);
$response = $api->make_request( $request );
return $api->process_response_data(
$response,
function ( $response_data ) {
return $this->get_response_candidates( $response_data );
}
);
}
/**
* Sends a request to generate text content, streaming the response.
*
* @since 0.7.0
*
* @param Content[] $contents Prompts for the content to generate.
* @param array<string, mixed> $request_options The request options.
* @return Generator<Candidates> Generator that yields the chunks of response candidates with generated text
* content - usually just one candidate.
*
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
final protected function send_stream_generate_text_request( array $contents, array $request_options ): Generator {
$api = $this->get_api_client();
$params = $this->prepare_generate_text_params( $contents );
$params['model'] = $this->get_model_slug();
$params['stream'] = true;
$request = $api->create_post_request(
'chat/completions',
$params,
array_merge(
$this->get_request_options(),
$request_options,
array( 'stream' => true )
)
);
$response = $api->make_request( $request );
return $api->process_response_stream(
$response,
function ( $response_data, $prev_chunk_candidates ) {
return $this->get_response_candidates( $response_data, $prev_chunk_candidates );
}
);
}
/**
* Prepares the API request parameters for generating text content.
*
* @since 0.7.0
*
* @param Content[] $contents The contents to generate text for.
* @return array<string, mixed> The parameters for generating text content.
*/
protected function prepare_generate_text_params( array $contents ): array {
if ( $this->get_system_instruction() ) {
$contents = array_merge( array( $this->get_system_instruction() ), $contents );
}
$transformers = $this->get_content_transformers();
$params = array(
'messages' => array_map(
static function ( Content $content ) use ( $transformers ) {
return Transformer::transform_content( $content, $transformers );
},
$contents
),
);
$generation_config = $this->get_text_generation_config();
if ( $generation_config ) {
$params = Transformer::transform_generation_config_params(
array_merge( $generation_config->get_additional_args(), $params ),
$generation_config,
$this->get_generation_config_transformers()
);
}
return $params;
}
/**
* Extracts the candidates with content from the response.
*
* @since 0.7.0
*
* @param array<string, mixed> $response_data The response data.
* @param ?Candidates $prev_chunk_candidates The candidates from the previous chunk in case of a streaming
* response, or null.
* @return Candidates The candidates with content parts.
*
* @throws Generative_AI_Exception Thrown if the response does not have any candidates with content.
*/
private function get_response_candidates( array $response_data, ?Candidates $prev_chunk_candidates = null ): Candidates {
if ( null === $prev_chunk_candidates ) {
// Regular (non-streaming) response, or first chunk of a streaming response.
if ( ! isset( $response_data['choices'] ) ) {
throw $this->get_api_client()->create_missing_response_key_exception( 'choices' );
}
$other_data = $response_data;
unset( $other_data['choices'] );
$candidates = new Candidates();
foreach ( $response_data['choices'] as $index => $candidate_data ) {
if ( isset( $candidate_data['delta'] ) && ! isset( $candidate_data['message'] ) ) {
$candidate_data['message'] = $candidate_data['delta'];
unset( $candidate_data['delta'] );
}
$other_candidate_data = $candidate_data;
unset( $other_candidate_data['message'] );
$candidates->add_candidate(
new Candidate(
$this->prepare_response_candidate_content( $candidate_data, $index ),
array_merge( $other_candidate_data, $other_data )
)
);
}
return $candidates;
}
// Subsequent chunk of a streaming response.
$candidates_data = $this->merge_candidates_chunk(
$prev_chunk_candidates->to_array(),
$response_data
);
return Candidates::from_array( $candidates_data );
}
/**
* Merges a streaming response chunk with the previous candidates data.
*
* @since 0.7.0
*
* @param array<string, mixed> $candidates_data The candidates data from the previous chunk.
* @param array<string, mixed> $chunk_data The response chunk data.
* @return array<string, mixed> The merged candidates data.
*
* @throws Generative_AI_Exception Thrown if the response is invalid.
*/
private function merge_candidates_chunk( array $candidates_data, array $chunk_data ): array {
if ( ! isset( $chunk_data['choices'] ) ) {
throw $this->get_api_client()->create_missing_response_key_exception( 'choices' );
}
$other_data = $chunk_data;
unset( $other_data['choices'] );
foreach ( $chunk_data['choices'] as $index => $candidate_data ) {
if ( isset( $candidate_data['delta']['reasoning_content'] ) ) {
$candidates_data[ $index ]['content']['parts'][0]['text'] = $candidate_data['delta']['reasoning_content'];
} elseif ( isset( $candidate_data['delta']['content'] ) ) {
$candidates_data[ $index ]['content']['parts'][0]['text'] = $candidate_data['delta']['content'];
} else {
// If there was a previous content block, ensure it is ends in a double newline.
if (
isset( $candidate_data['finish_reason'] ) &&
'stop' === $candidate_data['finish_reason'] &&
'' !== $candidates_data[ $index ]['content']['parts'][0]['text'] &&
! str_ends_with( $candidates_data[ $index ]['content']['parts'][0]['text'], "\n\n" )
) {
$text_suffix = str_ends_with( $candidates_data[ $index ]['content']['parts'][0]['text'], "\n" ) ? "\n" : "\n\n";
} else {
$text_suffix = '';
}
$candidates_data[ $index ]['content']['parts'][0]['text'] = $text_suffix;
}
unset( $candidate_data['delta'] );
$candidates_data[ $index ] = array_merge( $candidates_data[ $index ], $candidate_data, $other_data );
}
return $candidates_data;
}
/**
* Transforms a given candidate from the API response into a Content instance.
*
* @since 0.7.0
*
* @param array<string, mixed> $candidate_data The API response candidate data.
* @param int $index The index of the candidate in the response.
* @return Content The Content instance.
*
* @throws Generative_AI_Exception Thrown if the response is invalid.
*/
private function prepare_response_candidate_content( array $candidate_data, int $index ): Content {
if ( ! isset( $candidate_data['message'] ) ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw $this->get_api_client()->create_missing_response_key_exception( "choices.{$index}.message" );
}
$role = isset( $candidate_data['message']['role'] ) && 'user' === $candidate_data['message']['role']
? Content_Role::USER
: Content_Role::MODEL;
$parts = $this->prepare_response_candidate_content_parts( $candidate_data );
if ( count( $parts ) === 0 ) {
throw $this->get_api_client()->create_response_exception(
'Could not resolve content parts: The response includes unexpected content.'
);
}
return new Content(
$role,
$parts
);
}
/**
* Transforms a given candidate from the API response into a Parts instance.
*
* @since 0.7.0
*
* @param array<string, mixed> $candidate_data The API response candidate data.
* @return Parts The Parts instance.
*
* @throws Generative_AI_Exception Thrown if the response is invalid.
*/
protected function prepare_response_candidate_content_parts( array $candidate_data ): Parts {
$parts = array();
if ( isset( $candidate_data['message']['reasoning_content'] ) && is_string( $candidate_data['message']['reasoning_content'] ) ) {
$parts[] = array( 'text' => $candidate_data['message']['reasoning_content'] );
}
if ( isset( $candidate_data['message']['content'] ) && is_string( $candidate_data['message']['content'] ) ) {
$parts[] = array( 'text' => $candidate_data['message']['content'] );
}
return Parts::from_array( $parts );
}
/**
* Gets the content transformers.
*
* @since 0.7.0
*
* @return array<string, callable> The content transformers.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
protected function get_content_transformers(): array {
$api_client = $this->get_api_client();
return array(
'role' => static function ( Content $content ) {
if ( $content->get_role() === Content_Role::MODEL ) {
return 'assistant';
}
if ( $content->get_role() === Content_Role::SYSTEM ) {
return 'system';
}
return 'user';
},
'content' => static function ( Content $content ) use ( $api_client ) {
$parts = array();
foreach ( $content->get_parts() as $part ) {
if ( $part instanceof Text_Part ) {
$parts[] = array(
'type' => 'text',
'text' => $part->get_text(),
);
} else {
throw $api_client->create_bad_request_exception(
'The API only supports text, image, and audio parts.'
);
}
}
return $parts;
},
);
}
/**
* Gets the generation configuration transformers.
*
* @since 0.7.0
*
* @return array<string, callable> The generation configuration transformers.
*/
protected function get_generation_config_transformers(): array {
return array(
'stop' => static function ( Text_Generation_Config $config ) {
return $config->get_stop_sequences();
},
'response_format' => static function ( Text_Generation_Config $config ) {
if ( $config->get_response_mime_type() === 'application/json' ) {
$schema = $config->get_response_schema();
if ( $schema ) {
return array(
'type' => 'json_schema',
'json_schema' => $schema,
);
}
return array( 'type' => 'json_object' );
}
return array();
},
'n' => static function ( Text_Generation_Config $config ) {
return $config->get_candidate_count();
},
'max_completion_tokens' => static function ( Text_Generation_Config $config ) {
return $config->get_max_output_tokens();
},
'temperature' => static function ( Text_Generation_Config $config ) {
return $config->get_temperature();
},
'top_p' => static function ( Text_Generation_Config $config ) {
return $config->get_top_p();
},
'presence_penalty' => static function ( Text_Generation_Config $config ) {
return $config->get_presence_penalty();
},
'frequency_penalty' => static function ( Text_Generation_Config $config ) {
return $config->get_frequency_penalty();
},
'logprobs' => static function ( Text_Generation_Config $config ) {
return $config->get_response_logprobs();
},
'top_logprobs' => static function ( Text_Generation_Config $config ) {
return $config->get_logprobs();
},
);
}
}