Skip to content

Commit d5548b8

Browse files
authored
GH#2645: Add managed text-to-speech AI Client model
1 parent 2d5270f commit d5548b8

5 files changed

Lines changed: 762 additions & 8 deletions

File tree

includes/Infrastructure/AiClient/Superdav/SuperdavAiModelMetadataDirectory.php

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ protected function parseResponseToModelMetadataList( Response $response ): array
6565
: $item['id'];
6666

6767
$capabilities = self::supported_capabilities( $item );
68+
if ( null === $capabilities ) {
69+
continue;
70+
}
6871

6972
$models[] = new ModelMetadata(
7073
$item['id'],
@@ -85,13 +88,33 @@ protected function parseResponseToModelMetadataList( Response $response ): array
8588
* @return list<SupportedOption>
8689
*/
8790
private static function supported_options( array $capabilities ): array {
91+
if ( self::has_capability( $capabilities, CapabilityEnum::textToSpeechConversion() ) ) {
92+
return self::text_to_speech_supported_options();
93+
}
94+
8895
if ( self::has_capability( $capabilities, CapabilityEnum::imageGeneration() ) ) {
8996
return self::image_supported_options();
9097
}
9198

9299
return self::text_supported_options();
93100
}
94101

102+
/**
103+
* Managed text-to-speech options matching the service capability contract.
104+
*
105+
* @return list<SupportedOption>
106+
*/
107+
private static function text_to_speech_supported_options(): array {
108+
return array(
109+
new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ),
110+
new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::audio() ) ) ),
111+
new SupportedOption( OptionEnum::outputFileType(), array( FileTypeEnum::inline() ) ),
112+
new SupportedOption( OptionEnum::outputMimeType(), array_keys( SuperdavAiTextToSpeechConversionModel::MIME_TYPE_TO_RESPONSE_FORMAT ) ),
113+
new SupportedOption( OptionEnum::outputSpeechVoice(), SuperdavAiTextToSpeechConversionModel::SUPPORTED_VOICES ),
114+
new SupportedOption( OptionEnum::customOptions() ),
115+
);
116+
}
117+
95118
/**
96119
* Common OpenAI-compatible text generation options.
97120
*
@@ -168,15 +191,17 @@ private static function has_capability( array $capabilities, CapabilityEnum $tar
168191
* Parse provider-advertised capability flags into SDK capability enums.
169192
*
170193
* @param array<string, mixed> $item Model item from `/v1/models`.
171-
* @return list<CapabilityEnum>
194+
* @return list<CapabilityEnum>|null Known capabilities, or null for explicitly unsupported models.
172195
*/
173-
private static function supported_capabilities( array $item ): array {
174-
$values = array();
196+
private static function supported_capabilities( array $item ): ?array {
197+
$values = array();
198+
$has_explicit_capabilities = false;
175199

176200
foreach ( array( 'capabilities', 'supported_capabilities' ) as $key ) {
177201
if ( ! isset( $item[ $key ] ) || ! is_array( $item[ $key ] ) ) {
178202
continue;
179203
}
204+
$has_explicit_capabilities = true;
180205
foreach ( $item[ $key ] as $capability_key => $capability_value ) {
181206
if ( is_string( $capability_key ) && true === $capability_value ) {
182207
$values[] = $capability_key;
@@ -189,12 +214,22 @@ private static function supported_capabilities( array $item ): array {
189214
}
190215

191216
foreach ( $item as $key => $value ) {
192-
if ( is_string( $key ) && str_starts_with( $key, 'supports_' ) && true === $value ) {
193-
$values[] = substr( $key, strlen( 'supports_' ) );
217+
if ( ! is_string( $key ) || ! str_starts_with( $key, 'supports_' ) ) {
218+
continue;
219+
}
220+
221+
$capability = str_replace( '-', '_', strtolower( substr( $key, strlen( 'supports_' ) ) ) );
222+
if ( 'tool_calling' === $capability ) {
223+
continue;
224+
}
225+
226+
$has_explicit_capabilities = true;
227+
if ( true === $value ) {
228+
$values[] = $capability;
194229
}
195230
}
196231

197-
if ( array() === $values ) {
232+
if ( ! $has_explicit_capabilities ) {
198233
return array( CapabilityEnum::textGeneration() );
199234
}
200235

@@ -206,6 +241,6 @@ private static function supported_capabilities( array $item ): array {
206241
}
207242
}
208243

209-
return array() === $capabilities ? array( CapabilityEnum::textGeneration() ) : $capabilities;
244+
return array() === $capabilities ? null : $capabilities;
210245
}
211246
}

includes/Infrastructure/AiClient/Superdav/SuperdavAiProvider.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ public static function reasoning_effort_for_model( string $model_id ): string {
141141
*/
142142
protected static function createModel( ModelMetadata $model_metadata, ProviderMetadata $provider_metadata ): ModelInterface {
143143
foreach ( $model_metadata->getSupportedCapabilities() as $capability ) {
144+
if ( $capability->equals( CapabilityEnum::textToSpeechConversion() ) ) {
145+
return new SuperdavAiTextToSpeechConversionModel( $model_metadata, $provider_metadata );
146+
}
147+
144148
if ( $capability->equals( CapabilityEnum::imageGeneration() ) ) {
145149
return new SuperdavAiImageGenerationModel( $model_metadata, $provider_metadata );
146150
}
@@ -216,7 +220,7 @@ protected static function createProviderMetadata(): ProviderMetadata {
216220
ProviderTypeEnum::cloud(),
217221
null,
218222
RequestAuthenticationMethod::apiKey(),
219-
'OpenAI-compatible AI service hosted for SD AI Agent.'
223+
'OpenAI-compatible text, image, and speech synthesis service hosted for SD AI Agent.'
220224
);
221225
}
222226

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SdAiAgent\Infrastructure\AiClient\Superdav;
6+
7+
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
8+
use WordPress\AiClient\Files\DTO\File;
9+
use WordPress\AiClient\Messages\DTO\Message;
10+
use WordPress\AiClient\Messages\DTO\MessagePart;
11+
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
12+
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel;
13+
use WordPress\AiClient\Providers\Http\DTO\Request;
14+
use WordPress\AiClient\Providers\Http\DTO\Response;
15+
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
16+
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
17+
use WordPress\AiClient\Providers\Http\Util\ResponseUtil;
18+
use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface;
19+
use WordPress\AiClient\Results\DTO\Candidate;
20+
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
21+
use WordPress\AiClient\Results\DTO\TokenUsage;
22+
use WordPress\AiClient\Results\Enums\FinishReasonEnum;
23+
24+
if ( ! defined( 'ABSPATH' ) ) {
25+
exit;
26+
}
27+
28+
/**
29+
* Text-to-speech conversion model for the managed Superdav service.
30+
*/
31+
final class SuperdavAiTextToSpeechConversionModel extends AbstractApiBasedModel implements TextToSpeechConversionModelInterface {
32+
33+
public const DEFAULT_VOICE = 'alloy';
34+
public const DEFAULT_MIME_TYPE = 'audio/mpeg';
35+
public const MAX_INPUT_CHARACTERS = 4096;
36+
public const MAX_RESPONSE_BYTES = 20 * 1024 * 1024;
37+
public const MIN_SPEED = 0.25;
38+
public const MAX_SPEED = 4.0;
39+
40+
/** @var list<string> */
41+
public const SUPPORTED_VOICES = array( self::DEFAULT_VOICE );
42+
43+
/** @var list<string> */
44+
public const SUPPORTED_LANGUAGES = array( 'en-US' );
45+
46+
/** @var array<string, string> */
47+
public const MIME_TYPE_TO_RESPONSE_FORMAT = array(
48+
'audio/mpeg' => 'mp3',
49+
'audio/ogg' => 'opus',
50+
'audio/aac' => 'aac',
51+
'audio/flac' => 'flac',
52+
'audio/wav' => 'wav',
53+
'audio/l16' => 'pcm',
54+
);
55+
56+
/**
57+
* Convert text messages into one inline audio file.
58+
*
59+
* @param array<int, Message> $prompt Prompt messages.
60+
* @phpstan-param list<Message> $prompt
61+
* @return GenerativeAiResult Speech result.
62+
*/
63+
public function convertTextToSpeechResult( array $prompt ): GenerativeAiResult {
64+
$mime_type = $this->prepare_output_mime_type();
65+
$request = $this->createRequest(
66+
HttpMethodEnum::POST(),
67+
'audio/speech',
68+
array( 'Content-Type' => 'application/json' ),
69+
$this->prepare_convert_params( $prompt, $mime_type )
70+
);
71+
$request = $this->getRequestAuthentication()->authenticateRequest( $request );
72+
$response = $this->getHttpTransporter()->send( $request );
73+
74+
ResponseUtil::throwIfNotSuccessful( $response );
75+
76+
return $this->parse_response_to_generative_ai_result( $response, $mime_type );
77+
}
78+
79+
/**
80+
* Create an authenticated-service request with SDK transport options.
81+
*
82+
* @param HttpMethodEnum $method HTTP method.
83+
* @param string $path Service path.
84+
* @param array<string, string|list<string>> $headers Request headers.
85+
* @param string|array<string, mixed>|null $data Request data.
86+
* @return Request
87+
*/
88+
protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), mixed $data = null ): Request {
89+
return new Request( $method, SuperdavAiProvider::url( $path ), SuperdavAiProvider::with_session_attribution( $headers ), $data, $this->getRequestOptions() );
90+
}
91+
92+
/**
93+
* Prepare the allowlisted service request parameters.
94+
*
95+
* @param array<int, Message> $prompt Prompt messages.
96+
* @param string $mime_type Requested output MIME type.
97+
* @phpstan-param list<Message> $prompt
98+
* @return array<string, mixed>
99+
*/
100+
private function prepare_convert_params( array $prompt, string $mime_type ): array {
101+
$instructions = $this->getConfig()->getSystemInstruction();
102+
if ( is_string( $instructions ) && '' !== trim( $instructions ) ) {
103+
throw new InvalidArgumentException( 'Instructions are not supported for managed text-to-speech conversion.' );
104+
}
105+
106+
$params = array(
107+
'model' => $this->metadata()->getId(),
108+
'input' => $this->prepare_prompt_text( $prompt ),
109+
'voice' => $this->prepare_voice(),
110+
'response_format' => self::MIME_TYPE_TO_RESPONSE_FORMAT[ $mime_type ],
111+
);
112+
113+
foreach ( $this->getConfig()->getCustomOptions() as $key => $value ) {
114+
if ( array_key_exists( $key, $params ) ) {
115+
throw new InvalidArgumentException( sprintf( 'The custom option "%s" conflicts with a required text-to-speech parameter.', esc_html( (string) $key ) ) );
116+
}
117+
118+
switch ( $key ) {
119+
case 'speed':
120+
if ( ( ! is_int( $value ) && ! is_float( $value ) ) || ! is_finite( (float) $value ) || $value < self::MIN_SPEED || $value > self::MAX_SPEED ) {
121+
throw new InvalidArgumentException( 'The custom option "speed" must be a finite number from 0.25 through 4.' );
122+
}
123+
$params['speed'] = $value;
124+
break;
125+
126+
case 'language':
127+
if ( ! is_string( $value ) || ! in_array( $value, self::SUPPORTED_LANGUAGES, true ) ) {
128+
throw new InvalidArgumentException( 'The custom option "language" is not supported by the managed text-to-speech service.' );
129+
}
130+
$params['language'] = $value;
131+
break;
132+
133+
default:
134+
throw new InvalidArgumentException( sprintf( 'The custom option "%s" is not supported for managed text-to-speech conversion.', esc_html( (string) $key ) ) );
135+
}
136+
}
137+
138+
return $params;
139+
}
140+
141+
/**
142+
* Join only text parts and enforce the service input ceiling.
143+
*
144+
* @param array<int, Message> $prompt Prompt messages.
145+
* @phpstan-param list<Message> $prompt
146+
* @return string
147+
*/
148+
private function prepare_prompt_text( array $prompt ): string {
149+
$text_parts = array();
150+
foreach ( $prompt as $message ) {
151+
foreach ( $message->getParts() as $part ) {
152+
if ( ! $part instanceof MessagePart || ! $part->getType()->isText() ) {
153+
continue;
154+
}
155+
$text = $part->getText();
156+
if ( is_string( $text ) ) {
157+
$text_parts[] = $text;
158+
}
159+
}
160+
}
161+
162+
$input = implode( "\n", $text_parts );
163+
if ( '' === trim( $input ) ) {
164+
throw new InvalidArgumentException( 'The prompt must contain text to convert to speech.' );
165+
}
166+
167+
if ( self::unicode_length( $input ) > self::MAX_INPUT_CHARACTERS ) {
168+
throw new InvalidArgumentException( 'The text-to-speech prompt exceeds the 4096-character service limit.' );
169+
}
170+
171+
return $input;
172+
}
173+
174+
/** Resolve and validate the configured voice. */
175+
private function prepare_voice(): string {
176+
$voice = $this->getConfig()->getOutputSpeechVoice();
177+
$voice = null === $voice || '' === trim( $voice ) ? self::DEFAULT_VOICE : trim( $voice );
178+
179+
if ( ! in_array( $voice, self::SUPPORTED_VOICES, true ) ) {
180+
throw new InvalidArgumentException( 'The configured output speech voice is not supported by the managed service.' );
181+
}
182+
183+
return $voice;
184+
}
185+
186+
/** Resolve and validate the configured output MIME type. */
187+
private function prepare_output_mime_type(): string {
188+
$mime_type = $this->getConfig()->getOutputMimeType();
189+
$mime_type = null === $mime_type || '' === trim( $mime_type ) ? self::DEFAULT_MIME_TYPE : strtolower( trim( $mime_type ) );
190+
191+
if ( ! isset( self::MIME_TYPE_TO_RESPONSE_FORMAT[ $mime_type ] ) ) {
192+
throw new InvalidArgumentException( 'The configured output MIME type is not supported for managed text-to-speech conversion.' );
193+
}
194+
195+
return $mime_type;
196+
}
197+
198+
/**
199+
* Convert validated binary audio into an inline SDK result.
200+
*/
201+
private function parse_response_to_generative_ai_result( Response $response, string $requested_mime_type ): GenerativeAiResult {
202+
$body = $response->getBody();
203+
if ( null === $body || '' === $body ) {
204+
throw ResponseException::fromMissingData( 'SD AI', 'body' );
205+
}
206+
if ( strlen( $body ) > self::MAX_RESPONSE_BYTES ) {
207+
throw ResponseException::fromInvalidData( 'SD AI', 'body', 'The audio response exceeds the supported size limit.' );
208+
}
209+
210+
$content_type = $response->getHeaderAsString( 'content-type' );
211+
$response_mime_type = is_string( $content_type )
212+
? strtolower( trim( explode( ';', $content_type, 2 )[0] ) )
213+
: '';
214+
if ( ! isset( self::MIME_TYPE_TO_RESPONSE_FORMAT[ $response_mime_type ] ) ) {
215+
throw ResponseException::fromInvalidData( 'SD AI', 'content-type', 'The audio response MIME type is unsupported.' );
216+
}
217+
if ( $response_mime_type !== $requested_mime_type ) {
218+
throw ResponseException::fromInvalidData( 'SD AI', 'content-type', 'The audio response MIME type does not match the requested format.' );
219+
}
220+
221+
$file = new File( base64_encode( $body ), $response_mime_type ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Binary audio must be represented as an inline SDK file.
222+
$message = new Message( MessageRoleEnum::model(), array( new MessagePart( $file ) ) );
223+
$candidate = new Candidate( $message, FinishReasonEnum::stop() );
224+
225+
return new GenerativeAiResult(
226+
'superdav-tts-' . wp_generate_uuid4(),
227+
array( $candidate ),
228+
new TokenUsage( 0, 0, 0 ),
229+
$this->providerMetadata(),
230+
$this->metadata()
231+
);
232+
}
233+
234+
/** Count Unicode code points without silently accepting invalid UTF-8. */
235+
private static function unicode_length( string $value ): int {
236+
if ( 1 !== preg_match( '//u', $value ) ) {
237+
throw new InvalidArgumentException( 'The text-to-speech prompt must be valid UTF-8 text.' );
238+
}
239+
240+
if ( function_exists( 'mb_strlen' ) ) {
241+
return mb_strlen( $value, 'UTF-8' );
242+
}
243+
244+
$count = preg_match_all( '/./us', $value );
245+
if ( false === $count ) {
246+
throw new InvalidArgumentException( 'The text-to-speech prompt must be valid UTF-8 text.' );
247+
}
248+
249+
return $count;
250+
}
251+
}

0 commit comments

Comments
 (0)