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,151 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Candidates_Stream_Processor
*
* @since 0.3.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API;
use ATFPP\AI_Translate\Services\API\Types\Candidates;
use Generator;
/**
* Class to process a candidates stream.
*
* @since 0.3.0
*/
final class Candidates_Stream_Processor {
/**
* Generator that yields the chunks of response candidates.
*
* @since 0.3.0
* @var Generator<Candidates>
*/
private $generator;
/**
* The overall candidates instance.
*
* May be incomplete if the stream has not been fully processed yet.
*
* @since 0.3.0
* @var Candidates|null
*/
private $candidates;
/**
* Constructor.
*
* @since 0.3.0
*
* @param Generator<Candidates> $generator The generator that yields the chunks of response candidates.
*/
public function __construct( Generator $generator ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$this->generator = $generator;
}
/**
* Reads all chunks from the generator and adds them to the overall candidates instance.
*
* A callback can be passed that is called for each chunk of candidates. You could use such a callback for example
* to echo the text contents of each chunk as they are being processed.
*
* @since 0.3.0
*
* @param callable|null $chunk_callback Optional. Callback that is called for each chunk of candidates.
* @return Candidates The complete candidates instance.
*/
public function read_all( ?callable $chunk_callback = null ): Candidates {
foreach ( $this->generator as $candidates ) {
$this->add_chunk( $candidates );
if ( null !== $chunk_callback ) {
$chunk_callback( $candidates );
}
}
return $this->get_complete();
}
/**
* Adds a chunk of candidates to the overall candidates instance.
*
* @since 0.3.0
*
* @param Candidates $candidates The chunk of candidates to add.
*/
public function add_chunk( Candidates $candidates ): void {
if ( null === $this->candidates ) {
$this->candidates = $candidates;
return;
}
$existing_candidates = $this->candidates->to_array();
$new_candidates = $candidates->to_array();
foreach ( $new_candidates as $index => $new_candidate ) {
if ( ! isset( $existing_candidates[ $index ] ) ) {
$existing_candidates[] = $new_candidate;
continue;
}
if ( isset( $existing_candidates[ $index ]['content'] ) && isset( $new_candidate['content'] ) ) {
$existing_candidates[ $index ]['content'] = $this->append_content(
$existing_candidates[ $index ]['content'],
$new_candidate['content']
);
unset( $new_candidate['content'] );
}
$existing_candidates[ $index ] = array_merge( $existing_candidates[ $index ], $new_candidate );
}
$this->candidates = Candidates::from_array( $existing_candidates );
}
/**
* Gets the complete candidates instance.
*
* @since 0.3.0
*
* @return Candidates|null The complete candidates instance, or null if the generator is not done yet.
*/
public function get_complete(): ?Candidates {
// Only return the candidates if the generator is done.
if ( $this->generator->valid() ) {
return null;
}
return $this->candidates;
}
/**
* Appends the content of a new candidate to the content of an existing candidate.
*
* @since 0.3.0
*
* @param array<string, mixed> $existing_content The existing content data.
* @param array<string, mixed> $new_content The new content data.
* @return array<string, mixed> The combined content data.
*/
private function append_content( array $existing_content, array $new_content ) {
if ( ! isset( $existing_content['parts'] ) || ! isset( $new_content['parts'] ) ) {
return $existing_content;
}
foreach ( $new_content['parts'] as $index => $new_part ) {
if ( ! isset( $existing_content['parts'][ $index ] ) ) {
$existing_content['parts'][] = $new_part;
continue;
}
if ( ! isset( $existing_content['parts'][ $index ]['text'] ) || ! isset( $new_part['text'] ) ) {
continue;
}
$existing_content['parts'][ $index ]['text'] .= $new_part['text'];
}
return $existing_content;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Enums\AI_Capability
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums;
/**
* Class for the AI capability enum.
*
* @since 0.2.0
*/
final class AI_Capability extends Abstract_Enum {
const CHAT_HISTORY = 'chat_history';
const FUNCTION_CALLING = 'function_calling';
const IMAGE_GENERATION = 'image_generation';
const MULTIMODAL_INPUT = 'multimodal_input';
const MULTIMODAL_OUTPUT = 'multimodal_output';
const TEXT_GENERATION = 'text_generation';
const TEXT_TO_SPEECH = 'text_to_speech';
const WEB_SEARCH = 'web_search';
/**
* Gets all values for the enum.
*
* @since 0.2.0
*
* @return string[] The list of all values.
*/
protected static function get_all_values(): array {
return array(
self::CHAT_HISTORY,
self::FUNCTION_CALLING,
self::IMAGE_GENERATION,
self::MULTIMODAL_INPUT,
self::MULTIMODAL_OUTPUT,
self::TEXT_GENERATION,
self::TEXT_TO_SPEECH,
self::WEB_SEARCH,
);
}
}

View File

@@ -0,0 +1,76 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Enums\Abstract_Enum
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums;
use ATFPP\AI_Translate\Services\API\Enums\Contracts\Enum;
/**
* Base class for an enum.
*
* @since 0.2.0
*/
abstract class Abstract_Enum implements Enum {
/**
* The value map, to store in memory which values are valid.
*
* @since 0.2.0
* @var array<string, array<string, bool>>
*/
private static $value_map = array();
/**
* Checks if the given value is valid for the enum.
*
* @since 0.2.0
*
* @param string $value The value to check.
* @return bool True if the value is valid, false otherwise.
*/
final public static function is_valid_value( string $value ): bool {
$value_map = self::get_value_map_for_class( static::class );
return isset( $value_map[ $value ] );
}
/**
* Gets the list of valid values for the enum.
*
* @since 0.2.0
*
* @return string[] The list of valid values.
*/
final public static function get_values(): array {
$value_map = self::get_value_map_for_class( static::class );
return array_keys( $value_map );
}
/**
* Gets the value map for the given child class name.
*
* @since 0.7.0
*
* @param string $class_name The child class name.
* @return array<string, bool> The value map.
*/
private static function get_value_map_for_class( string $class_name ): array {
if ( ! isset( self::$value_map[ $class_name ] ) ) {
self::$value_map[ $class_name ] = array_fill_keys( call_user_func( array( $class_name, 'get_all_values' ) ), true );
}
return self::$value_map[ $class_name ];
}
/**
* Gets all values for the enum.
*
* @since 0.2.0
*
* @return string[] The list of all values.
*/
abstract protected static function get_all_values(): array;
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Enums\Content_Role
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums;
/**
* Class for the content role enum.
*
* @since 0.2.0
*/
final class Content_Role extends Abstract_Enum {
const USER = 'user';
const MODEL = 'model';
const SYSTEM = 'system';
/**
* Gets all values for the enum.
*
* @since 0.2.0
*
* @return string[] The list of all values.
*/
protected static function get_all_values(): array {
return array(
self::USER,
self::MODEL,
self::SYSTEM,
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\API\Enums\Contracts\Enum
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums\Contracts;
/**
* Interface for a class for an enum.
*
* @since 0.2.0
*/
interface Enum {
/**
* Checks if the given value is valid for the enum.
*
* @since 0.2.0
*
* @param string $value The value to check.
* @return bool True if the value is valid, false otherwise.
*/
public static function is_valid_value( string $value ): bool;
/**
* Gets the list of valid values for the enum.
*
* @since 0.2.0
*
* @return string[] The list of valid values.
*/
public static function get_values(): array;
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Enums\Modality
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums;
/**
* Class for the modality enum.
*
* @since 0.7.0
*/
final class Modality extends Abstract_Enum {
const TEXT = 'text';
const IMAGE = 'image';
const AUDIO = 'audio';
/**
* Gets all values for the enum.
*
* @since 0.7.0
*
* @return string[] The list of all values.
*/
protected static function get_all_values(): array {
return array(
self::TEXT,
self::IMAGE,
self::AUDIO,
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Enums\Service_Type
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Enums;
/**
* Class for the service type enum.
*
* @since 0.7.0
*/
final class Service_Type extends Abstract_Enum {
const CLOUD = 'cloud';
const SERVER = 'server';
const CLIENT = 'client';
/**
* Gets all values for the enum.
*
* @since 0.7.0
*
* @return string[] The list of all values.
*/
protected static function get_all_values(): array {
return array(
self::CLOUD,
self::SERVER,
self::CLIENT,
);
}
}

View File

@@ -0,0 +1,272 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Helpers
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API;
use ATFPP\AI_Translate\Services\API\Enums\Content_Role;
use ATFPP\AI_Translate\Services\API\Types\Blob;
use ATFPP\AI_Translate\Services\API\Types\Candidates;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\API\Types\Parts\Text_Part;
use ATFPP\AI_Translate\Services\Util\Formatter;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Current_User;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Meta\Meta_Repository;
use Generator;
use InvalidArgumentException;
/**
* Class providing static helper methods as part of the public API.
*
* @since 0.2.0
*
* @SuppressWarnings(PHPMD.TooManyPublicMethods)
*/
final class Helpers {
/**
* Converts a text string to a Content instance.
*
* @since 0.2.0
*
* @param string $text The text.
* @param string $role Optional. The role to use for the content. Default 'user'.
* @return Content The content instance.
*/
public static function text_to_content( string $text, string $role = Content_Role::USER ): Content {
return Formatter::format_content( $text, $role );
}
/**
* Converts a Content instance to a text string.
*
* This method will return the combined text from all consecutive text parts in the content.
* Realistically, this should almost always return the text from just one part, as API responses typically do not
* contain multiple text parts in a row - but it might be possible.
*
* @since 0.2.0
*
* @param Content $content The content instance.
* @return string The text, or an empty string if there are no text parts.
*/
public static function content_to_text( Content $content ): string {
$parts = $content->get_parts();
$text_parts = array();
foreach ( $parts as $part ) {
/*
* If there is any non-text part present, we want to ensure that no interrupted text content is returned.
* Therefore, we break the loop as soon as we encounter a non-text part, unless no text parts have been
* found yet, in which case the text may only start with a later part.
*/
if ( ! $part instanceof Text_Part ) {
if ( count( $text_parts ) > 0 ) {
break;
}
continue;
}
$text_parts[] = trim( $part->get_text() );
}
if ( count( $text_parts ) === 0 ) {
return '';
}
return implode( "\n\n", $text_parts );
}
/**
* Gets the text from the first Content instance in the given list which contains text.
*
* @since 0.2.0
*
* @param Content[] $contents The list of Content instances.
* @return string The text, or an empty string if no Content instance has text parts.
*/
public static function get_text_from_contents( array $contents ): string {
foreach ( $contents as $content ) {
$text = self::content_to_text( $content );
if ( '' !== $text ) {
return $text;
}
}
return '';
}
/**
* Gets the first Content instance in the given list which contains text.
*
* @since 0.5.0
*
* @param Content[] $contents The list of Content instances.
* @return Content|null The Content instance, or null if no Content instance has text parts.
*/
public static function get_text_content_from_contents( array $contents ): ?Content {
foreach ( $contents as $content ) {
$text = self::content_to_text( $content );
if ( '' !== $text ) {
return $content;
}
}
return null;
}
/**
* Gets the Content instances for each candidate in the given list.
*
* @since 0.2.0
*
* @param Candidates $candidates The list of candidates.
* @return Content[] The list of Content instances.
*/
public static function get_candidate_contents( Candidates $candidates ): array {
$contents = array();
foreach ( $candidates as $candidate ) {
$content = $candidate->get_content();
if ( ! $content ) {
continue;
}
$contents[] = $content;
}
return $contents;
}
/**
* Processes a stream of candidates, aggregating the candidates chunks into a single candidates instance.
*
* This method returns a stream processor instance that can be used to read all chunks from the given candidates
* generator and process them with a callback. Alternatively, you can read from the generator yourself and provide
* all chunks to the processor manually.
*
* @since 0.3.0
*
* @param Generator<Candidates> $generator The generator that yields the chunks of response candidates.
* @return Candidates_Stream_Processor The stream processor instance.
*/
public static function process_candidates_stream( Generator $generator ): Candidates_Stream_Processor { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
return new Candidates_Stream_Processor( $generator );
}
/**
* Returns the base64-encoded data URL representation of the given file URL.
*
* @since 0.5.0
*
* @param string $file Absolute path to the file, or its URL.
* @param string $mime_type Optional. The MIME type of the file. If provided, the base64-encoded data URL will
* be prefixed with `data:{mime_type};base64,`. Default empty string.
* @return string The base64-encoded file data URL, or empty string on failure.
*/
public static function file_to_base64_data_url( string $file, string $mime_type = '' ): string {
$blob = self::file_to_blob( $file, $mime_type );
if ( ! $blob ) {
return '';
}
return self::blob_to_base64_data_url( $blob );
}
/**
* Returns the binary data blob representation of the given file URL.
*
* @since 0.5.0
*
* @param string $file Absolute path to the file, or its URL.
* @param string $mime_type Optional. The MIME type of the file. If provided, the automatically detected MIME type
* will be overwritten. Default empty string.
* @return Blob|null The binary data blob, or null on failure.
*/
public static function file_to_blob( string $file, string $mime_type = '' ): ?Blob {
try {
return Blob::from_file( $file, $mime_type );
} catch ( InvalidArgumentException $e ) {
return null;
}
}
/**
* Returns the base64-encoded data URL representation of the given binary data blob.
*
* @since 0.5.0
*
* @param Blob $blob The binary data blob.
* @return string The base64-encoded file data URL, or empty string on failure.
*/
public static function blob_to_base64_data_url( Blob $blob ): string {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
$base64 = base64_encode( $blob->get_binary_data() );
$mime_type = $blob->get_mime_type();
return "data:$mime_type;base64,$base64";
}
/**
* Returns the binary data blob representation of the given base64-encoded data URL.
*
* @since 0.5.0
*
* @param string $base64_data_url The base64-encoded data URL.
* @return Blob|null The binary data blob, or null on failure.
*/
public static function base64_data_url_to_blob( string $base64_data_url ): ?Blob {
if ( ! preg_match( '/^data:([a-z0-9-]+\/[a-z0-9-]+);base64,/', $base64_data_url, $matches ) ) {
return null;
}
$base64 = substr( $base64_data_url, strlen( $matches[0] ) );
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$binary_data = base64_decode( $base64 );
if ( false === $binary_data ) {
return null;
}
return new Blob( $binary_data, $matches[1] );
}
/**
* Ensures the given base64 data is prefixed correctly to be a data URL.
*
* @since 0.6.0
*
* @param string $base64_data Base64-encoded data. If it is already a data URL, it will be returned as is.
* @param string $mime_type MIME type for the data.
* @return string The base64 data URL.
*/
public static function base64_data_to_base64_data_url( string $base64_data, string $mime_type ): string {
if ( str_starts_with( $base64_data, 'data:' ) ) {
return $base64_data;
}
return 'data:' . $mime_type . ';base64,' . $base64_data;
}
/**
* Ensures the given base64 data URL has its prefix removed to be just the base64 data.
*
* @since 0.6.0
*
* @param string $base64_data_url Base64 data URL. If it is already without prefix, it will be returned as is.
* @return string The base64-encoded data.
*/
public static function base64_data_url_to_base64_data( string $base64_data_url ): string {
if ( ! str_starts_with( $base64_data_url, 'data:' ) ) {
return $base64_data_url;
}
return preg_replace(
'/^data:[a-z0-9-]+\/[a-z0-9-]+;base64,/',
'',
$base64_data_url
);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Blob
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use InvalidArgumentException;
/**
* Simple value class representing a binary data blob, e.g. from a file.
*
* @since 0.5.0
*/
final class Blob {
/**
* The binary data of the blob.
*
* @since 0.5.0
* @var string
*/
private $binary_data;
/**
* The MIME type of the blob.
*
* @since 0.5.0
* @var string
*/
private $mime_type;
/**
* Constructor.
*
* @since 0.5.0
*
* @param string $binary_data The binary data of the blob.
* @param string $mime_type The MIME type of the blob.
*/
public function __construct( string $binary_data, string $mime_type ) {
$this->binary_data = $binary_data;
$this->mime_type = $mime_type;
}
/**
* Retrieves the binary data of the blob.
*
* @since 0.5.0
*
* @return string The binary data.
*/
public function get_binary_data(): string {
return $this->binary_data;
}
/**
* Retrieves the MIME type of the blob.
*
* @since 0.5.0
*
* @return string The MIME type.
*/
public function get_mime_type(): string {
return $this->mime_type;
}
/**
* Creates a new blob instance from a file.
*
* @since 0.5.0
*
* @param string $file The file path or URL.
* @param string $mime_type Optional. MIME type, to override the automatic detection. Default empty string.
* @return Blob The blob instance.
*
* @throws InvalidArgumentException Thrown if the file could not be read or if the MIME type cannot be determined.
*/
public static function from_file( string $file, string $mime_type = '' ): self {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$blob = file_get_contents( $file );
if ( ! $blob ) {
throw new InvalidArgumentException(
sprintf(
'Could not read file %s.',
htmlspecialchars( $file ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
if ( ! $mime_type ) {
$file_type = wp_check_filetype( $file );
if ( ! $file_type['type'] ) {
throw new InvalidArgumentException(
sprintf(
'Could not determine MIME type of file %s.',
htmlspecialchars( $file ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$mime_type = $file_type['type'];
}
return new self( $blob, $mime_type );
}
}

View File

@@ -0,0 +1,189 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Candidate
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\API\Enums\Content_Role;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Class representing a candidate for a content response from a generative AI model.
*
* @since 0.1.0
*/
final class Candidate implements Arrayable {
/**
* The content, unless no content is available as part of the candidate.
*
* @since 0.1.0
* @var ?Content
*/
private $content;
/**
* Additional data for the candidate, if any.
*
* @since 0.1.0
* @var array<string, mixed>
*/
private $additional_data;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ?Content $content The content, or null to indicate no content is available.
* @param array<string, mixed> $additional_data Additional data for the candidate, if any.
*/
public function __construct( ?Content $content, array $additional_data = array() ) {
$this->content = $content;
// Remove the content from the additional data, if present, to prevent conflicts.
unset( $additional_data['content'] );
$this->additional_data = $additional_data;
}
/**
* Gets the content.
*
* @since 0.1.0
*
* @return ?Content The content.
*/
public function get_content(): ?Content {
return $this->content;
}
/**
* Gets a field value from the additional data.
*
* @since 0.1.0
*
* @param string $field The field name.
* @return mixed|null The field value, or null if not found.
*/
public function get_field_value( string $field ) {
if ( isset( $this->additional_data[ $field ] ) ) {
return $this->additional_data[ $field ];
}
if ( str_contains( $field, '_' ) ) {
$camel_case_field = $this->underscore_to_camel_case( $field );
if ( isset( $this->additional_data[ $camel_case_field ] ) ) {
return $this->additional_data[ $camel_case_field ];
}
}
/*
* A few common special cases.
* For instance, "finish_reason" is sometimes called "stop_reason".
*/
switch ( $field ) {
case 'finish_reason':
return $this->get_field_value( 'stop_reason' );
case 'finishReason':
return $this->get_field_value( 'stopReason' );
}
return null;
}
/**
* Gets the additional data.
*
* @since 0.1.0
*
* @return array<string, mixed> The additional data.
*/
public function get_additional_data(): array {
return $this->additional_data;
}
/**
* Returns the array representation.
*
* @since 0.1.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return array_merge(
array(
'content' => $this->content ? $this->content->to_array() : null,
),
$this->additional_data
);
}
/**
* Creates a Candidate instance from an array of content data.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The content data.
* @return Candidate Candidate instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): Candidate {
if ( ! isset( $data['content'] ) ) {
return new Candidate( null, $data );
}
/*
* Apparently, the API sometimes omits this.
* Given candidates are always part of a model response, we can safely assume the role is 'model'.
*/
if ( ! isset( $data['content']['role'] ) ) {
$data['content']['role'] = Content_Role::MODEL;
}
$content = Content::from_array( $data['content'] );
unset( $data['content'] );
return new Candidate( $content, $data );
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'content' => array_merge(
array(
'description' => __( 'Candidate content.', 'ai-services' ),
'readonly' => true,
),
Content::get_json_schema()
),
),
'additionalProperties' => true,
);
}
/**
* Transforms a snake_case string to camelCase.
*
* @since 0.1.0
*
* @param string $input The snake_case string.
* @return string The camelCase string.
*/
private function underscore_to_camel_case( string $input ): string {
return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
}
}

View File

@@ -0,0 +1,167 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Candidates
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ArrayIterator;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Collection;
use InvalidArgumentException;
use Traversable;
/**
* Class representing a collection of response candidates for a generative model.
*
* @since 0.1.0
*/
final class Candidates implements Collection, Arrayable {
/**
* The candidates.
*
* @since 0.1.0
* @var Candidate[]
*/
private $candidates = array();
/**
* Adds a candidate to the collection.
*
* @since 0.1.0
*
* @param Candidate $candidate The candidate.
*/
public function add_candidate( Candidate $candidate ): void {
$this->candidates[] = $candidate;
}
/**
* Returns an iterator for the candidates collection.
*
* @since 0.1.0
*
* @return ArrayIterator<int, Candidate> Collection iterator.
*/
public function getIterator(): Traversable {
return new ArrayIterator( $this->candidates );
}
/**
* Returns the size of the candidates collection.
*
* @since 0.1.0
*
* @return int Collection size.
*/
public function count(): int {
return count( $this->candidates );
}
/**
* Filters the parts collection by the given criteria.
*
* @since 0.1.0
*
* @param array<string, mixed> $args {
* The filter arguments.
*
* @type string $part_class_name The class name to only allow candidates with content parts of that class.
* }
* @return Candidates The filtered parts collection.
*/
public function filter( array $args ): self {
if ( isset( $args['part_class_name'] ) ) {
$part_class_name = $args['part_class_name'];
$map = static function ( Candidate $candidate ) use ( $part_class_name ) {
$candidate_content = $candidate->get_content();
if ( ! $candidate_content ) {
return null;
}
$filtered_parts = $candidate_content->get_parts()->filter( array( 'class_name' => $part_class_name ) );
if ( count( $filtered_parts ) > 0 ) {
$candidate_data = $candidate->to_array();
$candidate_data['content']['parts'] = $filtered_parts->to_array();
return Candidate::from_array( $candidate_data );
}
return null;
};
} else {
$map = static function ( Candidate $candidate ) {
return Candidate::from_array( $candidate->to_array() );
};
}
$candidates = new Candidates();
foreach ( $this->candidates as $candidate ) {
$mapped_candidate = $map( $candidate );
if ( $mapped_candidate ) {
$candidates->add_candidate( $mapped_candidate );
}
}
return $candidates;
}
/**
* Returns the candidate at the given index.
*
* @since 0.1.0
*
* @param int $index The index.
* @return Candidate The candidate.
*
* @throws InvalidArgumentException Thrown if the index is out of bounds.
*/
public function get( int $index ): Candidate {
if ( ! isset( $this->candidates[ $index ] ) ) {
throw new InvalidArgumentException(
'Index out of bounds.'
);
}
return $this->candidates[ $index ];
}
/**
* Returns the array representation.
*
* @since 0.1.0
*
* @return array<string, mixed>[] Array representation.
*/
public function to_array(): array {
return array_map(
static function ( Candidate $candidate ) {
return $candidate->to_array();
},
$this->candidates
);
}
/**
* Creates a Candidates instance from an array of candidates data.
*
* @since 0.1.0
*
* @param array<string, mixed>[] $data The candidates data.
* @return Candidates The Candidates instance.
*
* @throws InvalidArgumentException Thrown if the candidates data is invalid.
*/
public static function from_array( array $data ): Candidates {
$candidates = new Candidates();
foreach ( $data as $candidate ) {
if ( ! is_array( $candidate ) ) {
throw new InvalidArgumentException( 'Invalid candidate data.' );
}
$candidates->add_candidate( Candidate::from_array( $candidate ) );
}
return $candidates;
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Content
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\API\Enums\Content_Role;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Class representing an entry of content for a generative AI model.
*
* @since 0.1.0
*/
final class Content implements Arrayable, With_JSON_Schema {
/**
* The role of the content.
*
* @since 0.1.0
* @var string
*/
private $role;
/**
* The parts of the content.
*
* @since 0.1.0
* @var Parts
*/
private $parts;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $role The role of the content.
* @param Parts $parts The parts of the content.
*
* @throws InvalidArgumentException Thrown if the given role is invalid.
*/
public function __construct( string $role, Parts $parts ) {
if ( ! Content_Role::is_valid_value( $role ) ) {
throw new InvalidArgumentException(
sprintf(
'The role %s is invalid.',
htmlspecialchars( $role ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$this->role = $role;
$this->parts = $parts;
}
/**
* Gets the role of the content.
*
* @since 0.1.0
*
* @return string The role of the content.
*/
public function get_role(): string {
return $this->role;
}
/**
* Gets the parts of the content.
*
* @since 0.1.0
*
* @return Parts The parts of the content.
*/
public function get_parts(): Parts {
return $this->parts;
}
/**
* Returns the array representation.
*
* @since 0.1.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return array(
'role' => $this->role,
'parts' => $this->parts->to_array(),
);
}
/**
* Creates a Content instance from an array of content data.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The content data.
* @return Content Content instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): Content {
if ( ! isset( $data['role'], $data['parts'] ) ) {
throw new InvalidArgumentException( 'Content data must contain role and parts.' );
}
return new Content( $data['role'], Parts::from_array( $data['parts'] ) );
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.2.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'role' => array(
'description' => __( 'The role of the content, i.e. which source it comes from.', 'ai-services' ),
'type' => 'string',
'enum' => array(
Content_Role::USER,
Content_Role::MODEL,
Content_Role::SYSTEM,
),
),
'parts' => array_merge(
array( 'description' => __( 'Content parts, including optional multimodal input.', 'ai-services' ) ),
Parts::get_json_schema()
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\API\Types\Contracts\Part
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Contracts;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
/**
* Interface for a class representing a part of content for a generative model.
*
* @since 0.1.0
*/
interface Part extends Arrayable {
/**
* Sets data for the part.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The part data.
*/
public function set_data( array $data ): void;
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\API\Types\Contracts\Tool
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Contracts;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
/**
* Interface for a class representing a tool for a generative model.
*
* @since 0.5.0
*/
interface Tool extends Arrayable {
/**
* Sets data for the tool.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The tool data.
*/
public function set_data( array $data ): void;
}

View File

@@ -0,0 +1,220 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\History
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use InvalidArgumentException;
/**
* Class representing a chat history.
*
* @since 0.5.0
*/
final class History {
/**
* The feature the history is associated with.
*
* @since 0.5.0
* @var string
*/
private $feature;
/**
* The history slug, unique within the feature.
*
* @since 0.5.0
* @var string
*/
private $slug;
/**
* When the history was last updated, as MySQL datetime string in GMT.
*
* @since 0.5.0
* @var string
*/
private $last_updated;
/**
* The history entries.
*
* @since 0.5.0
* @var History_Entry[]
*/
private $entries;
/**
* Constructor.
*
* @since 0.5.0
*
* @param string $feature The feature the history is associated with.
* @param string $slug The history slug.
* @param string $last_updated When the history was last updated, as MySQL datetime string in GMT.
* @param History_Entry[] $entries The history entries.
*/
public function __construct( string $feature, string $slug, string $last_updated, array $entries ) {
$this->feature = $feature;
$this->slug = $slug;
$this->last_updated = $last_updated;
$this->entries = $entries;
}
/**
* Gets the feature the history is associated with.
*
* @since 0.5.0
*
* @return string The feature.
*/
public function get_feature(): string {
return $this->feature;
}
/**
* Gets the history slug.
*
* @since 0.5.0
*
* @return string The history slug.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Gets when the history was last updated.
*
* @since 0.5.0
*
* @return string The last updated MySQL datetime string in GMT.
*/
public function get_last_updated(): string {
return $this->last_updated;
}
/**
* Gets the history entries.
*
* @since 0.5.0
*
* @return History_Entry[] The history entries.
*/
public function get_entries(): array {
return $this->entries;
}
/**
* Sets the history entries.
*
* @since 0.5.0
*
* @param History_Entry[]|array<string, mixed>[] $entries The history entries.
*/
public function set_entries( array $entries ): void {
$this->entries = array_map(
function ( $entry_data ) {
if ( ! $entry_data instanceof History_Entry ) {
return History_Entry::from_array( $entry_data );
}
return $entry_data;
},
$entries
);
}
/**
* Returns the array representation.
*
* @since 0.5.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return array(
'feature' => $this->feature,
'slug' => $this->slug,
'lastUpdated' => $this->last_updated,
'entries' => array_map(
function ( History_Entry $entry ) {
return $entry->to_array();
},
$this->entries
),
);
}
/**
* Creates a History instance from an array of history data.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The history data.
* @return History History instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): History {
if ( ! isset( $data['feature'], $data['slug'], $data['lastUpdated'], $data['entries'] ) ) {
throw new InvalidArgumentException( 'History data must contain feature, slug, lastUpdated, and entries.' );
}
return new History(
$data['feature'],
$data['slug'],
$data['lastUpdated'],
array_map(
function ( array $entry_data ) {
return History_Entry::from_array( $entry_data );
},
$data['entries']
)
);
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'feature' => array(
'description' => __( 'Unique identifier of the feature. Must only contain lowercase letters, numbers, hyphens.', 'ai-services' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'slug' => array(
'description' => __( 'Unique identifier of the history within the feature. Must only contain lowercase letters, numbers, hyphens.', 'ai-services' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'lastUpdated' => array(
'description' => __( 'When the history was last updated, as MySQL datetime string in GMT.', 'ai-services' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'entries' => array(
'description' => __( 'The history entries, in ascending order.', 'ai-services' ),
'type' => 'array',
'items' => History_Entry::get_json_schema(),
'context' => array( 'view', 'edit' ),
),
),
);
}
}

View File

@@ -0,0 +1,133 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\History_Entry
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use InvalidArgumentException;
/**
* Class representing a single entry in a chat history.
*
* @since 0.5.0
*/
final class History_Entry {
/**
* The history entry's content.
*
* @since 0.5.0
* @var Content
*/
private $content;
/**
* Additional data for the history entry, if any.
*
* @since 0.5.0
* @var array<string, mixed>
*/
private $additional_data;
/**
* Constructor.
*
* @since 0.5.0
*
* @param Content $content The history entry content.
* @param array<string, mixed> $additional_data Additional data for the history entry, if any.
*/
public function __construct( Content $content, array $additional_data = array() ) {
$this->content = $content;
// Remove the content from the additional data, if present, to prevent conflicts.
unset( $additional_data['content'] );
$this->additional_data = $additional_data;
}
/**
* Gets the history entry content.
*
* @since 0.5.0
*
* @return Content The content.
*/
public function get_content(): Content {
return $this->content;
}
/**
* Gets the additional data.
*
* @since 0.5.0
*
* @return array<string, mixed> The additional data.
*/
public function get_additional_data(): array {
return $this->additional_data;
}
/**
* Converts the candidate to an array.
*
* @since 0.5.0
*
* @return array<string, mixed> The array representation of the candidate.
*/
public function to_array(): array {
return array_merge(
array(
'content' => $this->content->to_array(),
),
$this->additional_data
);
}
/**
* Creates a History_Entry instance from an array of content data.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The content data.
* @return History_Entry History_Entry instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): History_Entry {
if ( ! isset( $data['content'] ) ) {
throw new InvalidArgumentException( 'History entry data must contain content.' );
}
$content = Content::from_array( $data['content'] );
unset( $data['content'] );
return new History_Entry( $content, $data );
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'content' => array_merge(
array(
'description' => __( 'History entry content.', 'ai-services' ),
'readonly' => true,
),
Content::get_json_schema()
),
),
'additionalProperties' => true,
);
}
}

View File

@@ -0,0 +1,202 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Model_Metadata
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\API\Enums\AI_Capability;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Value class representing metadata about a generative AI model.
*
* @since 0.7.0
*/
final class Model_Metadata implements Arrayable, With_JSON_Schema {
/**
* The model slug.
*
* @since 0.7.0
* @var string
*/
private $slug;
/**
* The model name.
*
* @since 0.7.0
* @var string
*/
private $name;
/**
* List of AI capabilities supported by the model.
*
* @since 0.7.0
* @var string[]
*/
private $capabilities;
/**
* Constructor.
*
* @since 0.7.0
*
* @param array<string, mixed> $args {
* The arguments for the model metadata.
*
* @type string $slug The model slug.
* @type string $name Optional. The model name. Default will be generated from the slug.
* @type string[] $capabilities Optional. The list of AI capabilities supported by the model.
* Default empty array.
* }
*
* @throws InvalidArgumentException Thrown if the given slug is invalid.
*/
public function __construct( array $args ) {
$args = $this->parse_args( $args );
$this->slug = $args['slug'];
$this->name = $args['name'];
$this->capabilities = $args['capabilities'];
}
/**
* Gets the model slug.
*
* @since 0.7.0
*
* @return string The model slug.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Gets the model name.
*
* @since 0.7.0
*
* @return string The model name.
*/
public function get_name(): string {
return $this->name;
}
/**
* Gets the list of AI capabilities supported by the model.
*
* @since 0.7.0
*
* @return string[] List of AI capabilities supported by the model.
*/
public function get_capabilities(): array {
return $this->capabilities;
}
/**
* Returns the array representation.
*
* @since 0.7.0
*
* @return array<string, mixed> The array representation.
*/
public function to_array(): array {
return array(
'slug' => $this->slug,
'name' => $this->name,
'capabilities' => $this->capabilities,
);
}
/**
* Creates a Model_Metadata instance from an array of model metadata arguments.
*
* @since 0.7.0
*
* @param array<string, mixed> $args The model metadata arguments.
* @return Model_Metadata The Model_Metadata instance.
*/
public static function from_array( array $args ): Model_Metadata {
return new Model_Metadata( $args );
}
/**
* Parses the model metadata arguments.
*
* @since 0.7.0
*
* @param array<string, mixed> $args The model metadata arguments.
* @return array<string, mixed> The parsed model metadata arguments.
*
* @throws InvalidArgumentException Thrown if an invalid argument is provided.
*/
private function parse_args( array $args ): array {
if ( ! isset( $args['slug'] ) ) {
throw new InvalidArgumentException( 'The slug is required.' );
}
if ( isset( $args['name'] ) ) {
$args['name'] = (string) $args['name'];
} else {
$args['name'] = ucwords( str_replace( array( '-', '_' ), ' ', $args['slug'] ) );
}
if ( isset( $args['capabilities'] ) ) {
if ( ! is_array( $args['capabilities'] ) ) {
throw new InvalidArgumentException( 'The capabilities must be an array.' );
}
foreach ( $args['capabilities'] as $capability ) {
if ( ! AI_Capability::is_valid_value( $capability ) ) {
throw new InvalidArgumentException( 'The capabilities contain an invalid value.' );
}
}
} else {
$args['capabilities'] = array();
}
return $args;
}
/**
* Returns the JSON schema for the model metadata.
*
* @since 0.7.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'slug' => array(
'description' => __( 'Unique model slug.', 'ai-services' ),
'type' => 'string',
'readonly' => true,
),
'name' => array(
'description' => __( 'User-facing model name.', 'ai-services' ),
'type' => 'string',
'readonly' => true,
),
'capabilities' => array(
'description' => __( 'List of AI capabilities supported by the model.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => AI_Capability::get_values(),
),
'readonly' => true,
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,275 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Parts
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ArrayIterator;
use ATFPP\AI_Translate\Services\API\Types\Contracts\Part;
use ATFPP\AI_Translate\Services\API\Types\Parts\Function_Call_Part;
use ATFPP\AI_Translate\Services\API\Types\Parts\Function_Response_Part;
use ATFPP\AI_Translate\Services\API\Types\Parts\Text_Part;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Collection;
use InvalidArgumentException;
use Traversable;
/**
* Class representing a collection of content parts for a generative model.
*
* @since 0.1.0
*/
final class Parts implements Collection, Arrayable, With_JSON_Schema {
/**
* The parts of the content.
*
* @since 0.1.0
* @var Part[]
*/
private $parts = array();
/**
* Adds a text part to the content.
*
* @since 0.1.0
*
* @param string $text The text.
*/
public function add_text_part( string $text ): void {
$this->add_part(
Text_Part::from_array( array( 'text' => $text ) )
);
}
/**
* Adds a function call part to the content.
*
* Every function call must have at least one of $id and $name provided.
*
* @since 0.5.0
*
* @param string $id The ID of the function call, or an empty string.
* @param string $name The name of the function, or an empty string.
* @param array<string, mixed> $args The arguments of the function call.
*/
public function add_function_call_part( string $id, string $name, array $args ): void {
$data = array();
if ( $id ) {
$data['id'] = $id;
}
if ( $name ) {
$data['name'] = $name;
}
$data['args'] = $args;
$this->add_part(
Function_Call_Part::from_array(
array( 'functionCall' => $data )
)
);
}
/**
* Adds a function response part to the content.
*
* Every function response must have at least one of $id and $name provided.
*
* @since 0.5.0
*
* @param string $id The ID of the function response, or an empty string. If present, this must match the
* function call ID.
* @param string $name The name of the function, or an empty string. If present, this must match the name of
* the function called.
* @param mixed $response The function output response.
*/
public function add_function_response_part( string $id, string $name, $response ): void {
$data = array();
if ( $id ) {
$data['id'] = $id;
}
if ( $name ) {
$data['name'] = $name;
}
$data['response'] = $response;
$this->add_part(
Function_Response_Part::from_array(
array( 'functionResponse' => $data )
)
);
}
/**
* Adds a part to the content.
*
* @since 0.1.0
*
* @param Part $part The part.
*/
public function add_part( Part $part ): void {
$this->parts[] = $part;
}
/**
* Returns an iterator for the parts collection.
*
* @since 0.1.0
*
* @return ArrayIterator<int, Part> Collection iterator.
*/
public function getIterator(): Traversable {
return new ArrayIterator( $this->parts );
}
/**
* Returns the size of the parts collection.
*
* @since 0.1.0
*
* @return int Collection size.
*/
public function count(): int {
return count( $this->parts );
}
/**
* Filters the parts collection by the given criteria.
*
* @since 0.1.0
*
* @param array<string, mixed> $args {
* The filter arguments.
*
* @type string $class_name The class name to only allow parts of that class.
* }
* @return Parts The filtered parts collection.
*/
public function filter( array $args ): self {
if ( isset( $args['class_name'] ) ) {
$class_name = $args['class_name'];
$map = static function ( Part $part ) use ( $class_name ) {
if ( $part instanceof $class_name ) {
return call_user_func( array( $class_name, 'from_array' ), $part->to_array() );
}
return null;
};
} else {
$map = static function ( Part $part ) {
return call_user_func( array( get_class( $part ), 'from_array' ), $part->to_array() );
};
}
$parts = new Parts();
foreach ( $this->parts as $part ) {
$mapped_part = $map( $part );
if ( $mapped_part ) {
$parts->add_part( $mapped_part );
}
}
return $parts;
}
/**
* Returns the part at the given index.
*
* @since 0.1.0
*
* @param int $index The index.
* @return Part The part.
*
* @throws InvalidArgumentException Thrown if the index is out of bounds.
*/
public function get( int $index ): Part {
if ( ! isset( $this->parts[ $index ] ) ) {
throw new InvalidArgumentException(
'Index out of bounds.'
);
}
return $this->parts[ $index ];
}
/**
* Returns the array representation.
*
* @since 0.1.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return array_map(
static function ( Part $part ) {
return $part->to_array();
},
$this->parts
);
}
/**
* Creates a Parts instance from an array of parts data.
*
* @since 0.1.0
*
* @param mixed[] $data The parts data.
* @return Parts The Parts instance.
*
* @throws InvalidArgumentException Thrown if the parts data is invalid.
*/
public static function from_array( array $data ): Parts {
$parts = new Parts();
foreach ( $data as $part ) {
if ( ! is_array( $part ) ) {
throw new InvalidArgumentException( 'Invalid part data.' );
}
if ( isset( $part['text'] ) ) {
$parts->add_part( Text_Part::from_array( $part ) );
} elseif ( isset( $part['functionCall'] ) ) {
$parts->add_part( Function_Call_Part::from_array( $part ) );
} elseif ( isset( $part['functionResponse'] ) ) {
$parts->add_part( Function_Response_Part::from_array( $part ) );
} else {
throw new InvalidArgumentException( 'Invalid part data.' );
}
}
return $parts;
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.2.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
$text_part_schema = Text_Part::get_json_schema();
$function_call_part_schema = Function_Call_Part::get_json_schema();
$function_response_part_schema = Function_Response_Part::get_json_schema();
unset(
$text_part_schema['type'],
$function_call_part_schema['type'],
$function_response_part_schema['type']
);
return array(
'type' => 'array',
'minItems' => 1,
'items' => array(
'type' => 'object',
'oneOf' => array(
$text_part_schema,
$function_call_part_schema,
$function_response_part_schema,
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Parts\Abstract_Part
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\API\Types\Contracts\Part;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use InvalidArgumentException;
/**
* Base class for a part of content for a generative model.
*
* @since 0.1.0
*/
abstract class Abstract_Part implements Part, With_JSON_Schema {
/**
* The part data.
*
* @since 0.1.0
* @var array<string, mixed>
*/
private $data = array();
/**
* Constructor.
*
* @since 0.1.0
*/
final public function __construct() {
// Empty constructor, only to prevent override.
}
/**
* Sets data for the part.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The part data.
*/
final public function set_data( array $data ): void {
$this->data = $this->format_data( $data );
}
/**
* Formats the data for the part.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The part data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the part data is invalid.
*/
abstract protected function format_data( array $data ): array;
/**
* Gets the default data for the part.
*
* @since 0.1.0
*
* @return array<string, mixed> Default data.
*/
abstract protected function get_default_data(): array;
/**
* Returns the array representation.
*
* @since 0.1.0
*
* @return mixed[] Array representation.
*/
final public function to_array(): array {
if ( ! $this->data ) {
$this->data = $this->get_default_data();
}
return $this->data;
}
/**
* Creates a specific Part instance from an array of part data.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The part data.
* @return Part The Part instance.
*
* @throws InvalidArgumentException Thrown if the parts data is invalid.
*/
final public static function from_array( array $data ): Part {
$part = new static();
$part->set_data( $data );
return $part;
}
}

View File

@@ -0,0 +1,157 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Parts\Function_Call_Part
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Parts;
use InvalidArgumentException;
/**
* Class for a function call part of content for a generative model.
*
* @since 0.5.0
*/
final class Function_Call_Part extends Abstract_Part {
/**
* Gets the ID of the function call from the part.
*
* Every function call must have at least one of 'id' or 'name' present.
*
* @since 0.5.0
*
* @return string The function call ID, or empty string if none set.
*/
public function get_id(): string {
$data = $this->to_array();
if ( ! isset( $data['functionCall']['id'] ) ) {
return '';
}
return $data['functionCall']['id'];
}
/**
* Gets the function name from the part.
*
* Every function call must have at least one of 'id' or 'name' present.
*
* @since 0.5.0
*
* @return string The function name, or empty string if none set.
*/
public function get_name(): string {
$data = $this->to_array();
if ( ! isset( $data['functionCall']['name'] ) ) {
return '';
}
return $data['functionCall']['name'];
}
/**
* Gets the function input arguments from the part.
*
* @since 0.5.0
*
* @return array<string, mixed> The function input arguments.
*/
public function get_args(): array {
return $this->to_array()['functionCall']['args'];
}
/**
* Formats the data for the part.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The part data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the part data is invalid.
*/
protected function format_data( array $data ): array {
if ( ! isset( $data['functionCall'] ) || ! is_array( $data['functionCall'] ) ) {
throw new InvalidArgumentException( 'The function call part data must contain an associative array functionCall value.' );
}
$function_call = $data['functionCall'];
if (
( ! isset( $function_call['id'] ) || ! is_string( $function_call['id'] ) ) &&
( ! isset( $function_call['name'] ) || ! is_string( $function_call['name'] ) )
) {
throw new InvalidArgumentException( 'The function call part data must contain either a string id value or a string name value.' );
}
if ( ! isset( $function_call['args'] ) || ! is_array( $function_call['args'] ) ) {
throw new InvalidArgumentException( 'The function call part data must contain an object / associative array args value.' );
}
$function_call_formatted = array();
if ( isset( $function_call['id'] ) ) {
$function_call_formatted['id'] = $function_call['id'];
}
if ( isset( $function_call['name'] ) ) {
$function_call_formatted['name'] = $function_call['name'];
}
$function_call_formatted['args'] = $function_call['args'];
return array(
'functionCall' => $function_call_formatted,
);
}
/**
* Gets the default data for the part.
*
* @since 0.5.0
*
* @return array<string, mixed> Default data.
*/
protected function get_default_data(): array {
return array(
'functionCall' => array(
'name' => '',
'args' => array(),
),
);
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'functionCall' => array(
'description' => __( 'Function call as part of the prompt.', 'ai-services' ),
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'ID of the function call. Either this or a name must be present.', 'ai-services' ),
'type' => 'string',
),
'name' => array(
'description' => __( 'Name of the function to call. Either this or a name must be present.', 'ai-services' ),
'type' => 'string',
),
'args' => array(
'description' => __( 'Arguments input for the function to call.', 'ai-services' ),
'type' => 'object',
'additionalProperties' => true,
),
),
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,159 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Parts\Function_Response_Part
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Parts;
use InvalidArgumentException;
/**
* Class for a function response part of content for a generative model.
*
* @since 0.5.0
*/
final class Function_Response_Part extends Abstract_Part {
/**
* Gets the ID of the function response from the part.
*
* If present, this must match the function call ID.
* Every function response must have at least one of 'id' or 'name' present.
*
* @since 0.5.0
*
* @return string The function response ID, or empty string if none set.
*/
public function get_id(): string {
$data = $this->to_array();
if ( ! isset( $data['functionResponse']['id'] ) ) {
return '';
}
return $data['functionResponse']['id'];
}
/**
* Gets the function name from the part.
*
* If present, this must match the name of the function called.
* Every function response must have at least one of 'id' or 'name' present.
*
* @since 0.5.0
*
* @return string The function name, or empty string if none set.
*/
public function get_name(): string {
$data = $this->to_array();
if ( ! isset( $data['functionResponse']['name'] ) ) {
return '';
}
return $data['functionResponse']['name'];
}
/**
* Gets the function output response from the part.
*
* @since 0.5.0
*
* @return mixed The function output response.
*/
public function get_response() {
return $this->to_array()['functionResponse']['response'];
}
/**
* Formats the data for the part.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The part data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the part data is invalid.
*/
protected function format_data( array $data ): array {
if ( ! isset( $data['functionResponse'] ) || ! is_array( $data['functionResponse'] ) ) {
throw new InvalidArgumentException( 'The function response part data must contain an associative array functionResponse value.' );
}
$function_response = $data['functionResponse'];
if (
( ! isset( $function_response['id'] ) || ! is_string( $function_response['id'] ) ) &&
( ! isset( $function_response['name'] ) || ! is_string( $function_response['name'] ) )
) {
throw new InvalidArgumentException( 'The function response part data must contain either a string id value or a string name value.' );
}
if ( ! isset( $function_response['response'] ) ) {
throw new InvalidArgumentException( 'The function response part data must contain a response value.' );
}
$function_response_formatted = array();
if ( isset( $function_response['id'] ) ) {
$function_response_formatted['id'] = $function_response['id'];
}
if ( isset( $function_response['name'] ) ) {
$function_response_formatted['name'] = $function_response['name'];
}
$function_response_formatted['response'] = $function_response['response'];
return array(
'functionResponse' => $function_response_formatted,
);
}
/**
* Gets the default data for the part.
*
* @since 0.5.0
*
* @return array<string, mixed> Default data.
*/
protected function get_default_data(): array {
return array(
'functionResponse' => array(
'name' => '',
'response' => null,
),
);
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'functionResponse' => array(
'description' => __( 'Function response as part of the prompt.', 'ai-services' ),
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'ID of the function response. If present, it must match the function call ID. Either this or a name must be present.', 'ai-services' ),
'type' => 'string',
),
'name' => array(
'description' => __( 'Name of the function called. Either this or a name must be present.', 'ai-services' ),
'type' => 'string',
),
'response' => array(
'description' => __( 'Response from the function called.', 'ai-services' ),
'type' => array( 'string', 'number', 'boolean', 'array', 'object' ),
'additionalProperties' => true,
),
),
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,83 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Parts\Text_Part
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Parts;
use InvalidArgumentException;
/**
* Class for a text part of content for a generative model.
*
* @since 0.1.0
*/
final class Text_Part extends Abstract_Part {
/**
* Gets the text from the part.
*
* @since 0.2.0
*
* @return string The text.
*/
public function get_text(): string {
return $this->to_array()['text'];
}
/**
* Formats the data for the part.
*
* @since 0.1.0
*
* @param array<string, mixed> $data The part data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the part data is invalid.
*/
protected function format_data( array $data ): array {
if ( ! isset( $data['text'] ) || ! is_string( $data['text'] ) ) {
throw new InvalidArgumentException( 'The text part data must contain a string text value.' );
}
return array(
'text' => $data['text'],
);
}
/**
* Gets the default data for the part.
*
* @since 0.1.0
*
* @return array<string, mixed> Default data.
*/
protected function get_default_data(): array {
return array(
'text' => '',
);
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.2.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'text' => array(
'description' => __( 'Prompt text content.', 'ai-services' ),
'type' => 'string',
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,285 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Service_Metadata
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\API\Enums\AI_Capability;
use ATFPP\AI_Translate\Services\API\Enums\Service_Type;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Value class representing metadata about a generative AI service.
*
* @since 0.7.0
*/
final class Service_Metadata implements Arrayable, With_JSON_Schema {
/**
* The service slug.
*
* @since 0.7.0
* @var string
*/
private $slug;
/**
* The service name.
*
* @since 0.7.0
* @var string
*/
private $name;
/**
* The service credentials URL.
*
* @since 0.7.0
* @var string
*/
private $credentials_url;
/**
* The service type.
*
* @since 0.7.0
* @var string
*/
private $type;
/**
* List of AI capabilities supported by the service and its models.
*
* @since 0.7.0
* @var string[]
*/
private $capabilities;
/**
* Constructor.
*
* @since 0.7.0
*
* @param array<string, mixed> $args {
* The arguments for the service metadata.
*
* @type string $slug The service slug.
* @type string $name Optional. The service name. Default will be generated from the slug.
* @type string $credentials_url Optional. The service credentials URL. Default empty string.
* @type string $type Optional. The service type. Default `Service_Type::CLOUD`.
* @type string[] $capabilities Optional. The list of AI capabilities supported by the service and its
* models. Default empty array.
* }
*
* @throws InvalidArgumentException Thrown if the given slug is invalid.
*/
public function __construct( array $args ) {
$args = $this->parse_args( $args );
$this->slug = $args['slug'];
$this->name = $args['name'];
$this->credentials_url = $args['credentials_url'];
$this->type = $args['type'];
$this->capabilities = $args['capabilities'];
}
/**
* Gets the service slug.
*
* @since 0.7.0
*
* @return string The service slug.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Gets the service name.
*
* @since 0.7.0
*
* @return string The service name.
*/
public function get_name(): string {
return $this->name;
}
/**
* Gets the service credentials URL.
*
* @since 0.7.0
*
* @return string The service credentials URL.
*/
public function get_credentials_url(): string {
return $this->credentials_url;
}
/**
* Gets the service type.
*
* @since 0.7.0
*
* @return string The service type.
*/
public function get_type(): string {
return $this->type;
}
/**
* Gets the list of AI capabilities supported by the service and its models.
*
* @since 0.7.0
*
* @return string[] List of AI capabilities supported by the service and its models.
*/
public function get_capabilities(): array {
return $this->capabilities;
}
/**
* Returns the array representation.
*
* @since 0.7.0
*
* @return array<string, mixed> The array representation.
*/
public function to_array(): array {
return array(
'slug' => $this->slug,
'name' => $this->name,
'credentials_url' => $this->credentials_url,
'type' => $this->type,
'capabilities' => $this->capabilities,
);
}
/**
* Creates a Service_Metadata instance from an array of service metadata arguments.
*
* @since 0.7.0
*
* @param array<string, mixed> $args The service metadata arguments.
* @return Service_Metadata The Service_Metadata instance.
*/
public static function from_array( array $args ): Service_Metadata {
return new Service_Metadata( $args );
}
/**
* Parses the service metadata arguments.
*
* @since 0.7.0
*
* @param array<string, mixed> $args The service metadata arguments.
* @return array<string, mixed> The parsed service metadata arguments.
*
* @throws InvalidArgumentException Thrown if an invalid argument is provided.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
private function parse_args( array $args ): array {
if ( ! isset( $args['slug'] ) ) {
throw new InvalidArgumentException( 'The slug is required.' );
}
if ( ! preg_match( '/^[a-z0-9-]+$/', $args['slug'] ) ) {
throw new InvalidArgumentException(
'The service slug must only contain lowercase letters, numbers, and hyphens.'
);
}
if ( isset( $args['name'] ) ) {
$args['name'] = (string) $args['name'];
} else {
$args['name'] = ucwords( str_replace( array( '-', '_' ), ' ', $args['slug'] ) );
}
if ( isset( $args['credentials_url'] ) ) {
$args['credentials_url'] = (string) $args['credentials_url'];
// Basic sanity check to ensure a protocol is present.
if ( ! str_contains( $args['credentials_url'], ':' ) && ! in_array( $args['credentials_url'][0], array( '/', '#', '?' ), true ) ) {
$args['credentials_url'] = 'https://' . $args['credentials_url'];
}
} else {
$args['credentials_url'] = '';
}
if ( isset( $args['type'] ) ) {
if ( ! Service_Type::is_valid_value( $args['type'] ) ) {
throw new InvalidArgumentException( 'The service type is invalid.' );
}
} else {
$args['type'] = Service_Type::CLOUD;
}
if ( isset( $args['capabilities'] ) ) {
if ( ! is_array( $args['capabilities'] ) ) {
throw new InvalidArgumentException( 'The capabilities must be an array.' );
}
foreach ( $args['capabilities'] as $capability ) {
if ( ! AI_Capability::is_valid_value( $capability ) ) {
throw new InvalidArgumentException( 'The capabilities contain an invalid value.' );
}
}
} else {
$args['capabilities'] = array();
}
return $args;
}
/**
* Returns the JSON schema for the service metadata.
*
* @since 0.7.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'slug' => array(
'description' => __( 'Unique service slug.', 'ai-services' ),
'type' => 'string',
'readonly' => true,
),
'name' => array(
'description' => __( 'User-facing service name.', 'ai-services' ),
'type' => 'string',
'readonly' => true,
),
'credentials_url' => array(
'description' => __( 'Service credentials URL, or empty string if not specified.', 'ai-services' ),
'type' => 'string',
'readonly' => true,
),
'type' => array(
'description' => __( 'Service type.', 'ai-services' ),
'type' => 'string',
'enum' => Service_Type::get_values(),
'readonly' => true,
),
'capabilities' => array(
'description' => __( 'List of AI capabilities supported by the service and its models.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => AI_Capability::get_values(),
),
'readonly' => true,
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,288 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Text_Generation_Config
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\API\Enums\Modality;
use ATFPP\AI_Translate\Services\Base\Abstract_Generation_Config;
use InvalidArgumentException;
/**
* Class representing text configuration options for a generative AI model.
*
* @since 0.2.0
* @since 0.5.0 Renamed from `Generation_Config`.
* @since 0.7.0 Now extends `Abstract_Generation_Config`.
*/
class Text_Generation_Config extends Abstract_Generation_Config {
/**
* Returns the stop sequences.
*
* @since 0.2.0
*
* @return string[] The stop sequences, or empty array if not set.
*/
public function get_stop_sequences(): array {
return $this->get_arg( 'stopSequences' );
}
/**
* Returns the response MIME type.
*
* @since 0.2.0
*
* @return string The response MIME type, or empty string if not set.
*/
public function get_response_mime_type(): string {
return $this->get_arg( 'responseMimeType' );
}
/**
* Returns the response schema.
*
* @since 0.2.0
*
* @return array<string, mixed> The response schema, or empty array if not set.
*/
public function get_response_schema(): array {
return $this->get_arg( 'responseSchema' );
}
/**
* Returns the candidate count.
*
* @since 0.2.0
*
* @return int The candidate count (default 1).
*/
public function get_candidate_count(): int {
return $this->get_arg( 'candidateCount' );
}
/**
* Returns the maximum output tokens.
*
* @since 0.2.0
*
* @return int The maximum output tokens, or 0 if not set.
*/
public function get_max_output_tokens(): int {
return $this->get_arg( 'maxOutputTokens' );
}
/**
* Returns the temperature.
*
* @since 0.2.0
*
* @return float The temperature (between 0.0 and 1.0), or 0.0 if not set.
*/
public function get_temperature(): float {
return $this->get_arg( 'temperature' );
}
/**
* Returns the top P.
*
* @since 0.2.0
*
* @return float The top P, or 0.0 if not set.
*/
public function get_top_p(): float {
return $this->get_arg( 'topP' );
}
/**
* Returns the top K.
*
* @since 0.2.0
*
* @return int The top K, or 0 if not set.
*/
public function get_top_k(): int {
return $this->get_arg( 'topK' );
}
/**
* Returns the presence penalty.
*
* @since 0.2.0
*
* @return float The presence penalty, or 0.0 if not set.
*/
public function get_presence_penalty(): float {
return $this->get_arg( 'presencePenalty' );
}
/**
* Returns the frequency penalty.
*
* @since 0.2.0
*
* @return float The frequency penalty, or 0.0 if not set.
*/
public function get_frequency_penalty(): float {
return $this->get_arg( 'frequencyPenalty' );
}
/**
* Returns whether to include the response logprobs.
*
* @since 0.2.0
*
* @return bool Whether to include the response logprobs.
*/
public function get_response_logprobs(): bool {
return $this->get_arg( 'responseLogprobs' );
}
/**
* Returns the top logprobs.
*
* @since 0.2.0
*
* @return int The top logprobs, or 0 if not set.
*/
public function get_logprobs(): int {
return $this->get_arg( 'logprobs' );
}
/**
* Returns the output modalities.
*
* @since 0.6.0
*
* @return string[] The output modalities, or empty array if not set.
*/
public function get_output_modalities(): array {
return $this->get_arg( 'outputModalities' );
}
/**
* Gets the definition for the supported arguments.
*
* @since 0.7.0
*
* @return array<string, mixed> The supported arguments definition.
*/
protected function get_supported_args_definition(): array {
$schema = self::get_json_schema();
return $schema['properties'];
}
/**
* Sanitizes the given value based on the given type.
*
* @since 0.7.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 ) {
if ( 'temperature' === $arg_name && ( (float) $value < 0.0 || (float) $value > 1.0 ) ) {
throw new InvalidArgumentException( 'Temperature must be between 0.0 and 1.0.' );
}
return parent::sanitize_arg( $value, $type, $arg_name );
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.2.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'stopSequences' => array(
'description' => __( 'Set of character sequences that will stop output generation.', 'ai-services' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'responseMimeType' => array(
'description' => __( 'MIME type of the generated candidate text.', 'ai-services' ),
'type' => 'string',
'enum' => array( 'text/plain', 'application/json' ),
),
'responseSchema' => array(
'description' => __( 'Output schema of the generated candidate text (only relevant if responseMimeType is application/json).', 'ai-services' ),
'type' => 'object',
'properties' => array(),
'additionalProperties' => true,
),
'candidateCount' => array(
'description' => __( 'Number of response candidates to generate.', 'ai-services' ),
'type' => 'integer',
'minimum' => 1,
),
'maxOutputTokens' => array(
'description' => __( 'The maximum number of tokens to include in a response candidate.', 'ai-services' ),
'type' => 'integer',
'minimum' => 1,
),
'temperature' => array(
'description' => sprintf(
/* translators: 1: Minimum value, 2: Maximum value */
__( 'Floating point value to control the randomness of the output, between %1$s and %2$s.', 'ai-services' ),
'0.0',
'1.0'
),
'type' => 'number',
'minimum' => 0.0,
'maximum' => 1.0,
),
'topP' => array(
'description' => __( 'The maximum cumulative probability of tokens to consider when sampling.', 'ai-services' ),
'type' => 'number',
),
'topK' => array(
'description' => __( 'The maximum number of tokens to consider when sampling.', 'ai-services' ),
'type' => 'integer',
),
'presencePenalty' => array(
'description' => __( 'Presence penalty applied to the next tokens logprobs if the token has already been seen in the response.', 'ai-services' ),
'type' => 'number',
),
'frequencyPenalty' => array(
'description' => __( 'Frequency penalty applied to the next tokens logprobs, multiplied by the number of times each token has been seen in the response so far.', 'ai-services' ),
'type' => 'number',
),
'responseLogprobs' => array(
'description' => __( 'Whether to return log probabilities of the output tokens in the response or not.', 'ai-services' ),
'type' => 'boolean',
),
'logprobs' => array(
'description' => __( 'The number of top logprobs to return at each decoding step.', 'ai-services' ),
'type' => 'integer',
),
'outputModalities' => array(
'description' => __( 'The modalities that the response can contain.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => array(
Modality::TEXT,
Modality::IMAGE,
Modality::AUDIO,
),
),
),
),
'additionalProperties' => true,
);
}
}

View File

@@ -0,0 +1,204 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Tool_Config
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use ATFPP\AI_Translate\Services\Util\Strings;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
/**
* Class representing tool configuration for a generative AI model.
*
* @since 0.5.0
*/
final class Tool_Config implements Arrayable, With_JSON_Schema {
/**
* The sanitized configuration arguments.
*
* @since 0.5.0
* @var array<string, mixed>
*/
private $sanitized_args;
/**
* Type definitions for the supported arguments.
*
* @since 0.5.0
* @var array<string, string>
*/
private $supported_args = array(
'functionCallMode' => 'string',
'allowedFunctionNames' => 'array',
);
/**
* Constructor.
*
* @since 0.5.0
*
* @param array<string, mixed> $args The configuration arguments.
*/
public function __construct( array $args ) {
$this->sanitized_args = $this->sanitize_args( $args );
}
/**
* Returns the function call mode.
*
* @since 0.5.0
*
* @return string The function call mode, or empty string if not set.
*/
public function get_function_call_mode(): string {
return $this->sanitized_args['functionCallMode'] ?? '';
}
/**
* Returns the allowed function names.
*
* @since 0.5.0
*
* @return string[] The allowed function names, or empty array if not set.
*/
public function get_allowed_function_names(): array {
return $this->sanitized_args['allowedFunctionNames'] ?? array();
}
/**
* Returns the array representation.
*
* @since 0.5.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return $this->sanitized_args;
}
/**
* Creates a Tool_Config instance from an array of content data.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The content data.
* @return Tool_Config Tool_Config instance.
*
* @throws InvalidArgumentException Thrown if the data is missing required fields.
*/
public static function from_array( array $data ): Tool_Config {
return new Tool_Config( $data );
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'functionCallMode' => array(
'description' => __( 'Mode for how to consider function calling.', 'ai-services' ),
'type' => 'string',
'enum' => array( 'auto', 'any' ),
),
'allowedFunctionNames' => array(
'description' => __( 'List of function names allowed to call.', 'ai-services' ),
'type' => 'array',
'items' => array( 'type' => 'string' ),
),
),
'additionalProperties' => false,
);
}
/**
* Sanitizes the given arguments.
*
* @since 0.5.0
*
* @param array<string, mixed> $args The arguments to sanitize.
* @return array<string, mixed> Sanitized arguments.
*/
private function sanitize_args( array $args ): array {
$sanitized = array();
foreach ( $args as $key => $value ) {
if ( isset( $this->supported_args[ $key ] ) ) {
$sanitized[ $key ] = $this->sanitize_arg( $value, $this->supported_args[ $key ], $key );
continue;
}
if ( str_contains( $key, '_' ) ) {
$camelcase_key = Strings::snake_case_to_camel_case( $key );
if ( isset( $this->supported_args[ $camelcase_key ] ) ) {
$sanitized[ $camelcase_key ] = $this->sanitize_arg( $value, $this->supported_args[ $camelcase_key ], $camelcase_key );
}
}
}
return $sanitized;
}
/**
* Sanitizies the given value based on the given type.
*
* @since 0.5.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.
*/
private function sanitize_arg( $value, string $type, string $arg_name ) {
if ( 'functionCallMode' === $arg_name && ! in_array( $value, array( 'auto', 'any' ), true ) ) {
return 'auto';
}
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,196 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Tools
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types;
use ArrayIterator;
use ATFPP\AI_Translate\Services\API\Types\Contracts\Tool;
use ATFPP\AI_Translate\Services\API\Types\Tools\Function_Declarations_Tool;
use ATFPP\AI_Translate\Services\API\Types\Tools\Web_Search_Tool;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Collection;
use InvalidArgumentException;
use Traversable;
/**
* Class representing a collection of content tools for a generative model.
*
* @since 0.5.0
*/
final class Tools implements Collection, Arrayable, With_JSON_Schema {
/**
* The tools.
*
* @since 0.5.0
* @var Tool[]
*/
private $tools = array();
/**
* Adds a function declarations tool.
*
* @since 0.5.0
*
* @param array<string, mixed>[] $function_declarations The function declarations.
*/
public function add_function_declarations_tool( array $function_declarations ): void {
$this->add_tool(
Function_Declarations_Tool::from_array(
array( 'functionDeclarations' => $function_declarations )
)
);
}
/**
* Adds a web search tool.
*
* @since 0.7.0
*
* @param string[] $allowed_domains Optional. The allowed domains. Default empty array.
* @param string[] $disallowed_domains Optional. The disallowed domains. Default empty array.
*/
public function add_web_search_tool( array $allowed_domains = array(), array $disallowed_domains = array() ): void {
$this->add_tool(
Web_Search_Tool::from_array(
array(
'webSearch' => array(
'allowedDomains' => $allowed_domains,
'disallowedDomains' => $disallowed_domains,
),
)
)
);
}
/**
* Adds a tool.
*
* @since 0.5.0
*
* @param Tool $tool The tool.
*/
public function add_tool( Tool $tool ): void {
$this->tools[] = $tool;
}
/**
* Returns an iterator for the tools collection.
*
* @since 0.5.0
*
* @return ArrayIterator<int, Tool> Collection iterator.
*/
public function getIterator(): Traversable {
return new ArrayIterator( $this->tools );
}
/**
* Returns the size of the tools collection.
*
* @since 0.5.0
*
* @return int Collection size.
*/
public function count(): int {
return count( $this->tools );
}
/**
* Returns the tool at the given index.
*
* @since 0.5.0
*
* @param int $index The index.
* @return Tool The tool.
*
* @throws InvalidArgumentException Thrown if the index is out of bounds.
*/
public function get( int $index ): Tool {
if ( ! isset( $this->tools[ $index ] ) ) {
throw new InvalidArgumentException(
'Index out of bounds.'
);
}
return $this->tools[ $index ];
}
/**
* Returns the array representation.
*
* @since 0.5.0
*
* @return mixed[] Array representation.
*/
public function to_array(): array {
return array_map(
static function ( Tool $tool ) {
return $tool->to_array();
},
$this->tools
);
}
/**
* Creates a Tools instance from an array of tools data.
*
* @since 0.5.0
*
* @param mixed[] $data The tools data.
* @return Tools The Tools instance.
*
* @throws InvalidArgumentException Thrown if the tools data is invalid.
*/
public static function from_array( array $data ): Tools {
$tools = new Tools();
foreach ( $data as $tool ) {
if ( ! is_array( $tool ) ) {
throw new InvalidArgumentException( 'Invalid tool data.' );
}
if ( isset( $tool['functionDeclarations'] ) ) {
$tools->add_tool( Function_Declarations_Tool::from_array( $tool ) );
} elseif ( isset( $tool['webSearch'] ) ) {
$tools->add_tool( Web_Search_Tool::from_array( $tool ) );
} else {
throw new InvalidArgumentException( 'Invalid tool data.' );
}
}
return $tools;
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
$function_declarations_tool_schema = Function_Declarations_Tool::get_json_schema();
unset( $function_declarations_tool_schema['type'] );
$web_search_tool_schema = Web_Search_Tool::get_json_schema();
unset( $web_search_tool_schema['type'] );
return array(
'type' => 'array',
'minItems' => 1,
'items' => array(
'type' => 'object',
'oneOf' => array(
$function_declarations_tool_schema,
$web_search_tool_schema,
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Tools\Abstract_Tool
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Tools;
use ATFPP\AI_Translate\Services\API\Types\Contracts\Tool;
use ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema;
use InvalidArgumentException;
/**
* Base class for a tool for a generative model.
*
* @since 0.5.0
*/
abstract class Abstract_Tool implements Tool, With_JSON_Schema {
/**
* The tool data.
*
* @since 0.5.0
* @var array<string, mixed>
*/
private $data = array();
/**
* Constructor.
*
* @since 0.5.0
*/
final public function __construct() {
// Empty constructor, only to prevent override.
}
/**
* Sets data for the tool.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The tool data.
*/
final public function set_data( array $data ): void {
$this->data = $this->format_data( $data );
}
/**
* Formats the data for the tool.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The tool data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the tool data is invalid.
*/
abstract protected function format_data( array $data ): array;
/**
* Gets the default data for the tool.
*
* @since 0.5.0
*
* @return array<string, mixed> Default data.
*/
abstract protected function get_default_data(): array;
/**
* Returns the array representation.
*
* @since 0.5.0
*
* @return mixed[] Array representation.
*/
final public function to_array(): array {
if ( ! $this->data ) {
$this->data = $this->get_default_data();
}
return $this->data;
}
/**
* Creates a specific Tool instance from an array of tool data.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The tool data.
* @return Tool The Tool instance.
*
* @throws InvalidArgumentException Thrown if the tools data is invalid.
*/
final public static function from_array( array $data ): Tool {
$tool = new static();
$tool->set_data( $data );
return $tool;
}
}

View File

@@ -0,0 +1,147 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Tools\Function_Declarations_Tool
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Tools;
use InvalidArgumentException;
/**
* Class for a function declarations tool for a generative model.
*
* @since 0.5.0
*/
final class Function_Declarations_Tool extends Abstract_Tool {
/**
* Gets the function declarations from the tool.
*
* @since 0.5.0
*
* @return array<string, mixed>[] The function declarations.
*/
public function get_function_declarations(): array {
return $this->to_array()['functionDeclarations'];
}
/**
* Formats the data for the tool.
*
* @since 0.5.0
*
* @param array<string, mixed> $data The tool data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the tool data is invalid.
*/
protected function format_data( array $data ): array {
if ( ! isset( $data['functionDeclarations'] ) || ! is_array( $data['functionDeclarations'] ) ) {
throw new InvalidArgumentException( 'The function declarations tool data must contain an array functionDeclarations value.' );
}
foreach ( $data['functionDeclarations'] as &$function_declaration ) {
if ( ! isset( $function_declaration['name'] ) || ! is_string( $function_declaration['name'] ) ) {
throw new InvalidArgumentException( 'Each function declaration data must contain a string name value.' );
}
if ( isset( $function_declaration['description'] ) && ! is_string( $function_declaration['description'] ) ) {
throw new InvalidArgumentException( 'The description value of a function declaration must be a string.' );
}
if ( isset( $function_declaration['parameters'] ) && ! is_array( $function_declaration['parameters'] ) ) {
throw new InvalidArgumentException( 'The parameters value of a function declaration must be an object / associative array.' );
}
$function_declaration['parameters'] = $this->sanitize_parameters( $function_declaration['parameters'] );
}
return array(
'functionDeclarations' => $data['functionDeclarations'],
);
}
/**
* Gets the default data for the tool.
*
* @since 0.5.0
*
* @return array<string, mixed> Default data.
*/
protected function get_default_data(): array {
return array(
'functionDeclarations' => array(),
);
}
/**
* Sanitizes the parameters schema, ensuring every object property is required and additional properties are disallowed.
*
* @since 0.5.0
*
* @param array<string, mixed> $schema The schema to sanitize.
* @return array<string, mixed> Sanitized schema.
*/
protected function sanitize_parameters( array $schema ): array {
// Every schema must have a type, but that will be checked elsewhere so we can ignore it here.
if ( ! isset( $schema['type'] ) ) {
return $schema;
}
$type = (array) $schema['type'];
if ( in_array( 'object', $type, true ) ) {
if ( isset( $schema['properties'] ) ) {
$schema['required'] = array_keys( $schema['properties'] );
foreach ( $schema['properties'] as $key => $child_schema ) {
$schema['properties'][ $key ] = $this->sanitize_parameters( $child_schema );
}
}
$schema['additionalProperties'] = false;
}
if ( in_array( 'array', $type, true ) && isset( $schema['items'] ) ) {
$schema['items'] = $this->sanitize_parameters( $schema['items'] );
}
return $schema;
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.5.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'functionDeclarations' => array(
'description' => __( 'Function declarations for the tool.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'name' => array(
'description' => __( 'Name of the function.', 'ai-services' ),
'type' => 'string',
),
'description' => array(
'description' => __( 'Description of the function.', 'ai-services' ),
'type' => 'string',
),
'parameters' => array(
'description' => __( 'Supported parameters of the function, as an object in JSON schema.', 'ai-services' ),
'type' => 'object',
'additionalProperties' => true,
),
),
),
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,118 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\API\Types\Tools\Web_Search_Tool
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\API\Types\Tools;
use InvalidArgumentException;
/**
* Class for a web search tool for a generative model.
*
* @since 0.7.0
*/
final class Web_Search_Tool extends Abstract_Tool {
/**
* Gets the allowed domains for the tool.
*
* @since 0.7.0
*
* @return string[] The allowed domains.
*/
public function get_allowed_domains(): array {
return $this->to_array()['webSearch']['allowedDomains'];
}
/**
* Gets the disallowed domains for the tool.
*
* @since 0.7.0
*
* @return string[] The disallowed domains.
*/
public function get_disallowed_domains(): array {
return $this->to_array()['webSearch']['disallowedDomains'];
}
/**
* Formats the data for the tool.
*
* @since 0.7.0
*
* @param array<string, mixed> $data The tool data.
* @return array<string, mixed> Formatted data.
*
* @throws InvalidArgumentException Thrown if the tool data is invalid.
*/
protected function format_data( array $data ): array {
if ( isset( $data['webSearch']['allowedDomains'] ) && ! is_array( $data['webSearch']['allowedDomains'] ) ) {
throw new InvalidArgumentException( 'The allowedDomains value for the web search tool data must be an array of strings.' );
}
if ( isset( $data['webSearch']['disallowedDomains'] ) && ! is_array( $data['webSearch']['disallowedDomains'] ) ) {
throw new InvalidArgumentException( 'The disallowedDomains value for the web search tool data must be an array of strings.' );
}
return array(
'webSearch' => array(
'allowedDomains' => isset( $data['webSearch']['allowedDomains'] ) ? array_values( array_filter( $data['webSearch']['allowedDomains'], 'is_string' ) ) : array(),
'disallowedDomains' => isset( $data['webSearch']['disallowedDomains'] ) ? array_values( array_filter( $data['webSearch']['disallowedDomains'], 'is_string' ) ) : array(),
),
);
}
/**
* Gets the default data for the tool.
*
* @since 0.7.0
*
* @return array<string, mixed> Default data.
*/
protected function get_default_data(): array {
return array(
'webSearch' => array(
'allowedDomains' => array(),
'disallowedDomains' => array(),
),
);
}
/**
* Returns the JSON schema for the expected input.
*
* @since 0.7.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'webSearch' => array(
'type' => 'object',
'properties' => array(
'allowedDomains' => array(
'description' => __( 'Web search allowed domains for the tool.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
'disallowedDomains' => array(
'description' => __( 'Web search disallowed domains for the tool.', 'ai-services' ),
'type' => 'array',
'items' => array(
'type' => 'string',
),
),
),
),
),
'additionalProperties' => false,
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Authentication\API_Key_Authentication
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Authentication;
use ATFPP\AI_Translate\Services\Contracts\Authentication;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
/**
* Class that represents an API key.
*
* @since 0.1.0
*/
final class API_Key_Authentication implements Authentication {
/**
* The API key.
*
* @since 0.1.0
* @var string
*/
private $api_key;
/**
* The HTTP header to use for the API key.
*
* @since 0.1.0
* @var string
*/
private $header_name = 'Authorization';
/**
* The authentication scheme to use for the API key.
*
* @since 0.1.0
* @var string
*/
private $authencation_scheme = 'Bearer';
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $api_key The API key.
*/
public function __construct( string $api_key ) {
$this->api_key = $api_key;
}
/**
* Authenticates the given request with the credentials.
*
* @since 0.1.0
*
* @param Request $request The request instance. Updated in place.
*/
public function authenticate( Request $request ): void {
if ( 'authorization' === strtolower( $this->header_name ) ) {
$request->add_header( $this->header_name, $this->authencation_scheme . ' ' . $this->api_key );
} else {
$request->add_header( $this->header_name, $this->api_key );
}
}
/**
* Sets the header name to use to add the credentials to a request.
*
* @since 0.1.0
*
* @param string $header_name The header name.
*/
public function set_header_name( string $header_name ): void {
$this->header_name = $header_name;
}
public function set_authencation_scheme( string $authencation_scheme ): void {
$this->authencation_scheme = $authencation_scheme;
}
/**
* Returns the option definitions needed to store the credentials.
*
* @since 0.1.0
*
* @param string $service_slug The service slug.
* @return array<string, array<string, mixed>> The option definitions.
*/
public static function get_option_definitions( string $service_slug ): array {
$option_slug = sprintf( 'Atfpp_Ai_Translate_%s_api_key', $service_slug );
return array(
$option_slug => array(
'type' => 'string',
'default' => '',
'show_in_rest' => true,
'autoload' => true,
),
);
}
}

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();
},
);
}
}

View File

@@ -0,0 +1,326 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Cache\Service_Request_Cache
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Cache;
use Exception;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Model;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Service;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
use InvalidArgumentException;
use RuntimeException;
/**
* Class that allows to wrap service method calls so that their return values are cached.
*
* @since 0.1.0
*/
final class Service_Request_Cache {
/**
* Wraps the given method call in a WordPress transient so that its return value is cached.
*
* The transient name is generated based on the method name and arguments. It is unique per service and method
* name, so that different services can have methods with the same name that are cached separately. It also
* includes a timestamp of when the service configuration was last changed, so that the cache is invalidated as
* needed.
*
* If the method throws an exception, the exception is cached as well, so that it can be rethrown on subsequent
* calls.
*
* The transient is stored for 24 hours.
*
* @since 0.1.0
*
* @param string $service_slug Service slug.
* @param callable $method Method to cache.
* @param mixed[] $args Optional. Method arguments. Default empty array.
* @return mixed Method return value, potentially served from cache.
*
* @throws Exception Rethrown original exception from the method call, if there was one.
*/
public static function wrap_transient( string $service_slug, callable $method, array $args = array() ) {
$key = self::get_cache_key( $method, $args );
$last_changed = self::get_last_changed( $service_slug );
$transient_name = "ATFPP:{$service_slug}:{$key}:{$last_changed}";
$value = get_transient( $transient_name );
if ( false === $value ) {
$value = self::call_method( $method, $args );
set_transient( $transient_name, self::sanitize_value_for_cache( $value ), DAY_IN_SECONDS );
} else {
$value = self::parse_value_from_cache( $value );
}
if ( $value instanceof Exception ) {
throw $value;
}
return $value;
}
/**
* Wraps the given method call in the WordPress object cache so that its return value is cached.
*
* The cache key is generated based on the method name and arguments. It is unique per service and method name,
* so that different services can have methods with the same name that are cached separately. It also includes
* a timestamp of when the service configuration was last changed, so that the cache is invalidated as needed.
*
* The service slug is used as the cache group.
*
* If the method throws an exception, the exception is cached as well, so that it can be rethrown on subsequent
* calls.
*
* The cached value is stored for 24 hours.
*
* @since 0.1.0
*
* @param string $service_slug Service slug.
* @param callable $method Method to cache.
* @param mixed[] $args Optional. Method arguments. Default empty array.
* @return mixed Method return value, potentially served from cache.
*
* @throws Exception Rethrown original exception from the method call, if there was one.
*/
public static function wrap_cache( string $service_slug, callable $method, array $args = array() ) {
$key = self::get_cache_key( $method, $args );
$last_changed = self::get_last_changed( $service_slug );
$cache_name = "{$key}:{$last_changed}";
$value = wp_cache_get( $cache_name, $service_slug );
if ( false === $value ) {
$value = self::call_method( $method, $args );
wp_cache_set( $cache_name, self::sanitize_value_for_cache( $value ), $service_slug, DAY_IN_SECONDS );
} else {
$value = self::parse_value_from_cache( $value );
}
if ( $value instanceof Exception ) {
throw $value;
}
return $value;
}
/**
* Invalidates the caches for a service.
*
* This method should be called whenever the configuration of a service changes, so that the caches are invalidated
* and the next request will fetch fresh data. This encompasses both transients and the object cache.
*
* @since 0.1.0
*
* @param string $service_slug Service slug.
*/
public static function invalidate_caches( string $service_slug ): void {
self::set_last_changed( $service_slug );
// Not strictly necessary, but if we can clean up, let's do so.
if (
function_exists( 'wp_cache_flush_group' ) &&
function_exists( 'wp_cache_supports' ) &&
wp_cache_supports( 'flush_group' )
) {
wp_cache_flush_group( $service_slug );
}
}
/**
* Calls the given method with the given arguments, catching any exceptions that are thrown.
*
* If an exception is thrown, it will be returned instead of the method's return value.
*
* @since 0.1.0
*
* @param callable $method Method to call.
* @param mixed[] $args Method arguments.
* @return mixed Method return value or exception.
*/
private static function call_method( callable $method, array $args ) {
try {
return call_user_func_array( $method, $args );
} catch ( Exception $e ) {
return $e;
}
}
/**
* Sanitizes the given value to be stored in the cache.
*
* If the value is an exception, it is converted to an array with the exception class name and message.
*
* @since 0.1.0
*
* @param mixed $value Value to sanitize.
* @return mixed Sanitized value.
*/
private static function sanitize_value_for_cache( $value ) {
// Exception thrown.
if ( is_object( $value ) && $value instanceof Exception ) {
return array(
'classname' => get_class( $value ),
'message' => $value->getMessage(),
);
}
// Arrayable class object.
if ( is_object( $value ) && $value instanceof Arrayable && method_exists( get_class( $value ), 'from_array' ) ) {
return array(
'classname' => get_class( $value ),
'data' => $value->to_array(),
);
}
// Array (recursion necessary).
if ( is_array( $value ) ) {
foreach ( $value as $key => $item ) {
$value[ $key ] = self::sanitize_value_for_cache( $item );
}
}
return $value;
}
/**
* Parses the given value from the cache.
*
* This converts any sanitized exceptions back to their original exception form.
*
* @since 0.1.0
*
* @param mixed $value Value from the cache.
* @return mixed Parsed value.
*
* @throws RuntimeException Thrown if the cached value uses an invalid class.
*/
private static function parse_value_from_cache( $value ) {
// Exception thrown.
if ( is_array( $value ) && isset( $value['classname'], $value['message'] ) ) {
$class = $value['classname'];
if ( ! class_exists( $class ) ) { // This should never be true, but a reasonable safeguard.
$class = Exception::class;
}
$message = $value['message'];
return new $class( $message );
}
// Arrayable class object.
if ( is_array( $value ) && isset( $value['classname'], $value['data'] ) ) {
$class = $value['classname'];
if ( ! class_exists( $class ) || ! method_exists( $class, 'from_array' ) ) { // This should never be true, but a reasonable safeguard.
throw new RuntimeException(
sprintf(
/* translators: %s: class name */
esc_html__( 'The class %s from the cached value does not exist or does not have a from_array method.', 'ai-services' ),
esc_html( $class )
)
);
}
$data = $value['data'];
return call_user_func( array( $class, 'from_array' ), $data );
}
// Array (recursion necessary).
if ( is_array( $value ) ) {
foreach ( $value as $key => $item ) {
$value[ $key ] = self::parse_value_from_cache( $item );
}
}
return $value;
}
/**
* Gets the cache key for a method call.
*
* The returned key does not include the service slug, so the service slug has to be separately included as part of
* the identifier for where to cache the value.
*
* @since 0.1.0
*
* @param callable $method Method to cache.
* @param mixed[] $args Optional. Method arguments. Default empty array.
* @return string Cache key.
*
* @throws InvalidArgumentException Thrown if the method is not a method on a service or model instance.
*/
private static function get_cache_key( callable $method, array $args = array() ): string {
if ( ! is_array( $method ) || ! is_object( $method[0] ) || ! is_string( $method[1] ) ) {
throw new InvalidArgumentException(
esc_html__( 'Only methods on service and model instances can be cached.', 'ai-services' )
);
}
if ( $method[0] instanceof Generative_AI_Service ) {
$type = 'service';
} elseif ( $method[0] instanceof Generative_AI_Model ) {
$type = 'model';
} else {
throw new InvalidArgumentException(
esc_html__( 'Only methods on service and model instances can be cached.', 'ai-services' )
);
}
return $type . ':' . self::get_cache_hash( $method[1], $args );
}
/**
* Gets the cache hash for a method call.
*
* @since 0.1.0
*
* @param string $method_name Method name.
* @param mixed[] $args Optional. Method arguments. Default empty array.
* @return string Cache hash.
*/
private static function get_cache_hash( string $method_name, array $args = array() ): string {
$hash = $method_name;
if ( ! empty( $args ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
$hash .= '_' . md5( serialize( $args ) );
}
return $hash;
}
/**
* Gets the last changed value for a service.
*
* @since 0.1.0
*
* @param string $service_slug Service slug.
* @return string UNIX timestamp for when the configuration of the service was last changed.
*/
private static function get_last_changed( string $service_slug ): string {
if ( wp_using_ext_object_cache() ) {
return wp_cache_get_last_changed( $service_slug );
}
$last_changed_option = (array) get_option( 'ais_services_last_changed', array() );
if ( ! isset( $last_changed_option[ $service_slug ] ) ) {
$last_changed_option[ $service_slug ] = microtime();
update_option( 'ais_services_last_changed', $last_changed_option );
}
return $last_changed_option[ $service_slug ];
}
/**
* Sets the last changed value for a service to the current UNIX timestamp.
*
* @since 0.1.0
*
* @param string $service_slug Service slug.
*/
private static function set_last_changed( string $service_slug ): void {
if ( wp_using_ext_object_cache() ) {
wp_cache_set_last_changed( $service_slug );
return;
}
$last_changed_option = (array) get_option( 'ais_services_last_changed', array() );
$last_changed_option[ $service_slug ] = microtime();
update_option( 'ais_services_last_changed', $last_changed_option );
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\Authentication
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
/**
* Interface for a class representing authentication credentials of a certain kind for an API client.
*
* @since 0.1.0
*/
interface Authentication {
/**
* Authenticates the given request with the credentials.
*
* @since 0.1.0
*
* @param Request $request The request instance. Updated in place.
*/
public function authenticate( Request $request ): void;
/**
* Sets the header name to use to add the credentials to a request.
*
* @since 0.1.0
*
* @param string $header_name The header name.
*/
public function set_header_name( string $header_name ): void;
/**
* Returns the option definitions needed to store the credentials.
*
* @since 0.1.0
*
* @param string $service_slug The service slug.
* @return array<string, array<string, mixed>> The option definitions.
*/
public static function get_option_definitions( string $service_slug ): array;
}

View File

@@ -0,0 +1,49 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\Generation_Config
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Arrayable;
/**
* Interface for a class representing configuration options for a generative AI model.
*
* @since 0.7.0
*/
interface Generation_Config extends Arrayable, With_JSON_Schema {
/**
* 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.
*/
public function get_arg( string $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.
*/
public function get_args(): array;
/**
* Returns the additional arguments.
*
* @since 0.7.0
*
* @return array<string, mixed> The additional arguments.
*/
public function get_additional_args(): array;
}

View File

@@ -0,0 +1,148 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\Generative_AI_API_Client
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Response;
use Generator;
use InvalidArgumentException;
/**
* Interface for a class representing a client for a generative AI web API.
*
* @since 0.1.0
*/
interface Generative_AI_API_Client {
/**
* 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;
/**
* 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;
/**
* Sends the given request to the API and returns the response data.
*
* @since 0.1.0
*
* @param Request $request The request instance.
* @return Response The response instance.
*
* @throws Generative_AI_Exception If an error occurs while making the request.
*/
public function make_request( Request $request ): Response;
/**
* Processes the response data from the API.
*
* @since 0.3.0
*
* @param Response $response The response instance. Must not be a stream response, i.e. not implement the
* With_Stream interface.
* @param callable $process_callback The callback to process the response data. Receives the JSON-decoded response
* data as associative array and should return the processed data in the desired
* format.
* @return mixed The processed response data.
*
* @throws Generative_AI_Exception If an error occurs while processing the response data.
*/
public function process_response_data( Response $response, $process_callback );
/**
* Processes the response body from the API.
*
* @since 0.7.0
*
* @param Response $response The response instance. Must not be a stream response, i.e. not implement the
* With_Stream interface.
* @param callable $process_callback The callback to process the response body. Receives the response body as
* string and should return the processed data in the desired format.
* @return mixed The processed response data.
*
* @throws Generative_AI_Exception If an error occurs while processing the response body.
*/
public function process_response_body( Response $response, $process_callback );
/**
* Processes the response data stream from the API.
*
* @since 0.3.0
*
* @param Response $response The response instance. Must implement With_Stream. The response data will
* be processed in chunks, with each chunk of data being passed to the process
* callback.
* @param callable $process_callback The callback to process the response data. Receives the JSON-decoded response
* data (associative array) as first parameter, and the previous processed data
* as second parameter (or null in case this is the first chunk). It should
* return the processed data for the chunk in the desired format.
* @return Generator Generator that yields the individual processed response data chunks.
*
* @throws Generative_AI_Exception If an error occurs while processing the response data.
*/
public function process_response_stream( Response $response, $process_callback ): Generator;
/**
* Creates a new exception for a bad request, i.e. invalid or unsupported request data.
*
* @since 0.7.0
*
* @param string $message The error message to include in the exception.
* @return InvalidArgumentException The exception instance.
*/
public function create_bad_request_exception( string $message ): InvalidArgumentException;
/**
* Creates a new exception for an AI API request error.
*
* @since 0.3.0
*
* @param string $message The error message to include in the exception.
* @return Generative_AI_Exception The exception instance.
*/
public function create_request_exception( string $message ): Generative_AI_Exception;
/**
* Creates a new exception for an AI API response error.
*
* @since 0.3.0
*
* @param string $message The error message to include in the exception.
* @return Generative_AI_Exception The exception instance.
*/
public function create_response_exception( string $message ): Generative_AI_Exception;
/**
* Creates a new exception for an AI API response error for a missing key.
*
* @since 0.3.0
*
* @param string $key The missing key in the response data.
* @return Generative_AI_Exception The exception instance.
*/
public function create_missing_response_key_exception( string $key ): Generative_AI_Exception;
}

View File

@@ -0,0 +1,37 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\Generative_AI_Model
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
use ATFPP\AI_Translate\Services\API\Types\Model_Metadata;
/**
* Interface for a class representing a generative AI model.
*
* @since 0.1.0
*/
interface Generative_AI_Model {
/**
* Gets the model slug.
*
* @since 0.1.0
*
* @return string The model slug.
*/
public function get_model_slug(): string;
/**
* Gets the model metadata.
*
* @since 0.7.0
*
* @return Model_Metadata The model metadata.
*/
public function get_model_metadata(): Model_Metadata;
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\Generative_AI_Service
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
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\Service_Metadata;
use ATFPP\AI_Translate\Services\API\Types\Tool_Config;
use ATFPP\AI_Translate\Services\API\Types\Tools;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use InvalidArgumentException;
/**
* Interface for a class representing a generative AI service which provides access to models.
*
* @since 0.1.0
*/
interface Generative_AI_Service {
/**
* Gets the service slug.
*
* @since 0.1.0
*
* @return string The service slug.
*/
public function get_service_slug(): string;
/**
* Gets the service metadata.
*
* @since 0.7.0
*
* @return Service_Metadata The service metadata.
*/
public function get_service_metadata(): Service_Metadata;
/**
* Checks whether the service is connected.
*
* This is typically used to check whether the current service credentials are valid.
*
* @since 0.2.0
*
* @return bool True if the service is connected, false otherwise.
*/
public function is_connected(): bool;
/**
* Lists the available generative model slugs and their metadata.
*
* @since 0.1.0
* @since 0.5.0 Return type changed to a map of model data shapes.
* @since 0.7.0 Return type changed to a map of model metadata objects.
*
* @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.
*/
public function list_models( array $request_options = array() ): array;
/**
* Gets a generative model instance for the provided model parameters.
*
* @since 0.1.0
* @since 0.5.0 Support for the $tools and $toolConfig arguments was added.
*
* @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.
*/
public function get_model( array $model_params = array(), array $request_options = array() ): Generative_AI_Model;
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_API_Client
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a service or model that uses an AI API client.
*
* @since 0.7.0
*/
interface With_API_Client {
/**
* Gets the API client instance.
*
* @since 0.7.0
*
* @return Generative_AI_API_Client The API client instance.
*/
public function get_api_client(): Generative_AI_API_Client;
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_Function_Calling
*
* @since 0.5.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a model which supports function calling.
*
* @since 0.5.0
*/
interface With_Function_Calling {
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_JSON_Schema
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a class that provides a JSON schema for its input.
*
* @since 0.2.0
*/
interface With_JSON_Schema {
/**
* Returns the JSON schema for the expected input.
*
* @since 0.2.0
*
* @return array<string, mixed> The JSON schema.
*/
public static function get_json_schema(): array;
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_Multimodal_Input
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a model which allows multimodal input.
*
* @since 0.1.0
*/
interface With_Multimodal_Input {
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_Multimodal_Output
*
* @since 0.6.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a model which allows multimodal output.
*
* @since 0.6.0
*/
interface With_Multimodal_Output {
}

View File

@@ -0,0 +1,55 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_Text_Generation
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
use ATFPP\AI_Translate\Services\API\Types\Candidates;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use Generator;
use InvalidArgumentException;
/**
* Interface for a model which allows generating text content.
*
* @since 0.1.0
*/
interface With_Text_Generation {
/**
* Generates text content using the model.
*
* @since 0.1.0
*
* @param string|Parts|Content|Content[] $content Prompt for the content to generate. Optionally, an array
* can be passed for additional context (e.g. chat history).
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Candidates The response candidates with generated text content - usually just one.
*
* @throws InvalidArgumentException Thrown if the given content is invalid.
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
public function generate_text( $content, array $request_options = array() ): Candidates;
/**
* Generates text content using the model, streaming the response.
*
* @since 0.3.0
*
* @param string|Parts|Content|Content[] $content Prompt for the content to generate. Optionally, an array
* can be passed for additional context (e.g. chat history).
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Generator<Candidates> Generator that yields the chunks of response candidates with generated text
* content - usually just one candidate.
*
* @throws InvalidArgumentException Thrown if the given content is invalid.
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
public function stream_generate_text( $content, array $request_options = array() ): Generator;
}

View File

@@ -0,0 +1,18 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\Contracts\With_Web_Search
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Contracts;
/**
* Interface for a model which supports web search.
*
* @since 0.7.0
*/
interface With_Web_Search {
}

View File

@@ -0,0 +1,241 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Decorators\AI_Service_Decorator
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Decorators;
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\Service_Metadata;
use ATFPP\AI_Translate\Services\API\Types\Tool_Config;
use ATFPP\AI_Translate\Services\API\Types\Tools;
use ATFPP\AI_Translate\Services\Cache\Service_Request_Cache;
use ATFPP\AI_Translate\Services\Contracts\Generation_Config;
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 InvalidArgumentException;
/**
* Class for an AI service that wraps another AI service through a decorator pattern.
*
* This class effectively acts as middleware for the underlying AI service, allowing for additional functionality to be
* centrally provided.
*
* @since 0.1.0
*/
class AI_Service_Decorator implements Generative_AI_Service {
/**
* The underlying AI service to wrap.
*
* @since 0.1.0
* @var Generative_AI_Service
*/
private $service;
/**
* Constructor.
*
* @since 0.1.0
*
* @param Generative_AI_Service $service The underlying AI service to wrap.
*/
public function __construct( Generative_AI_Service $service ) {
$this->service = $service;
}
/**
* Gets the service slug.
*
* @since 0.1.0
*
* @return string The service slug.
*/
public function get_service_slug(): string {
return $this->service->get_service_slug();
}
/**
* Gets the service metadata.
*
* @since 0.7.0
*
* @return Service_Metadata The service metadata.
*/
public function get_service_metadata(): Service_Metadata {
return $this->service->get_service_metadata();
}
/**
* Checks whether the service is connected.
*
* This is typically used to check whether the current service credentials are valid.
*
* @since 0.2.0
*
* @return bool True if the service is connected, false otherwise.
*/
public function is_connected(): bool {
if ( ! function_exists( 'get_transient' ) ) {
// If the transient function is not available, we cannot cache the result.
return $this->service->is_connected();
}
return Service_Request_Cache::wrap_transient(
$this->get_service_slug(),
array( $this->service, 'is_connected' )
);
}
/**
* Lists the available generative model slugs and their metadata.
*
* @since 0.1.0
* @since 0.5.0 Return type changed to a map of model data shapes.
* @since 0.7.0 Return type changed to a map of model metadata objects.
*
* @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.
*/
public function 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->service->list_models( $request_options );
}
return Service_Request_Cache::wrap_transient(
$this->get_service_slug(),
array( $this->service, 'list_models' ),
array( $request_options )
);
}
/**
* Gets a generative model instance for the provided model parameters.
*
* @since 0.1.0
* @since 0.5.0 Support for the $tools and $toolConfig arguments was added.
*
* @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.
*/
public function get_model( array $model_params = array(), array $request_options = array() ): Generative_AI_Model {
if ( ! isset( $model_params['feature'] ) || ! preg_match( '/^[a-z0-9-]+$/', $model_params['feature'] ) ) {
throw new InvalidArgumentException(
'You must provide a "feature" identifier as part of the model parameters, which only contains lowercase letters, numbers, and hyphens.'
);
}
/**
* Filters the AI service model parameters before retrieving the model with them.
*
* This can be used, for example, to inject additional parameters via server-side logic based on the given
* feature identifier.
*
* @since 0.2.0
*
* @param array<string, mixed> $model_params The model parameters. Commonly supports at least the parameters
* 'feature', 'capabilities', 'generationConfig' and
* 'systemInstruction'.
* @param string $service_slug The service slug.
*
* @return array<string, mixed> The processed model parameters.
*/
$filtered_model_params = (array) apply_filters( 'ai_services_model_params', $model_params, $this->service->get_service_slug() );
// Ensure that the feature identifier cannot be changed.
$filtered_model_params['feature'] = $model_params['feature'];
$model_params = $filtered_model_params;
// Perform basic validation so that the model classes don't have to.
$this->validate_model_params( $model_params );
return $this->service->get_model( $model_params, $request_options );
}
/**
* Validates various model parameters centrally.
*
* @since 0.5.0
*
* @param array<string, mixed> $model_params The model parameters.
*
* @throws InvalidArgumentException Thrown if the model parameters are invalid.
*/
private function validate_model_params( array $model_params ): void {
if (
isset( $model_params['tools'] )
&& ! $model_params['tools'] instanceof Tools
) {
throw new InvalidArgumentException(
sprintf(
'The tools argument must be an instance of %s.',
Tools::class
)
);
}
if (
isset( $model_params['toolConfig'] )
&& ! $model_params['toolConfig'] instanceof Tool_Config
) {
throw new InvalidArgumentException(
sprintf(
'The tool config argument must be an instance of %s.',
Tool_Config::class
)
);
}
if (
isset( $model_params['generationConfig'] )
&& ! $model_params['generationConfig'] instanceof Generation_Config
) {
throw new InvalidArgumentException(
sprintf(
'The generation config argument must be an instance of %s.',
Generation_Config::class
)
);
}
if (
isset( $model_params['systemInstruction'] )
&& ! is_string( $model_params['systemInstruction'] )
&& ! $model_params['systemInstruction'] instanceof Parts
&& ! $model_params['systemInstruction'] instanceof Content
) {
throw new InvalidArgumentException(
sprintf(
'The system instruction argument must be either a string, or an instance of %1$s, or an instance of %2$s.',
'Parts',
'Content'
)
);
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Exception;
use RuntimeException;
/**
* Class for an exception thrown when a runtime error occurs in a generative AI service, e.g. a failing API request.
*
* @since 0.1.0
*/
class Generative_AI_Exception extends RuntimeException {
}

View File

@@ -0,0 +1,33 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\HTTP\Contracts\Stream_Request_Handler
*
* @since 0.6.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\HTTP\Contracts;
use ATFPP\AI_Translate\Services\HTTP\Stream_Response;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Exception\Request_Exception;
/**
* Interface for a request handler that can stream responses.
*
* @since 0.6.0
*/
interface Stream_Request_Handler {
/**
* Sends an HTTP request and streams the response.
*
* @since 0.6.0
*
* @param Request $request The request to send.
* @return Stream_Response The stream response.
*
* @throws Request_Exception Thrown if the request fails.
*/
public function request_stream( Request $request ): Stream_Response;
}

View File

@@ -0,0 +1,28 @@
<?php
/**
* Interface ATFPP\AI_Translate\Services\HTTP\Contracts\With_Stream
*
* @since 0.3.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\HTTP\Contracts;
use Generator;
/**
* Interface for a class that contains a readable stream.
*
* @since 0.3.0
*/
interface With_Stream {
/**
* Returns a generator that reads individual chunks of decoded JSON data from the streamed response body.
*
* @since 0.3.0
*
* @return Generator The generator for the response stream.
*/
public function read_stream(): Generator;
}

View File

@@ -0,0 +1,104 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\HTTP\HTTP_With_Streams
*
* @since 0.3.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\HTTP;
use ATFPP\AI_Translate\Services\HTTP\Contracts\Stream_Request_Handler;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Exception\Request_Exception;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\HTTP;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
/**
* Extended HTTP class with support for streaming responses.
*
* @since 0.3.0
*/
final class HTTP_With_Streams extends HTTP implements Stream_Request_Handler {
/**
* Guzzle client instance.
*
* Used for streaming requests, as WordPress Core's Requests API does not support this.
*
* @since 0.3.0
* @var Client
*/
private $guzzle;
/**
* Constructor.
*
* @since 0.3.0
*
* @param array<string, mixed> $default_options Optional. Default options to use for all requests. Default empty
* array.
*/
public function __construct( array $default_options = array() ) {
parent::__construct( $default_options );
$this->guzzle = new Client();
}
/**
* Sends an HTTP request and streams the response.
*
* @since 0.3.0
*
* @param Request $request The request to send.
* @return Stream_Response The stream response.
*
* @throws Request_Exception Thrown if the request fails.
*/
public function request_stream( Request $request ): Stream_Response {
$request_args = $this->build_request_args( $request );
$request_options = array(
'allow_redirects' => $request_args['options']['redirection'] > 0 ? array( 'max' => $request_args['options']['redirection'] ) : false,
'timeout' => (float) $request_args['options']['timeout'],
'stream' => true,
);
if ( isset( $request_args['data'] ) ) {
if ( in_array( $request_args['type'], array( Request::HEAD, Request::GET, Request::DELETE ), true ) ) {
$request_options['query'] = $request_args['data'];
} else {
if ( ! is_string( $request_args['data'] ) ) {
$request_args['data'] = http_build_query( $request_args['data'], '', '&' );
}
$request_options['body'] = $request_args['data'];
}
}
if ( isset( $request_args['headers'] ) ) {
if ( ! isset( $request_args['headers']['User-Agent'] ) ) {
$request_args['headers']['User-Agent'] = $request_args['options']['user-agent'];
}
} else {
$request_args['headers'] = array(
'User-Agent' => $request_args['options']['user-agent'],
);
}
$request_options['headers'] = $request_args['headers'];
try {
$response = $this->guzzle->request(
$request_args['type'],
$request_args['url'],
$request_options
);
} catch ( ClientException $e ) {
throw new Request_Exception(
htmlspecialchars( $e->getMessage() ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
$headers = $this->sanitize_headers( $response->getHeaders() );
return new Stream_Response( $response->getStatusCode(), $response->getBody(), $headers );
}
}

View File

@@ -0,0 +1,106 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\HTTP\Stream_Response
*
* @since 0.3.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\HTTP;
use ATFPP\AI_Translate\Services\HTTP\Contracts\With_Stream;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Generic_Response;
use ATFPP\AI_Translate_Dependencies\Psr\Http\Message\StreamInterface;
use Generator;
use InvalidArgumentException;
use IteratorAggregate;
/**
* Class for a HTTP response that uses streaming.
*
* @since 0.3.0
*
* @implements IteratorAggregate<Generator>
*/
class Stream_Response extends Generic_Response implements With_Stream, IteratorAggregate {
/**
* The stream to read from.
*
* @since 0.3.0
* @var StreamInterface
*/
private $stream;
/**
* Constructor.
*
* @since 0.3.0
*
* @param int $status The HTTP status code received with the response.
* @param StreamInterface $stream The response body stream to read from.
* @param array<string, string> $headers The headers received with the response.
*
* @throws InvalidArgumentException Thrown if the $stream parameter has an invalid type.
*/
public function __construct( int $status, StreamInterface $stream, array $headers ) {
parent::__construct( $status, '', $headers );
$this->stream = $stream;
}
/**
* Returns a generator that reads individual chunks of decoded JSON data from the streamed response body.
*
* @since 0.3.0
*
* @return Generator The generator for the response stream.
*/
public function read_stream(): Generator {
while ( ! $this->stream->eof() ) {
$line = $this->read_line( $this->stream );
$data = json_decode( $line, true );
if ( ! $data ) {
continue;
}
yield $data;
}
}
/**
* Retrieves an iterator reading individual chunks of decoded JSON data from the streamed response body.
*
* @since 0.3.0
*
* @return Generator The iterator for the response stream.
*/
public function getIterator(): Generator {
return $this->read_stream();
}
/**
* Reads a line from the stream.
*
* @since 0.3.0
*
* @param StreamInterface $stream The stream to read from.
* @return string The line read from the stream.
*/
private function read_line( $stream ): string {
$buffer = '';
while ( ! $stream->eof() ) {
$buffer .= $stream->read( 1 );
if ( strlen( $buffer ) === 1 && '{' !== $buffer ) {
$buffer = '';
}
if ( json_decode( $buffer ) !== null ) {
return $buffer;
}
}
return rtrim( $buffer, ']' );
}
}

View File

@@ -0,0 +1,166 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Options\Option_Encrypter
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Options;
use ATFPP\AI_Translate\Services\Util\Data_Encryption;
/**
* Class that allows for options to be encrypted when stored in the database as well as decrypted when retrieved.
*
* @since 0.1.0
*/
final class Option_Encrypter {
const ENCRYPTION_PREFIX = 'enc::';
/**
* The data encryption instance.
*
* @since 0.1.0
* @var Data_Encryption
*/
private $data_encryption;
/**
* Constructor.
*
* @since 0.1.0
*
* @param Data_Encryption $data_encryption The data encryption instance.
*/
public function __construct( Data_Encryption $data_encryption ) {
$this->data_encryption = $data_encryption;
}
/**
* Adds relevant hooks to handle encryption and decryption of the given option.
*
* @since 0.1.0
*
* @param string $option_slug The option to use encryption with.
*/
public function add_encryption_hooks( string $option_slug ): void {
add_filter( "sanitize_option_{$option_slug}", array( $this, 'encrypt_option' ), 9999, 2 ); // Encrypt late.
add_filter( "option_{$option_slug}", array( $this, 'decrypt_option' ), -9999, 2 ); // Decrypt early.
}
/**
* Checks if the given option has encryption enabled.
*
* @since 0.1.0
*
* @param string $option_slug The identifier/name of the option.
* @return bool True if the option has encryption enabled, false otherwise.
*/
public function has_encryption( string $option_slug ): bool {
return (
has_filter( "sanitize_option_{$option_slug}", array( $this, 'encrypt_option' ) ) &&
has_filter( "option_{$option_slug}", array( $this, 'decrypt_option' ) )
);
}
/**
* Encrypts the given option value.
*
* @since 0.1.0
*
* @param mixed $value The option value to encrypt.
* @param string $option_slug The identifier/name of the option.
* @return string Encrypted option value.
*/
public function encrypt_option( $value, string $option_slug ): string {
// Do not encrypt if the value is empty.
if ( '' === $value ) {
return $value;
}
// Bail if the value is already encrypted.
if ( is_string( $value ) && str_starts_with( $value, self::ENCRYPTION_PREFIX ) ) {
return $value;
}
$encrypted = $this->data_encryption->encrypt( maybe_serialize( $value ) );
// If encryption fails, trigger a warning but continue with the unencrypted value. Better not to lose data.
if ( ! $encrypted ) {
$this->trigger_error(
__METHOD__,
sprintf(
/* translators: %s: Option slug */
__( 'Failed to encrypt the value for the option "%s".', 'ai-services' ),
$option_slug
)
);
return $value;
}
return self::ENCRYPTION_PREFIX . $encrypted;
}
/**
* Decrypts the given option value.
*
* @since 0.1.0
*
* @param mixed $value The option value to decrypt.
* @param string $option_slug The identifier/name of the option.
* @return mixed Decrypted option value.
*/
public function decrypt_option( $value, string $option_slug ) {
// Bail if the value is already decrypted.
if ( ! is_string( $value ) || ! str_starts_with( $value, self::ENCRYPTION_PREFIX ) ) {
return $value;
}
$decrypted = $this->data_encryption->decrypt( substr( $value, strlen( self::ENCRYPTION_PREFIX ) ) );
// If decryption fails, trigger a warning and return an empty string.
if ( ! $decrypted ) {
$this->trigger_error(
__METHOD__,
sprintf(
/* translators: %s: Option slug */
__( 'Failed to decrypt the value for the option "%s".', 'ai-services' ),
$option_slug
)
);
return '';
}
return maybe_unserialize( $decrypted );
}
/**
* Triggers an error, if WP_DEBUG is enabled.
*
* @since 0.1.0
*
* @param string $function_name The name of the function that triggered the error.
* @param string $message The message explaining the error.
* @param int $error_level Optional. The designated error type for this error.
* Only works with E_USER family of constants. Default E_USER_NOTICE.
*/
private function trigger_error( string $function_name, string $message, int $error_level = E_USER_NOTICE ): void {
// The wp_trigger_error() function was only added in WordPress 6.4, so this is a minimal shim.
if ( ! function_exists( 'wp_trigger_error' ) ) {
if ( ! WP_DEBUG ) {
return;
}
if ( ! empty( $function_name ) ) {
$message = sprintf( '%s(): %s', $function_name, $message );
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error
trigger_error( esc_html( $message ), $error_level );
return;
}
// @phpstan-ignore-next-line
wp_trigger_error( $function_name, $message, $error_level );
}
}

View File

@@ -0,0 +1,286 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Service_Registration
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
use ATFPP\AI_Translate\Services\API\Enums\Service_Type;
use ATFPP\AI_Translate\Services\API\Types\Service_Metadata;
use ATFPP\AI_Translate\Services\Authentication\API_Key_Authentication;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Service;
use ATFPP\AI_Translate\Services\Decorators\AI_Service_Decorator;
use ATFPP\AI_Translate\Services\HTTP\HTTP_With_Streams;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Key_Value_Repository;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request_Handler;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Repository;
use InvalidArgumentException;
use RuntimeException;
/**
* Class representing a service registration.
*
* This is an internal class and NOT the actual service.
*
* @since 0.1.0
*/
final class Service_Registration {
/**
* The service metadata.
*
* @since 0.7.0
* @var Service_Metadata
*/
private $metadata;
/**
* Whether the service can be overridden through another registration with the same slug.
*
* @since 0.7.0
* @var bool
*/
private $allow_override;
/**
* The service creator.
*
* @since 0.1.0
* @var callable
*/
private $creator;
/**
* The service instance arguments.
*
* @since 0.1.0
* @var array<string, mixed>
*/
private $instance_args;
/**
* The authentication option slugs.
*
* @since 0.1.0
* @var string[]
*/
private $authentication_option_slugs;
/**
* Constructor.
*
* @since 0.1.0
* @since 0.6.0 The service argument keys were updated.
*
* @param string $slug The service slug. Must only contain lowercase letters, numbers, hyphens.
* @param callable $creator The service creator. Receives the Service_Registration_Context as sole
* parameter and must return a Generative_AI_Service instance. The parameter
* provides access to the service metadata and other relevant dependencies.
* @param array<string, mixed> $args {
* Optional. The service arguments. Default empty array.
*
* @type string $name The service name. Default is the slug with spaces and uppercase
* first letters.
* @type string $credentials_url The URL to manage credentials for the service. Default empty
* string.
* @type string $type The service type. Default is Service_Type::CLOUD.
* @type string[] $capabilities The list of AI capabilities supported by the service and its
* models. Default empty array.
* @type bool $allow_override Whether the service can be overridden by another service with
* the same slug. Default true.
* @type Request_Handler $request_handler The request handler instance. Default is a new HTTP_With_Streams
* instance.
* @type Container $container The container instance with data for the API key options.
* Default is a new Option_Container instance.
* @type Key_Value_Repository $repository The repository instance to read API keys Default is a new
* Option_Repository instance.
* }
*/
public function __construct( string $slug, callable $creator, array $args = array() ) {
$this->metadata = Service_Metadata::from_array( array_merge( array( 'slug' => $slug ), $args ) );
$this->creator = $creator;
$this->allow_override = isset( $args['allow_override'] ) ? (bool) $args['allow_override'] : true;
$this->instance_args = $this->parse_instance_args( $args );
$option_definitions = array();
if ( $this->metadata->get_type() === Service_Type::CLOUD ) {
$option_definitions = API_Key_Authentication::get_option_definitions( $slug );
}
$this->authentication_option_slugs = array();
foreach ( $option_definitions as $option_slug => $option_args ) {
$this->authentication_option_slugs[] = $option_slug;
$this->instance_args['container'][ $option_slug ] = function () use ( $option_slug, $option_args ) {
return new Option(
$this->instance_args['repository'],
$option_slug,
$option_args
);
};
}
}
/**
* Gets the service metadata.
*
* @since 0.7.0
*
* @return Service_Metadata The service metadata.
*/
public function get_metadata(): Service_Metadata {
return $this->metadata;
}
/**
* Gets the authentication option instances.
*
* @since 0.1.0
*
* @return Option[] The authentication option instances.
*/
public function get_authentication_options(): array {
return array_map(
function ( string $option_slug ) {
return $this->instance_args['container'][ $option_slug ];
},
$this->authentication_option_slugs
);
}
/**
* Gets the authentication option slugs.
*
* @since 0.1.0
*
* @return string[] The authentication option slugs.
*/
public function get_authentication_option_slugs(): array {
return $this->authentication_option_slugs;
}
/**
* Creates a new instance of the service.
*
* @since 0.1.0
*
* @return Generative_AI_Service The service instance.
*
* @throws RuntimeException Thrown if no API key is set for the service or if the service creator's return value is
* not a valid Generative_AI_Service instance.
*/
public function create_instance(): Generative_AI_Service {
$authentication_options = $this->get_authentication_options();
$slug = $this->metadata->get_slug();
$authentication = null;
if ( count( $authentication_options ) > 0 ) {
// For now an API key is the only authentication method supported.
$api_key = $authentication_options[0]->get_value();
if ( ! $api_key ) {
throw new RuntimeException(
sprintf(
'Cannot instantiate service %s without an API key.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$authentication = new API_Key_Authentication( $api_key );
}
$context = new Service_Registration_Context(
$slug,
$this->metadata,
$this->instance_args['request_handler'],
$authentication
);
$instance = ( $this->creator )( $context );
if ( ! $instance instanceof Generative_AI_Service ) {
throw new RuntimeException(
sprintf(
'The service creator for %s must return an instance of Generative_AI_Service.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
if ( $instance->get_service_slug() !== $slug ) {
throw new RuntimeException(
sprintf(
'The service creator for %1$s must return an instance of Generative_AI_Service with the same slug, but instead it returned another slug %2$s.',
htmlspecialchars( $slug ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
htmlspecialchars( $instance->get_service_slug() ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
if ( $instance->get_service_metadata() !== $this->metadata ) {
throw new RuntimeException(
sprintf(
'The service creator for %s must return an instance of Generative_AI_Service with the same metadata, but instead it returned different metadata.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
// Wrap the instance in a decorator for centralized functionality.
return new AI_Service_Decorator( $instance );
}
/**
* Checks whether the service can be overridden.
*
* @since 0.1.0
*
* @return bool True if the service can be overridden, false otherwise.
*/
public function allows_override(): bool {
return $this->allow_override;
}
/**
* Parses the service registration instance arguments.
*
* @since 0.1.0
*
* @param array<string, mixed> $args The service registration instance arguments.
* @return array<string, mixed> The parsed service registration instance arguments.
*
* @throws InvalidArgumentException Thrown if an invalid instance argument is provided.
*/
private function parse_instance_args( array $args ): array {
$requirements_map = array(
'request_handler' => array( Request_Handler::class, HTTP_With_Streams::class ),
'container' => array( Container::class, Option_Container::class ),
'repository' => array( Key_Value_Repository::class, Option_Repository::class ),
);
$instance_args = array();
foreach ( $requirements_map as $key => $requirements ) {
list( $interface_name, $class_name ) = $requirements;
if ( isset( $args[ $key ] ) ) {
if ( ! $args[ $key ] instanceof $interface_name ) {
throw new InvalidArgumentException(
sprintf(
'The %1$s argument must be an instance of %2$s.',
htmlspecialchars( $key ), // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
htmlspecialchars( $interface_name ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$instance_args[ $key ] = $args[ $key ];
} else {
$instance_args[ $key ] = new $class_name();
}
}
return $instance_args;
}
}

View File

@@ -0,0 +1,119 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Service_Registration_Context
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
use ATFPP\AI_Translate\Services\API\Types\Service_Metadata;
use ATFPP\AI_Translate\Services\Contracts\Authentication;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request_Handler;
/**
* Value class with service context dependencies and data that can be used to create a service instance.
*
* @since 0.7.0
*/
final class Service_Registration_Context {
/**
* The service slug.
*
* @since 0.7.0
* @var string
*/
private $slug;
/**
* The service metadata.
*
* @since 0.7.0
* @var Service_Metadata
*/
private $metadata;
/**
* The service request handler instance.
*
* @since 0.7.0
* @var Request_Handler
*/
private $request_handler;
/**
* The service authentication instance, if any.
*
* @since 0.7.0
* @var Authentication|null
*/
private $authentication;
/**
* Constructor.
*
* @since 0.7.0
*
* @param string $slug The service slug.
* @param Service_Metadata $metadata The service metadata.
* @param Request_Handler $request_handler The service request handler instance.
* @param Authentication|null $authentication Optional. The service authentication instance, if any. Default null.
*/
public function __construct(
string $slug,
Service_Metadata $metadata,
Request_Handler $request_handler,
?Authentication $authentication = null
) {
$this->slug = $slug;
$this->metadata = $metadata;
$this->request_handler = $request_handler;
$this->authentication = $authentication;
}
/**
* Gets the service slug.
*
* @since 0.7.0
*
* @return string The service slug.
*/
public function get_slug(): string {
return $this->slug;
}
/**
* Gets the service metadata.
*
* @since 0.7.0
*
* @return Service_Metadata The service metadata.
*/
public function get_metadata(): Service_Metadata {
return $this->metadata;
}
/**
* Gets the service request handler instance.
*
* @since 0.7.0
*
* @return Request_Handler The service request handler instance.
*/
public function get_request_handler(): Request_Handler {
return $this->request_handler;
}
/**
* Gets the service authentication instance, if any.
*
* @since 0.7.0
*
* @return Authentication|null The service authentication instance, if any.
*/
public function get_authentication(): ?Authentication {
return $this->authentication;
}
}

View File

@@ -0,0 +1,391 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Services_API
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
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_Service;
use ATFPP\AI_Translate\Services\Options\Option_Encrypter;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\Key_Value_Repository;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Current_User;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Contracts\Request_Handler;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Repository;
use InvalidArgumentException;
/**
* Main API class providing the entry point to the generative AI services.
*
* @since 0.1.0
*/
final class Services_API {
/**
* The service registration definitions, keyed by service slug.
*
* @since 0.1.0
* @var array<string, Service_Registration>
*/
private $service_registrations = array();
/**
* The service instances, keyed by service slug.
*
* @since 0.1.0
* @var array<string, Generative_AI_Service>
*/
private $service_instances = array();
/**
* The current user instance.
*
* @since 0.1.0
* @var Current_User
*/
private $current_user;
/**
* The request handler instance.
*
* @since 0.1.0
* @var Request_Handler
*/
private $request_handler;
/**
* The container instance with data for the API key options.
*
* @since 0.1.0
* @var Container
*/
private $container;
/**
* The repository instance to read API keys.
*
* @since 0.1.0
* @var Key_Value_Repository
*/
private $repository;
/**
* The option encrypter instance.
*
* @since 0.1.0
* @var Option_Encrypter
*/
private $option_encrypter;
/**
* Constructor.
*
* @since 0.1.0
* @since 0.6.0 The constructor parameters were updated.
*
* @param Current_User $current_user The current user instance.
* @param Request_Handler $request_handler The request handler instance.
* @param Container $container The container instance with data for the API key options.
* @param Key_Value_Repository $repository The repository instance to read API keys.
* @param Option_Encrypter|null $option_encrypter Optional. The option encrypter instance. If not provided, the
* API key options are assumed to not be encrypted. Default null.
*/
public function __construct(
Current_User $current_user,
Request_Handler $request_handler,
Container $container,
Key_Value_Repository $repository,
?Option_Encrypter $option_encrypter = null
) {
$this->current_user = $current_user;
$this->request_handler = $request_handler;
$this->container = $container;
$this->repository = $repository;
$this->option_encrypter = $option_encrypter;
}
/**
* Registers a generative AI service.
*
* An AI service consists at least of a service class that implements the Generative_AI_Service interface and a
* model class that implements the Generative_AI_Model interface.
*
* Consumers of the service will access the service class through a proxy wrapper class which automatically handles
* caching and other infrastructure concerns. It is therefore advised to not implement any caching concerns in the
* service class itself as well as to not implement any public methods other than those required by the relevant
* interfaces.
*
* The $creator parameter of this method needs to return the instance of the service class.
*
* @since 0.1.0
*
* @see Generative_AI_Service
*
* @param string $slug The service slug. Must only contain lowercase letters, numbers, hyphens. It
* must be unique and must match the service slug returned by the service
* class.
* @param callable $creator The service creator. Receives the Service_Registration_Context as sole
* parameter and must return a Generative_AI_Service instance. The parameter
* provides access to the service metadata and other relevant dependencies.
* @param array<string, mixed> $args {
* Optional. The service arguments. Default empty array.
*
* @type string $name The user-facing service name. Default is the slug with spaces and uppercase
* first letters.
* @type string $credentials_url The URL to manage credentials for the service. Default empty string.
* @type bool $allow_override Whether the service can be overridden by another service with the same slug.
* Default true.
* }
*
* @throws InvalidArgumentException Thrown if an already registered slug or invalid arguments are provided.
*/
public function register_service( string $slug, callable $creator, array $args = array() ): void {
if ( 'browser' === $slug ) {
throw new InvalidArgumentException(
sprintf(
'Service %s is reserved for in-browser AI and cannot be registered.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
if ( isset( $this->service_registrations[ $slug ] ) && ! $this->service_registrations[ $slug ]->allows_override() ) {
throw new InvalidArgumentException(
sprintf(
'Service %s is already registered and cannot be overridden.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$args['request_handler'] = $this->request_handler;
$args['container'] = $this->container;
$args['repository'] = $this->repository;
$this->service_registrations[ $slug ] = new Service_Registration( $slug, $creator, $args );
$option_slugs = $this->service_registrations[ $slug ]->get_authentication_option_slugs();
foreach ( $option_slugs as $option_slug ) {
// Ensure the authentication options are encrypted.
if ( null !== $this->option_encrypter && ! $this->option_encrypter->has_encryption( $option_slug ) ) {
$this->option_encrypter->add_encryption_hooks( $option_slug );
}
/*
* If the repository uses WordPress options, ensure the authentication options are invalidated when the
* credentials change.
*/
if ( $this->repository instanceof Option_Repository ) {
$invalid_service_caches = static function () use ( $slug ) {
Service_Request_Cache::invalidate_caches( $slug );
};
add_action( "add_option_{$option_slug}", $invalid_service_caches );
add_action( "update_option_{$option_slug}", $invalid_service_caches );
add_action( "delete_option_{$option_slug}", $invalid_service_caches );
}
}
}
/**
* Checks whether a service is registered.
*
* @since 0.1.0
*
* @param string $slug The service slug.
* @return bool True if the service is registered, false otherwise.
*/
public function is_service_registered( string $slug ): bool {
return isset( $this->service_registrations[ $slug ] );
}
/**
* Checks whether a service is available.
*
* For a service to be considered available, all of the following conditions must be met:
* - The service is registered.
* - The service has an API key set.
* - The API key is valid.
* - The current user has the necessary capabilities.
*
* @since 0.1.0
*
* @param string $slug The service slug.
* @return bool True if the service is available, false otherwise.
*/
public function is_service_available( string $slug ): bool {
/*
* If the service was already instantiated in the class, it is available.
* In that case, the only thing left to check is whether the current user has the necessary capabilities.
*/
if ( isset( $this->service_instances[ $slug ] ) ) {
if ( ! $this->current_user->has_cap( 'ais_access_service', $slug ) ) {
return false;
}
return true;
}
// If the service is not registered, it is not available.
if ( ! isset( $this->service_registrations[ $slug ] ) ) {
return false;
}
// If any authentication credentials are missing for the service, it is not available.
$authentication_options = $this->service_registrations[ $slug ]->get_authentication_options();
foreach ( $authentication_options as $option ) {
if ( ! $option->get_value() ) {
return false;
}
}
// Test whether the API key is valid by listing the models.
$instance = $this->service_registrations[ $slug ]->create_instance();
if ( ! $instance->is_connected() ) {
return false;
}
// If so, the service is available so we can store the instance.
$this->service_instances[ $slug ] = $instance;
// Finally, check whether the current user has the necessary capabilities.
return $this->current_user->has_cap( 'ais_access_service', $slug );
}
/**
* Checks whether any services are available.
*
* For some use-cases it may be acceptable to use any AI service. In those cases, this method can be used to check
* whether any services are available. If so, an arbitrary available service can be retrieved using the
* {@see Services_API::get_available_service()} method.
*
* @since 0.1.0
*
* @param array<string, mixed> $args {
* Optional. Arguments to filter the services to consider. By default, any available service is considered.
*
* @type string[] $slugs List of service slugs, to only consider any of these services.
* @type string[] $capabilities List of AI capabilities, to only consider services that support all of these
* capabilities.
* }
* @return bool True if any of the services are available, false otherwise.
*/
public function has_available_services( array $args = array() ): bool {
$slug = $this->get_available_service_slug( $args );
return '' !== $slug;
}
/**
* Gets a generative AI service instance that is available for use.
*
* If you intend to call this method with a specific service slug, you should first check whether the service is
* available using {@see Services_API::is_service_available()}.
*
* If you intend to call this method to get any service (optionally with additional criteria to satisfy), you
* should first check if any of the services are available using {@see Services_API::has_available_services()}.
*
* @since 0.1.0
*
* @param string|array<string, mixed> $args Optional. Either a single service slug to get that service, or
* arguments to get any service that satisfies the criteria from these
* arguments. See {@see Services_API::has_available_services()} for the
* possible arguments. Default is an empty array so that any available
* service is considered.
* @return Generative_AI_Service The available service instance.
*
* @throws InvalidArgumentException Thrown if no service corresponding to the given arguments is available.
*/
public function get_available_service( $args = array() ): Generative_AI_Service {
if ( is_string( $args ) ) {
$slug = $args;
if ( ! $this->is_service_available( $slug ) ) {
throw new InvalidArgumentException(
sprintf(
'Service %s is either not registered or not available.',
htmlspecialchars( $slug ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
return $this->service_instances[ $slug ];
}
$slug = $this->get_available_service_slug( $args );
if ( '' === $slug ) {
if ( count( $args ) > 0 ) {
$message = 'No service satisfying the given arguments is registered and available.';
} else {
$message = 'No service is registered and available.';
}
throw new InvalidArgumentException( $message ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
return $this->service_instances[ $slug ];
}
/**
* Gets the service metadata for a given service slug.
*
* @since 0.7.0
*
* @param string $slug The service slug.
* @return Service_Metadata|null The service metadata, or null if the service is not registered.
*/
public function get_service_metadata( string $slug ): ?Service_Metadata {
if ( ! isset( $this->service_registrations[ $slug ] ) ) {
return null;
}
return $this->service_registrations[ $slug ]->get_metadata();
}
/**
* Gets the list of all registered service slugs.
*
* @since 0.1.0
*
* @return string[] The list of registered service slugs.
*/
public function get_registered_service_slugs(): array {
return array_keys( $this->service_registrations );
}
/**
* Gets the first available service slug, optionally satisfying the given criteria.
*
* @since 0.1.0
*
* @param array<string, mixed> $args Optional. Arguments to filter the services to consider. See
* {@see Services_API::has_available_services()} for the possible arguments.
* By default, any available service is considered.
* @return string The first available service slug, or empty string if no service is available.
*/
private function get_available_service_slug( array $args = array() ): string {
$slugs = $args['slugs'] ?? $this->get_registered_service_slugs();
foreach ( $slugs as $slug ) {
if ( ! $this->is_service_available( $slug ) ) {
continue;
}
if ( isset( $args['capabilities'] ) ) {
$metadata = $this->get_service_metadata( $slug );
if ( ! $metadata ) {
continue;
}
$missing_capabilities = array_diff( $args['capabilities'], $metadata->get_capabilities() );
if ( count( $missing_capabilities ) > 0 ) {
continue;
}
}
return $slug;
}
return '';
}
}

View File

@@ -0,0 +1,65 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Services_API_Instance
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
use RuntimeException;
/**
* Class to provide singleton-like access to the canonical Services_API instance.
*
* @since 0.1.0
*/
final class Services_API_Instance {
/**
* Retrieve the canonical Services_API instance.
*
* @since 0.1.0
* @var Services_API|null The canonical Services_API instance.
*/
private static $instance;
/**
* Retrieves the canonical Services_API instance.
*
* @since 0.1.0
*
* @return Services_API The canonical Services_API instance.
*
* @throws RuntimeException Thrown if the method is called too early when no instance has been set before.
*/
public static function get(): Services_API {
if ( ! isset( self::$instance ) ) {
throw new RuntimeException(
'Cannot get Services_API instance before it was set.'
);
}
return self::$instance;
}
/**
* Sets the canonical Services_API instance.
*
* @since 0.1.0
*
* @param Services_API $instance The canonical Services_API instance.
*
* @throws RuntimeException Thrown if the method is called after the instance has already been set.
*/
public static function set( Services_API $instance ): void {
if ( isset( self::$instance ) ) {
throw new RuntimeException(
'Cannot set Services_API instance after it has already been set.'
);
}
self::$instance = $instance;
}
}

View File

@@ -0,0 +1,206 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Services_Loader
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Capability_Controller;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Contracts\With_Hooks;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Service_Container;
/**
* Loader class responsible for initializing the AI services functionality, including its public API.
*
* @since 0.1.0
*/
final class Services_Loader implements With_Hooks {
/**
* Service container for the class's dependencies.
*
* @since 0.1.0
* @var Service_Container
*/
private $container;
/**
* Constructor.
*
* @since 0.1.0
*
* @param string $main_file Absolute path to the plugin main file.
*/
public function __construct( string $main_file ) {
$this->container = $this->set_up_container( $main_file );
Services_API_Instance::set( $this->container['api'] );
}
/**
* Adds relevant WordPress hooks.
*
* @since 0.1.0
*/
public function add_hooks(): void {
$this->add_cleanup_hooks();
$this->load_capabilities();
$this->load_options();
}
/**
* Adds cleanup hooks related to plugin deactivation.
*
* @since 0.4.0
*/
private function add_cleanup_hooks(): void {
// This function is only available in WordPress 6.4+.
if ( ! function_exists( 'wp_set_options_autoload' ) ) {
return;
}
// Disable autoloading of plugin options on deactivation.
register_deactivation_hook(
$this->container['plugin_env']->main_file(),
function ( $network_wide ) {
// For network-wide deactivation, this cleanup cannot be reliably implemented.
if ( $network_wide ) {
return;
}
$autoloaded_options = $this->get_autoloaded_options();
if ( ! $autoloaded_options ) {
return;
}
wp_set_options_autoload(
$autoloaded_options,
false
);
}
);
// Reinstate original autoload settings on (re-)activation.
register_activation_hook(
$this->container['plugin_env']->main_file(),
function ( $network_wide ) {
// See deactivation hook for network-wide cleanup limitations.
if ( $network_wide ) {
return;
}
$autoloaded_options = $this->get_autoloaded_options();
if ( ! $autoloaded_options ) {
return;
}
wp_set_options_autoload(
$autoloaded_options,
true
);
}
);
}
/**
* Loads the services capabilities and sets up the relevant filters.
*
* @since 0.1.0
*/
private function load_capabilities(): void {
add_action(
'plugins_loaded',
function () {
$controller = $this->container['capability_controller'];
/**
* Fires when the services capabilities are loaded.
*
* This hook allows you to modify the rules for how these capabilities are granted. The capabilities
* available in the controller are:
*
* - 'ais_manage_services' (base capability)
* - 'ais_access_services' (base capability)
* - 'ais_access_service' (meta capability, called with the specific service slug as parameter)
* - 'ais_use_playground' (meta capability)
*
* @since 0.1.0
*
* @param Capability_Controller $controller The capability controller, which can be used to modify the
* rules for how capabilities are granted.
*/
do_action( 'ais_load_services_capabilities', $controller );
$this->container['capability_filters']->add_hooks();
},
0
);
}
/**
* Loads the services options.
*
* The option container is populated with options dynamically based on registered AI services. Each of the relevant
* options will be registered here.
*
* @since 0.1.0
*/
private function load_options(): void {
add_action(
'init',
function () {
$registry = $this->container['option_registry'];
foreach ( $this->container['option_container']->get_keys() as $key ) {
$option = $this->container['option_container']->get( $key );
$registry->register(
$option->get_key(),
$option->get_registration_args()
);
}
},
0
);
}
/**
* Gets the service option names that are autoloaded.
*
* @since 0.4.0
*
* @return string[] List of autoloaded service options.
*/
private function get_autoloaded_options(): array {
$autoloaded_options = array();
foreach ( $this->container['option_container']->get_keys() as $key ) {
// Trigger option instantiation so that the autoload config is populated.
$this->container['option_container']->get( $key );
$autoload = $this->container['option_repository']->get_autoload_config( $key );
if ( true === $autoload ) {
$autoloaded_options[] = $key;
}
}
return $autoloaded_options;
}
/**
* Sets up the services service container.
*
* @since 0.1.0
*
* @param string $main_file Absolute path to the plugin main file.
* @return Service_Container The services service container.
*/
private function set_up_container( string $main_file ): Service_Container {
$builder = new Services_Service_Container_Builder();
return $builder->build_env( $main_file )
->build_services()
->get();
}
}

View File

@@ -0,0 +1,227 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Services_Service_Container_Builder
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services;
use ATFPP\AI_Translate\Services\HTTP\HTTP_With_Streams;
use ATFPP\AI_Translate\Services\Options\Option_Encrypter;
use ATFPP\AI_Translate\Services\Util\Data_Encryption;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Admin_Pages\Admin_Menu;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Base_Capability;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Capability_Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Capability_Controller;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Capability_Filters;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Capabilities\Meta_Capability;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Current_User;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Plugin_Env;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Service_Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\General\Site_Env;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Meta\Meta_Repository;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Container;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Registry;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\Options\Option_Repository;
/**
* Service container builder for the services loader.
*
* @since 0.1.0
*/
final class Services_Service_Container_Builder {
/**
* Service container.
*
* @since 0.1.0
* @var Service_Container
*/
private $container;
/**
* Constructor.
*
* @since 0.1.0
*/
public function __construct() {
$this->container = new Service_Container();
}
/**
* Gets the service container.
*
* @since 0.1.0
*
* @return Service_Container Service container for the plugin.
*/
public function get(): Service_Container {
return $this->container;
}
/**
* Builds the plugin environment service for the service container.
*
* @since 0.1.0
*
* @param string $main_file Absolute path to the plugin main file.
* @return self The builder instance, for chaining.
*/
public function build_env( string $main_file ): self {
$this->container['plugin_env'] = function () use ( $main_file ) {
return new Plugin_Env( $main_file, ATFPP_V );
};
return $this;
}
/**
* Builds the services for the service container.
*
* @since 0.1.0
*
* @return self The builder instance, for chaining.
*/
public function build_services(): self {
$this->build_general_services();
$this->build_capability_services();
$this->build_http_services();
$this->build_option_services();
$this->build_entity_services();
$this->build_admin_services();
$this->container['api'] = static function ( $cont ) {
return new Services_API(
$cont['current_user'],
$cont['http'],
$cont['option_container'],
$cont['option_repository'],
$cont['option_encrypter']
);
};
return $this;
}
/**
* Builds the general services for the service container.
*
* @since 0.1.0
*/
private function build_general_services(): void {
$this->container['current_user'] = static function () {
return new Current_User();
};
$this->container['site_env'] = static function () {
return new Site_Env();
};
}
/**
* Builds the capability services for the service container.
*
* @since 0.1.0
*/
private function build_capability_services(): void {
$this->container['capability_container'] = static function () {
$capabilities = new Capability_Container();
$capabilities['ais_manage_services'] = static function () {
return new Base_Capability(
'ais_manage_services',
array( 'manage_options' )
);
};
$capabilities['ais_access_services'] = static function () {
return new Base_Capability(
'ais_access_services',
array( 'edit_posts' )
);
};
$capabilities['ais_access_service'] = static function () {
return new Meta_Capability(
'ais_access_service',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
static function ( int $user_id, string $service_slug ) {
return array( 'ais_access_services' );
}
);
};
$capabilities['ais_use_playground'] = static function () {
return new Meta_Capability(
'ais_use_playground',
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter
static function ( int $user_id ) {
return array( 'ais_access_services' );
}
);
};
return $capabilities;
};
$this->container['capability_controller'] = static function ( $cont ) {
return new Capability_Controller( $cont['capability_container'] );
};
$this->container['capability_filters'] = static function ( $cont ) {
return new Capability_Filters( $cont['capability_container'] );
};
}
/**
* Builds the HTTP services for the service container.
*
* @since 0.1.0
*/
private function build_http_services(): void {
$this->container['http'] = static function () {
// Custom implementation with additional support for streaming responses.
return new HTTP_With_Streams();
};
}
/**
* Builds the option services for the service container.
*
* @since 0.1.0
*/
private function build_option_services(): void {
$this->container['option_repository'] = static function () {
return new Option_Repository();
};
$this->container['option_container'] = static function () {
return new Option_Container();
};
$this->container['option_registry'] = static function () {
return new Option_Registry( 'ais_services' );
};
$this->container['option_encrypter'] = static function () {
return new Option_Encrypter( new Data_Encryption() );
};
}
/**
* Builds the entity services for the service container.
*
* @since 0.5.0
*/
private function build_entity_services(): void {
$this->container['user_meta_repository'] = static function () {
return new Meta_Repository( 'user' );
};
}
/**
* Builds the admin services for the service container.
*
* @since 0.1.0
*/
private function build_admin_services(): void {
$this->container['admin_settings_menu'] = static function () {
return new Admin_Menu( 'options-general.php' );
};
$this->container['admin_tools_menu'] = static function () {
return new Admin_Menu( 'tools.php' );
};
}
}

View File

@@ -0,0 +1,296 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\Generative_AI_API_Client_Trait
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use ATFPP\AI_Translate\Services\HTTP\Contracts\Stream_Request_Handler;
use ATFPP\AI_Translate\Services\HTTP\Contracts\With_Stream;
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\Contracts\Response;
use Felix_Arntz\ATFPP\WP_OOP_Plugin_Lib\HTTP\Exception\Request_Exception;
use Generator;
use InvalidArgumentException;
/**
* Trait for an API client class which implements the Generative_AI_API_Client interface.
*
* @since 0.1.0
*/
trait Generative_AI_API_Client_Trait {
/**
* Sends the given request to the API and returns the response data.
*
* @since 0.1.0
*
* @param Request $request The request instance.
* @return Response The response instance.
*
* @throws Generative_AI_Exception If an error occurs while making the request.
*/
final public function make_request( Request $request ): Response {
$request_handler = $this->get_request_handler();
$options = $request->get_options();
if ( isset( $options['stream'] ) && $options['stream'] ) {
if ( ! $request_handler instanceof Stream_Request_Handler ) {
throw new Generative_AI_Exception(
'Streaming requests are not supported by this API client.'
);
}
try {
$response = $request_handler->request_stream( $request );
} catch ( Request_Exception $e ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw $this->create_request_exception( $e->getMessage() );
}
} else {
try {
$response = $request_handler->request( $request );
} catch ( Request_Exception $e ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw $this->create_request_exception( $e->getMessage() );
}
}
if ( $response->get_status() < 200 || $response->get_status() >= 300 ) {
$data = $response->get_data();
if ( $data && isset( $data['error']['message'] ) && is_string( $data['error']['message'] ) ) {
$error_message = $data['error']['message'];
} elseif ( $data && isset( $data['error'] ) && is_string( $data['error'] ) ) {
$error_message = $data['error'];
} elseif ( $data && isset( $data['message'] ) && is_string( $data['message'] ) ) {
$error_message = $data['message'];
} else {
$error_message = sprintf(
'Bad status code: %d',
$response->get_status()
);
}
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
throw $this->create_request_exception( $error_message );
}
return $response;
}
/**
* Processes the response data from the API.
*
* @since 0.3.0
*
* @param Response $response The response instance. Must not be a stream response, i.e. not implement the
* With_Stream interface.
* @param callable $process_callback The callback to process the response data. Receives the JSON-decoded response
* data as associative array and should return the processed data in the desired
* format.
* @return mixed The processed response data.
*
* @throws Generative_AI_Exception If an error occurs while processing the response data.
*/
final public function process_response_data( Response $response, $process_callback ) {
if ( $response instanceof With_Stream ) {
throw new Generative_AI_Exception(
sprintf(
'Response must not implement %s.',
With_Stream::class
)
);
}
$data = $response->get_data();
if ( ! $data ) {
throw new Generative_AI_Exception(
'No data received in response.'
);
}
$processed_data = call_user_func( $process_callback, $data );
if ( ! $processed_data ) {
throw new Generative_AI_Exception(
'No data returned by process callback.'
);
}
return $processed_data;
}
/**
* Processes the response body from the API.
*
* @since 0.7.0
*
* @param Response $response The response instance. Must not be a stream response, i.e. not implement the
* With_Stream interface.
* @param callable $process_callback The callback to process the response body. Receives the response body as
* string and should return the processed data in the desired format.
* @return mixed The processed response data.
*
* @throws Generative_AI_Exception If an error occurs while processing the response body.
*/
final public function process_response_body( Response $response, $process_callback ) {
if ( $response instanceof With_Stream ) {
throw new Generative_AI_Exception(
sprintf(
'Response must not implement %s.',
With_Stream::class
)
);
}
$body = $response->get_body();
if ( ! $body ) {
throw new Generative_AI_Exception(
'No body received in response.'
);
}
$processed_data = call_user_func( $process_callback, $body );
if ( ! $processed_data ) {
throw new Generative_AI_Exception(
'No data returned by process callback.'
);
}
return $processed_data;
}
/**
* Processes the response data stream from the API.
*
* @since 0.3.0
*
* @param Response $response The response instance. Must implement With_Stream. The response data will
* be processed in chunks, with each chunk of data being passed to the process
* callback.
* @param callable $process_callback The callback to process the response data. Receives the JSON-decoded response
* data (associative array) as first parameter, and the previous processed data
* as second parameter (or null in case this is the first chunk). It should
* return the processed data for the chunk in the desired format.
* @return Generator Generator that yields the individual processed response data chunks.
*
* @throws Generative_AI_Exception If an error occurs while processing the response data.
*/
final public function process_response_stream( Response $response, $process_callback ): Generator {
if ( ! $response instanceof With_Stream ) {
throw new Generative_AI_Exception(
sprintf(
'Response does not implement %s.',
With_Stream::class
)
);
}
$stream_generator = $response->read_stream();
$previous_processed_data = null;
foreach ( $stream_generator as $data ) {
$processed_data = call_user_func( $process_callback, $data, $previous_processed_data );
if ( ! $processed_data ) {
continue;
}
$previous_processed_data = $processed_data;
yield $processed_data;
}
}
/**
* Creates a new exception for a bad request, i.e. invalid or unsupported request data.
*
* @since 0.7.0
*
* @param string $message The error message to include in the exception.
* @return InvalidArgumentException The exception instance.
*/
final public function create_bad_request_exception( string $message ): InvalidArgumentException {
return new InvalidArgumentException(
sprintf(
'Invalid request data for the %1$s API: %2$s',
$this->get_api_name(),
$message
)
);
}
/**
* Creates a new exception for an AI API request error.
*
* @since 0.1.0
* @since 0.3.0 Method made public.
*
* @param string $message The error message to include in the exception.
* @return Generative_AI_Exception The exception instance.
*/
final public function create_request_exception( string $message ): Generative_AI_Exception {
return new Generative_AI_Exception(
sprintf(
'Error while making request to the %1$s API: %2$s ',
$this->get_api_name(),
$message
)
);
}
/**
* Creates a new exception for an AI API response error.
*
* @since 0.3.0
*
* @param string $message The error message to include in the exception.
* @return Generative_AI_Exception The exception instance.
*/
final public function create_response_exception( string $message ): Generative_AI_Exception {
return new Generative_AI_Exception(
sprintf(
'Error in the response from the %1$s API: %2$s ',
$this->get_api_name(),
$message
)
);
}
/**
* Creates a new exception for an AI API response error for a missing key.
*
* @since 0.3.0
*
* @param string $key The missing key in the response data.
* @return Generative_AI_Exception The exception instance.
*/
final public function create_missing_response_key_exception( string $key ): Generative_AI_Exception {
return $this->create_response_exception(
sprintf(
'The response is missing the "%s" key.',
$key
)
);
}
/**
* Returns the request handler instance to use for requests.
*
* @since 0.1.0
* @since 0.6.0 Renamed from `get_http()`.
*
* @return Request_Handler The request handler instance.
*/
abstract protected function get_request_handler(): Request_Handler;
/**
* Returns the human readable API name (without the "API" suffix).
*
* @since 0.1.0
*
* @return string The API name.
*/
abstract protected function get_api_name(): string;
}

View File

@@ -0,0 +1,79 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\Model_Param_System_Instruction_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\Util\Formatter;
use InvalidArgumentException;
/**
* Trait for a model that uses a system instruction.
*
* @since 0.7.0
*/
trait Model_Param_System_Instruction_Trait {
/**
* The system instruction.
*
* @since 0.7.0
* @var Content|null
*/
private $system_instruction;
/**
* Gets the system instruction.
*
* @since 0.7.0
*
* @return Content|null The system instruction, or null if not set.
*/
final protected function get_system_instruction(): ?Content {
return $this->system_instruction;
}
/**
* Sets the system instruction.
*
* @since 0.7.0
*
* @param Content $system_instruction The system instruction.
*/
final protected function set_system_instruction( Content $system_instruction ): void {
$this->system_instruction = $system_instruction;
}
/**
* Sets the system instruction if provided in the `systemInstruction` model parameter.
*
* @since 0.7.0
*
* @param array<string, mixed> $model_params The model parameters.
*
* @throws InvalidArgumentException Thrown if the `systemInstruction` model parameter is invalid.
*/
protected function set_system_instruction_from_model_params( array $model_params ): void {
if ( ! isset( $model_params['systemInstruction'] ) ) {
return;
}
try {
$model_params['systemInstruction'] = Formatter::format_system_instruction( $model_params['systemInstruction'] );
} catch ( InvalidArgumentException $e ) {
throw new InvalidArgumentException(
sprintf(
'Invalid systemInstruction model parameter: %s',
htmlspecialchars( $e->getMessage() ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
$this->set_system_instruction( $model_params['systemInstruction'] );
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\Model_Param_Text_Generation_Config_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Text_Generation_Config;
use InvalidArgumentException;
/**
* Trait for a model that uses `Text_Generation_Config`.
*
* @since 0.7.0
*/
trait Model_Param_Text_Generation_Config_Trait {
/**
* The text generation configuration.
*
* @since 0.7.0
* @var Text_Generation_Config|null
*/
private $text_generation_config;
/**
* Gets the text generation configuration.
*
* @since 0.7.0
*
* @return Text_Generation_Config|null The text generation configuration, or null if not set.
*/
final protected function get_text_generation_config(): ?Text_Generation_Config {
return $this->text_generation_config;
}
/**
* Sets the text generation configuration.
*
* @since 0.7.0
*
* @param Text_Generation_Config $text_generation_config The text generation configuration.
*/
final protected function set_text_generation_config( Text_Generation_Config $text_generation_config ): void {
$this->text_generation_config = $text_generation_config;
}
/**
* Sets the text generation configuration if provided in the `generationConfig` model parameter.
*
* @since 0.7.0
*
* @param array<string, mixed> $model_params The model parameters.
*
* @throws InvalidArgumentException Thrown if the `generationConfig` model parameter is invalid.
*/
protected function set_text_generation_config_from_model_params( array $model_params ): void {
if ( ! isset( $model_params['generationConfig'] ) ) {
return;
}
if ( is_array( $model_params['generationConfig'] ) ) {
$model_params['generationConfig'] = Text_Generation_Config::from_array( $model_params['generationConfig'] );
}
if ( ! $model_params['generationConfig'] instanceof Text_Generation_Config ) {
throw new InvalidArgumentException(
sprintf(
'Invalid generationConfig model parameter: The value must be an array or an instance of %s.',
Text_Generation_Config::class
)
);
}
$this->set_text_generation_config( $model_params['generationConfig'] );
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\Model_Param_Tool_Config_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Tool_Config;
use InvalidArgumentException;
/**
* Trait for a model that uses `Tool_Config`.
*
* @since 0.7.0
*/
trait Model_Param_Tool_Config_Trait {
/**
* The tool configuration.
*
* @since 0.7.0
* @var Tool_Config|null
*/
private $tool_config;
/**
* Gets the tool configuration.
*
* @since 0.7.0
*
* @return Tool_Config|null The tool configuration, or null if not set.
*/
final protected function get_tool_config(): ?Tool_Config {
return $this->tool_config;
}
/**
* Sets the tool configuration.
*
* @since 0.7.0
*
* @param Tool_Config $tool_config The tool configuration.
*/
final protected function set_tool_config( Tool_Config $tool_config ): void {
$this->tool_config = $tool_config;
}
/**
* Sets the tool configuration if provided in the `toolConfig` model parameter.
*
* @since 0.7.0
*
* @param array<string, mixed> $model_params The model parameters.
*
* @throws InvalidArgumentException Thrown if the `toolConfig` model parameter is invalid.
*/
protected function set_tool_config_from_model_params( array $model_params ): void {
if ( ! isset( $model_params['toolConfig'] ) ) {
return;
}
if ( is_array( $model_params['toolConfig'] ) ) {
$model_params['toolConfig'] = Tool_Config::from_array( $model_params['toolConfig'] );
}
if ( ! $model_params['toolConfig'] instanceof Tool_Config ) {
throw new InvalidArgumentException(
sprintf(
'Invalid toolConfig model parameter: The value must be an array or an instance of %s.',
Tool_Config::class
)
);
}
$this->set_tool_config( $model_params['toolConfig'] );
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\Model_Param_Tools_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Tools;
use InvalidArgumentException;
/**
* Trait for a model that uses `Tools`.
*
* @since 0.7.0
*/
trait Model_Param_Tools_Trait {
/**
* The tools instance.
*
* @since 0.7.0
* @var Tools|null
*/
private $tools;
/**
* Gets the tools instance.
*
* @since 0.7.0
*
* @return Tools|null The tools instance, or null if not set.
*/
final protected function get_tools(): ?Tools {
return $this->tools;
}
/**
* Sets the tools instance.
*
* @since 0.7.0
*
* @param Tools $tools The tools instance.
*/
final protected function set_tools( Tools $tools ): void {
$this->tools = $tools;
}
/**
* Sets the tools instance if provided in the `tools` model parameter.
*
* @since 0.7.0
*
* @param array<string, mixed> $model_params The model parameters.
*
* @throws InvalidArgumentException Thrown if the `tools` model parameter is invalid.
*/
protected function set_tools_from_model_params( array $model_params ): void {
if ( ! isset( $model_params['tools'] ) ) {
return;
}
if ( is_array( $model_params['tools'] ) ) {
$model_params['tools'] = Tools::from_array( $model_params['tools'] );
}
if ( ! $model_params['tools'] instanceof Tools ) {
throw new InvalidArgumentException(
sprintf(
'Invalid tools model parameter: The value must be an array or an instance of %s.',
Tools::class
)
);
}
$this->set_tools( $model_params['tools'] );
}
}

View File

@@ -0,0 +1,273 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\OpenAI_Compatible_Text_Generation_With_Function_Calling_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Contracts\Tool;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\API\Types\Parts\Function_Call_Part;
use ATFPP\AI_Translate\Services\API\Types\Parts\Function_Response_Part;
use ATFPP\AI_Translate\Services\API\Types\Tool_Config;
use ATFPP\AI_Translate\Services\API\Types\Tools\Function_Declarations_Tool;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use InvalidArgumentException;
/**
* Trait for an OpenAI compatible text generation model which implements function calling.
*
* @since 0.7.0
*/
trait OpenAI_Compatible_Text_Generation_With_Function_Calling_Trait {
use Model_Param_Tool_Config_Trait;
use Model_Param_Tools_Trait;
/**
* 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.
*
* @throws InvalidArgumentException Thrown if an invalid tool is provided.
*/
protected function prepare_generate_text_params( array $contents ): array {
$params = parent::prepare_generate_text_params( $contents );
if ( $this->get_tools() ) {
foreach ( $this->get_tools() as $tool ) {
$prepared = $this->prepare_tool( $params, $tool );
if ( ! $prepared ) {
throw $this->get_api_client()->create_bad_request_exception(
'Only function declarations tools are supported.'
);
}
}
}
if ( $this->get_tool_config() ) {
$params['tool_choice'] = $this->prepare_tool_choice_param( $this->get_tool_config() );
}
return $params;
}
/**
* 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 = parent::prepare_response_candidate_content_parts( $candidate_data );
if ( isset( $candidate_data['message']['tool_calls'] ) && is_array( $candidate_data['message']['tool_calls'] ) ) {
foreach ( $candidate_data['message']['tool_calls'] as $tool_call ) {
$prepared = $this->prepare_response_message_tool_call( $parts, $tool_call );
if ( ! $prepared ) {
throw $this->get_api_client()->create_response_exception(
'The response includes a tool call of an unexpected type.'
);
}
}
}
return $parts;
}
/**
* Prepares a given tool call from the response message, amending the provided Parts instance as needed.
*
* @since 0.7.0
*
* @param Parts $parts The Parts instance to amend.
* @param array<string, mixed> $tool_call_data The tool call data from the response message.
* @return bool True if the tool call was successfully prepared, false otherwise.
*/
protected function prepare_response_message_tool_call( Parts $parts, array $tool_call_data ): bool {
// Not all OpenAI compatible APIs include a 'type' key, so we only check its value if it is set.
if (
( isset( $tool_call_data['type'] ) && 'function' !== $tool_call_data['type'] ) ||
! isset( $tool_call_data['function'] ) ) {
return false;
}
$parts->add_function_call_part(
$tool_call_data['id'],
$tool_call_data['function']['name'],
is_string( $tool_call_data['function']['arguments'] )
? json_decode( $tool_call_data['function']['arguments'], true )
: $tool_call_data['function']['arguments']
);
return true;
}
/**
* Prepares a single tool for the API request, amending the provided parameters as needed.
*
* @since 0.7.0
*
* @param array<string, mixed> $params The parameters to prepare the tools for. Passed by reference.
* @param Tool $tool The tool to prepare.
* @return bool True if the tool was successfully prepared, false otherwise.
*/
protected function prepare_tool( array &$params, Tool $tool ): bool {
if ( ! $tool instanceof Function_Declarations_Tool ) {
return false;
}
$function_declarations = $tool->get_function_declarations();
if ( count( $function_declarations ) > 0 ) {
if ( ! isset( $params['tools'] ) ) {
$params['tools'] = array();
}
foreach ( $function_declarations as $declaration ) {
$params['tools'][] = array(
'type' => 'function',
'function' => array_filter(
array(
'name' => $declaration['name'],
'description' => $declaration['description'] ?? null,
'parameters' => $declaration['parameters'] ?? null,
)
),
);
}
}
return true;
}
/**
* Prepares the API request tool choice parameter for the model.
*
* @since 0.7.0
*
* @param Tool_Config $tool_config The tool config to prepare the parameter with.
* @return array<string, mixed> The tool config parameter value.
*/
private function prepare_tool_choice_param( Tool_Config $tool_config ): array {
// Either 'auto' or 'any'.
$tool_choice_param = $tool_config->get_function_call_mode() === 'any' ? 'required' : 'auto';
if ( 'required' === $tool_choice_param ) {
// If one specific function must be called, the parameter needs to be an object, otherwise a string.
$allowed_function_names = $tool_config->get_allowed_function_names();
if ( count( $allowed_function_names ) === 1 ) {
$tool_choice_param = array(
'type' => 'function',
'function' => array( 'name' => $allowed_function_names[0] ),
);
}
}
return $tool_choice_param;
}
/**
* 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();
$transformers = parent::get_content_transformers();
$orig_role_transformer = $transformers['role'];
$orig_content_transformer = $transformers['content'];
$transformers['role'] = static function ( Content $content ) use ( $orig_role_transformer ) {
// Special case of a function response.
$parts = $content->get_parts();
if ( count( $parts ) === 1 && $parts->get( 0 ) instanceof Function_Response_Part ) {
return 'tool';
}
return $orig_role_transformer( $content );
};
$transformers['content'] = static function ( Content $content ) use ( $orig_content_transformer, $api_client ) {
// Special case of a function response.
$parts = $content->get_parts();
if ( count( $parts ) === 1 && $parts->get( 0 ) instanceof Function_Response_Part ) {
$response = $parts->get( 0 )->get_response();
return json_encode( $response ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
}
$sanitized_parts = new Parts();
foreach ( $parts as $part ) {
/*
* Special cases: Function call parts are handled as part of a separate `tool_calls` key, and
* function response parts are are only supported as the only content of a message. They are
* handled as a special case above.
*/
if ( $part instanceof Function_Response_Part ) {
throw $api_client->create_bad_request_exception(
'The API only allows a single function response, and it has to be the only content of the message.'
);
}
if ( $part instanceof Function_Call_Part ) {
// Skip function call parts, they are handled in a separate `tool_calls` key.
continue;
}
$sanitized_parts->add_part( $part );
}
$sanitized_content = new Content( $content->get_role(), $sanitized_parts );
return $orig_content_transformer( $sanitized_content );
};
$transformers['tool_calls'] = static function ( Content $content ) {
// Special key that only applies in case function calls are present.
$tool_calls = array();
foreach ( $content->get_parts() as $part ) {
if ( $part instanceof Function_Call_Part ) {
$tool_calls[] = array(
'type' => 'function',
'id' => $part->get_id(),
'function' => array(
'name' => $part->get_name(),
'arguments' => json_encode( $part->get_args() ), // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
),
);
}
}
if ( count( $tool_calls ) > 0 ) {
return $tool_calls;
}
return null;
};
$transformers['tool_call_id'] = static function ( Content $content ) {
// Special key that only applies in case of a function response.
$parts = $content->get_parts();
if ( count( $parts ) === 1 && $parts->get( 0 ) instanceof Function_Response_Part ) {
return $parts->get( 0 )->get_id();
}
return null;
};
return $transformers;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\With_API_Client_Trait
*
* @since 0.7.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_API_Client;
use RuntimeException;
/**
* Trait for a service or model which implements the With_API_Client interface.
*
* @since 0.7.0
*/
trait With_API_Client_Trait {
/**
* The AI API client instance.
*
* @since 0.7.0
* @var Generative_AI_API_Client
*/
private $api_client;
/**
* Gets the API client instance.
*
* @since 0.7.0
*
* @return Generative_AI_API_Client The API client instance.
*
* @throws RuntimeException Thrown if the API client is not set.
*/
final public function get_api_client(): Generative_AI_API_Client {
if ( ! $this->api_client instanceof Generative_AI_API_Client ) {
throw new RuntimeException( 'API client must be set in the constructor.' );
}
return $this->api_client;
}
/**
* Sets the API client instance.
*
* @since 0.7.0
*
* @param Generative_AI_API_Client $api_client The API client instance.
*/
final protected function set_api_client( Generative_AI_API_Client $api_client ): void {
$this->api_client = $api_client;
}
}

View File

@@ -0,0 +1,105 @@
<?php
/**
* Trait ATFPP\AI_Translate\Services\Traits\With_Text_Generation_Trait
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Traits;
use ATFPP\AI_Translate\Services\API\Types\Candidates;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\Exception\Generative_AI_Exception;
use ATFPP\AI_Translate\Services\Util\AI_Capabilities;
use ATFPP\AI_Translate\Services\Util\Formatter;
use Generator;
use InvalidArgumentException;
/**
* Trait for a model which implements the With_Text_Generation interface.
*
* @since 0.1.0
*/
trait With_Text_Generation_Trait {
/**
* Generates text content using the model.
*
* @since 0.1.0
*
* @param string|Parts|Content|Content[] $content Prompt for the content to generate. Optionally, an array
* can be passed for additional context (e.g. chat history).
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Candidates The response candidates with generated text content - usually just one.
*
* @throws InvalidArgumentException Thrown if the given content is invalid.
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
final public function generate_text( $content, array $request_options = array() ): Candidates {
$contents = $this->sanitize_new_content( $content );
return $this->send_generate_text_request( $contents, $request_options );
}
/**
* Generates text content using the model, streaming the response.
*
* @since 0.3.0
*
* @param string|Parts|Content|Content[] $content Prompt for the content to generate. Optionally, an array
* can be passed for additional context (e.g. chat history).
* @param array<string, mixed> $request_options Optional. The request options. Default empty array.
* @return Generator<Candidates> Generator that yields the chunks of response candidates with generated text
* content - usually just one candidate.
*
* @throws InvalidArgumentException Thrown if the given content is invalid.
* @throws Generative_AI_Exception Thrown if the request fails or the response is invalid.
*/
final public function stream_generate_text( $content, array $request_options = array() ): Generator {
$contents = $this->sanitize_new_content( $content );
return $this->send_stream_generate_text_request( $contents, $request_options );
}
/**
* Sanitizes the input content for generating text.
*
* @since 0.3.0
*
* @param string|Parts|Content|Content[] $content The input content.
* @return Content[] The sanitized content.
*
* @throws InvalidArgumentException Thrown if the input content is invalid.
*/
private function sanitize_new_content( $content ) {
$capabilities = AI_Capabilities::get_model_instance_capabilities( $this );
return Formatter::format_and_validate_new_contents( $content, $capabilities );
}
/**
* Sends a request to generate text content.
*
* @since 0.1.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.
*/
abstract protected function send_generate_text_request( array $contents, array $request_options ): Candidates;
/**
* Sends a request to generate text content, streaming the response.
*
* @since 0.3.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.
*/
abstract protected function send_stream_generate_text_request( array $contents, array $request_options ): Generator;
}

View File

@@ -0,0 +1,163 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Util\AI_Capabilities
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Util;
use ATFPP\AI_Translate\Services\API\Enums\AI_Capability;
use ATFPP\AI_Translate\Services\API\Types\Model_Metadata;
use ATFPP\AI_Translate\Services\Contracts\Generative_AI_Model;
use ATFPP\AI_Translate\Services\Contracts\With_Function_Calling;
use ATFPP\AI_Translate\Services\Contracts\With_Multimodal_Input;
use ATFPP\AI_Translate\Services\Contracts\With_Multimodal_Output;
use ATFPP\AI_Translate\Services\Contracts\With_Text_Generation;
use ATFPP\AI_Translate\Services\Contracts\With_Web_Search;
use InvalidArgumentException;
/**
* Class exposing the available AI capabilities and related static utility methods.
*
* @since 0.1.0
*/
final class AI_Capabilities {
/**
* Gets the combined AI capabilities that the given model classes support.
*
* @since 0.7.0
*
* @param string[] $model_classes The model class names.
* @return string[] The AI capabilities that the model classes support, based on the interfaces they implement.
*/
public static function get_model_classes_capabilities( array $model_classes ): array {
$capabilities = array();
foreach ( $model_classes as $model_class ) {
$model_capabilities = self::get_model_class_capabilities( $model_class );
foreach ( $model_capabilities as $capability ) {
$capabilities[] = $capability;
}
}
return array_unique( $capabilities );
}
/**
* Gets the AI capabilities that the given model class supports.
*
* @since 0.1.0
*
* @param string $model_class The model class name.
* @return string[] The AI capabilities that the model class supports, based on the interfaces it implements.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
public static function get_model_class_capabilities( string $model_class ): array {
$interfaces = class_implements( $model_class );
$capabilities = array();
if ( isset( $interfaces[ With_Function_Calling::class ] ) ) {
$capabilities[] = AI_Capability::FUNCTION_CALLING;
}
if ( isset( $interfaces[ With_Multimodal_Input::class ] ) ) {
$capabilities[] = AI_Capability::MULTIMODAL_INPUT;
}
if ( isset( $interfaces[ With_Multimodal_Output::class ] ) ) {
$capabilities[] = AI_Capability::MULTIMODAL_OUTPUT;
}
if ( isset( $interfaces[ With_Text_Generation::class ] ) ) {
$capabilities[] = AI_Capability::TEXT_GENERATION;
}
if ( isset( $interfaces[ With_Web_Search::class ] ) ) {
$capabilities[] = AI_Capability::WEB_SEARCH;
}
return $capabilities;
}
/**
* Gets the AI capabilities that the given model instance supports.
*
* @since 0.5.0
*
* @param Generative_AI_Model $model The model instance.
* @return string[] The AI capabilities that the model instance supports, based on the interfaces it implements.
*
* @SuppressWarnings(PHPMD.NPathComplexity)
*/
public static function get_model_instance_capabilities( Generative_AI_Model $model ): array {
$capabilities = array();
if ( $model instanceof With_Function_Calling ) {
$capabilities[] = AI_Capability::FUNCTION_CALLING;
}
if ( $model instanceof With_Multimodal_Input ) {
$capabilities[] = AI_Capability::MULTIMODAL_INPUT;
}
if ( $model instanceof With_Multimodal_Output ) {
$capabilities[] = AI_Capability::MULTIMODAL_OUTPUT;
}
if ( $model instanceof With_Text_Generation ) {
$capabilities[] = AI_Capability::TEXT_GENERATION;
}
if ( $model instanceof With_Web_Search ) {
$capabilities[] = AI_Capability::WEB_SEARCH;
}
return $capabilities;
}
/**
* Gets the model slugs that satisfy the given capabilities.
*
* @since 0.1.0
* @since 0.5.0 Now expects an array of model data shapes, mapped by model slug.
* @since 0.7.0 Now expects a map of model metadata objects.
*
* @param array<string, Model_Metadata> $models Metadata for each model, mapped by model slug.
* @param string[] $capabilities The required capabilities that the models should satisfy.
* @return string[] Slugs of all models that satisfy the given capabilities.
*
* @throws InvalidArgumentException Thrown if no model satisfies the given capabilities.
*/
public static function get_model_slugs_for_capabilities( array $models, array $capabilities ): array {
$model_slugs = array();
foreach ( $models as $model_slug => $model_metadata ) {
$model_capabilities = $model_metadata->get_capabilities();
if ( ! array_diff( $capabilities, $model_capabilities ) ) {
$model_slugs[] = $model_slug;
}
}
if ( ! $model_slugs ) {
throw new InvalidArgumentException(
'No model satisfies the given capabilities.'
);
}
return $model_slugs;
}
/**
* Gets the model class name from the given model class names that satisfies the given capabilities.
*
* @since 0.7.0
*
* @param string[] $model_classes The model class names.
* @param string[] $capabilities The required capabilities that the models should satisfy.
* @return string The model class name that satisfies the given capabilities.
*
* @throws InvalidArgumentException Thrown if no model satisfies the given capabilities.
*/
public static function get_model_class_for_capabilities( array $model_classes, array $capabilities ): string {
foreach ( $model_classes as $model_class ) {
$model_capabilities = self::get_model_class_capabilities( $model_class );
if ( ! array_diff( $capabilities, $model_capabilities ) ) {
return $model_class;
}
}
throw new InvalidArgumentException(
'No model class satisfies the given capabilities.'
);
}
}

View File

@@ -0,0 +1,153 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Util\Data_Encryption
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Util;
/**
* Class responsible for encrypting and decrypting data.
*
* @since 0.1.0
* @see https://felix-arntz.me/blog/storing-confidential-data-in-wordpress/
*/
final class Data_Encryption {
/**
* Key to use for encryption.
*
* @since 0.1.0
* @var string
*/
private $key;
/**
* Salt to use for encryption.
*
* @since 0.1.0
* @var string
*/
private $salt;
/**
* Constructor.
*
* @since 0.1.0
*
* @param ?string $key Optional. Key to use for encryption. If not passed, the default key determined by constants
* will be used.
* @param ?string $salt Optional. Salt to use for encryption. If not passed, the default salt determined by
* constants will be used.
*/
public function __construct( ?string $key = null, ?string $salt = null ) {
$this->key = $key ?? $this->get_default_key();
$this->salt = $salt ?? $this->get_default_salt();
}
/**
* Encrypts a value.
*
* If a user-based key is set, that key is used. Otherwise the default key is used.
*
* @since 0.1.0
*
* @param string $value Value to encrypt.
* @return string Encrypted value, or empty string on failure.
*/
public function encrypt( string $value ): string {
if ( ! extension_loaded( 'openssl' ) ) {
return $value;
}
$method = 'aes-256-ctr';
$ivlen = openssl_cipher_iv_length( $method );
$iv = openssl_random_pseudo_bytes( $ivlen );
$raw_value = openssl_encrypt( $value . $this->salt, $method, $this->key, 0, $iv );
if ( ! $raw_value ) {
return '';
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return base64_encode( $iv . $raw_value );
}
/**
* Decrypts a value.
*
* If a user-based key is set, that key is used. Otherwise the default key is used.
*
* @since 0.1.0
*
* @param string $raw_value Value to decrypt.
* @return string Decrypted value, or empty string on failure.
*/
public function decrypt( string $raw_value ): string {
if ( ! extension_loaded( 'openssl' ) ) {
return $raw_value;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$decoded_value = base64_decode( $raw_value, true );
if ( false === $decoded_value ) {
return '';
}
$method = 'aes-256-ctr';
$ivlen = openssl_cipher_iv_length( $method );
$iv = substr( $decoded_value, 0, $ivlen );
$decoded_value = substr( $decoded_value, $ivlen );
$value = openssl_decrypt( $decoded_value, $method, $this->key, 0, $iv );
if ( ! $value || substr( $value, - strlen( $this->salt ) ) !== $this->salt ) {
return '';
}
return substr( $value, 0, - strlen( $this->salt ) );
}
/**
* Gets the default encryption key to use.
*
* @since 0.1.0
*
* @return string Default (not user-based) encryption key.
*/
private function get_default_key(): string {
if ( defined( 'AI_SERVICES_ENCRYPTION_KEY' ) && '' !== AI_SERVICES_ENCRYPTION_KEY ) {
return AI_SERVICES_ENCRYPTION_KEY;
}
if ( defined( 'LOGGED_IN_KEY' ) && '' !== LOGGED_IN_KEY ) {
return LOGGED_IN_KEY;
}
// If this is reached, you're either not on a live site or have a serious security issue.
return 'test-key';
}
/**
* Gets the default encryption salt to use.
*
* @since 0.1.0
*
* @return string Encryption salt.
*/
private function get_default_salt(): string {
if ( defined( 'AI_SERVICES_ENCRYPTION_SALT' ) && '' !== AI_SERVICES_ENCRYPTION_SALT ) {
return AI_SERVICES_ENCRYPTION_SALT;
}
if ( defined( 'LOGGED_IN_SALT' ) && '' !== LOGGED_IN_SALT ) {
return LOGGED_IN_SALT;
}
// If this is reached, you're either not on a live site or have a serious security issue.
return 'test-salt';
}
}

View File

@@ -0,0 +1,136 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Util\Formatter
*
* @since 0.1.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Util;
use ATFPP\AI_Translate\Services\API\Enums\AI_Capability;
use ATFPP\AI_Translate\Services\API\Enums\Content_Role;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\API\Types\Parts;
use ATFPP\AI_Translate\Services\API\Types\Parts\Text_Part;
use InvalidArgumentException;
/**
* Class providing static methods for formatting content.
*
* @since 0.1.0
*/
final class Formatter {
/**
* Formats and validates the various supported formats of a user prompt into a consistent list of Content instances.
*
* This method takes into account whether the provided content is supported by the given model, based on its capabilities.
*
* @since 0.5.0
*
* @param string|Parts|Content|Content[] $content The content to format.
* @param string[] $capabilities The AI capabilities that the model supports.
* @return Content[] The formatted Content instances.
*
* @throws InvalidArgumentException Thrown if the content is invalid or the model does not support it.
*/
public static function format_and_validate_new_contents( $content, array $capabilities ): array {
if ( is_array( $content ) ) {
$contents = array_map(
array( __CLASS__, 'format_new_content' ),
$content
);
} else {
$contents = array( self::format_new_content( $content ) );
}
if ( count( $contents ) === 0 ) {
throw new InvalidArgumentException(
'No prompt was provided.'
);
}
if ( Content_Role::USER !== $contents[0]->get_role() ) {
throw new InvalidArgumentException(
'The first Content instance in the conversation or prompt must be user content.'
);
}
if ( ! in_array( AI_Capability::CHAT_HISTORY, $capabilities, true ) && count( $contents ) > 1 ) {
throw new InvalidArgumentException(
'The model does not support chat history. Only one content prompt must be provided.'
);
}
if ( ! in_array( AI_Capability::MULTIMODAL_INPUT, $capabilities, true ) ) {
// For performance reasons, only check the last content prompt, which likely is the only new one.
$last_content = $contents[ count( $contents ) - 1 ];
$last_parts = $last_content->get_parts();
$last_parts_text_only = $last_parts->filter( array( 'class_name' => Text_Part::class ) );
if ( count( $last_parts_text_only ) < count( $last_parts ) ) {
throw new InvalidArgumentException(
'The model does not support multimodal input. Only text parts must be provided.'
);
}
}
return $contents;
}
/**
* Formats the various supported formats of new user content into a consistent Content instance.
*
* @since 0.1.0
*
* @param string|Parts|Content $content The content to format.
* @return Content The formatted new content.
*/
public static function format_new_content( $content ): Content {
return self::format_content( $content, Content_Role::USER );
}
/**
* Formats the various supported formats of a system instruction into a consistent Content instance.
*
* @since 0.1.0
*
* @param string|Parts|Content $input The system instruction to format.
* @return Content The formatted system instruction.
*/
public static function format_system_instruction( $input ): Content {
return self::format_content( $input, Content_Role::SYSTEM );
}
/**
* Formats the various supported formats of content into a consistent Content instance.
*
* @since 0.1.0
*
* @param string|Parts|Content $input The content to format.
* @param string $role The role for the content.
* @return Content The formatted content.
*
* @throws InvalidArgumentException Thrown if the value is not a string, a Parts instance, or a Content instance.
*/
public static function format_content( $input, string $role ): Content {
if ( is_string( $input ) ) {
$parts = new Parts();
$parts->add_text_part( $input );
return new Content( $role, $parts );
}
if ( $input instanceof Parts ) {
return new Content( $role, $input );
}
if ( ! $input instanceof Content ) {
throw new InvalidArgumentException(
'The value must be a string, a Parts instance, or a Content instance.'
);
}
return $input;
}
}

View File

@@ -0,0 +1,29 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Util\Strings
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Util;
/**
* Class providing static methods for string operations.
*
* @since 0.2.0
*/
final class Strings {
/**
* Converts a snake_case string to a camelCase string.
*
* @since 0.2.0
*
* @param string $input The snake_case string.
* @return string The camelCase string.
*/
public static function snake_case_to_camel_case( string $input ): string {
return lcfirst( str_replace( '_', '', ucwords( $input, '_' ) ) );
}
}

View File

@@ -0,0 +1,99 @@
<?php
/**
* Class ATFPP\AI_Translate\Services\Util\Transformer
*
* @since 0.2.0
* @package ai-services
*/
namespace ATFPP\AI_Translate\Services\Util;
use ATFPP\AI_Translate\Services\API\Types\Content;
use ATFPP\AI_Translate\Services\Contracts\Generation_Config;
use InvalidArgumentException;
/**
* Class providing static methods for transforming data.
*
* @since 0.2.0
*/
final class Transformer {
/**
* Transforms the given content using the provided transformers.
*
* @since 0.2.0
*
* @param Content $content The content to transform.
* @param array<string, callable> $transformers The transformers to use. Each transformer callback should accept
* the content as its only parameter and return the transformed value
* for its key.
* @return array<string, mixed> The transformed content.
*
* @throws InvalidArgumentException Thrown if a provided transformer is not callable.
*/
public static function transform_content( Content $content, array $transformers ): array {
$data = array();
foreach ( $transformers as $key => $transformer ) {
if ( ! is_callable( $transformer ) ) {
throw new InvalidArgumentException(
sprintf(
'The transformer for key %s is invalid.',
htmlspecialchars( $key ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
// Transform the value and set it if truthy.
$value = $transformer( $content );
if ( ! $value ) {
continue;
}
$data[ $key ] = $value;
}
return $data;
}
/**
* Merges the given Generation_Config instance into the given parameters using the provided transformers.
*
* @since 0.2.0
*
* @param array<string, mixed> $params The parameters to merge the generation config into.
* @param Generation_Config $config The generation config to use for the transformation.
* @param array<string, callable> $transformers The transformers to use. Each transformer callback should accept
* the generation config as its only parameter and return the
* transformed value for its key.
* @return array<string, mixed> The transformed parameters.
*
* @throws InvalidArgumentException Thrown if a provided transformer is not callable.
*/
public static function transform_generation_config_params( array $params, Generation_Config $config, array $transformers ): array {
foreach ( $transformers as $key => $transformer ) {
if ( ! is_callable( $transformer ) ) {
throw new InvalidArgumentException(
sprintf(
'The transformer for key %s is invalid.',
htmlspecialchars( $key ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
)
);
}
// Already set parameters take precedence.
if ( isset( $params[ $key ] ) ) {
continue;
}
// Transform the value and set it if truthy.
$value = $transformer( $config );
if ( ! $value ) {
continue;
}
$params[ $key ] = $value;
}
return $params;
}
}