mcp-core.php
10 hours ago
mcp-oauth.php
1 month ago
mcp-rest.php
3 weeks ago
mcp.conf
1 year ago
mcp.js
8 months ago
mcp.md
8 months ago
mcp.php
1 week ago
wpai-connectors.php
2 months ago
wpai-gateway-availability.php
2 months ago
wpai-gateway-directory.php
2 months ago
wpai-gateway-image-model.php
2 months ago
wpai-gateway-model.php
2 months ago
wpai-gateway-providers.php
2 months ago
wpai-gateway.php
2 months ago
wpai-gateway-model.php
245 lines
| 1 | <?php |
| 2 | /** |
| 3 | * TextGenerationModel for the AI Engine gateway. |
| 4 | * |
| 5 | * Translates AiClient's `array<Message> $prompt` into `Meow_MWAI_Query_Text`, |
| 6 | * dispatches it through AI Engine's engine for the matching provider, and |
| 7 | * wraps the reply as a `GenerativeAiResult`. |
| 8 | */ |
| 9 | |
| 10 | use WordPress\AiClient\Common\Exception\RuntimeException; |
| 11 | use WordPress\AiClient\Messages\DTO\Message; |
| 12 | use WordPress\AiClient\Messages\DTO\MessagePart; |
| 13 | use WordPress\AiClient\Messages\DTO\ModelMessage; |
| 14 | use WordPress\AiClient\Messages\Enums\MessageRoleEnum; |
| 15 | use WordPress\AiClient\Providers\DTO\ProviderMetadata; |
| 16 | use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; |
| 17 | use WordPress\AiClient\Providers\Models\DTO\ModelConfig; |
| 18 | use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; |
| 19 | use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; |
| 20 | use WordPress\AiClient\Results\DTO\Candidate; |
| 21 | use WordPress\AiClient\Results\DTO\GenerativeAiResult; |
| 22 | use WordPress\AiClient\Results\DTO\TokenUsage; |
| 23 | use WordPress\AiClient\Results\Enums\FinishReasonEnum; |
| 24 | |
| 25 | class Meow_MWAI_Labs_WPAI_Gateway_Model implements ModelInterface, TextGenerationModelInterface { |
| 26 | |
| 27 | private ModelMetadata $modelMetadata; |
| 28 | private ProviderMetadata $providerMetadata; |
| 29 | private ModelConfig $config; |
| 30 | |
| 31 | public function __construct( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ) { |
| 32 | $this->modelMetadata = $modelMetadata; |
| 33 | $this->providerMetadata = $providerMetadata; |
| 34 | $this->config = new ModelConfig(); |
| 35 | } |
| 36 | |
| 37 | public function metadata(): ModelMetadata { return $this->modelMetadata; } |
| 38 | public function providerMetadata(): ProviderMetadata { return $this->providerMetadata; } |
| 39 | public function setConfig( ModelConfig $config ): void { $this->config = $config; } |
| 40 | public function getConfig(): ModelConfig { return $this->config; } |
| 41 | |
| 42 | public function generateTextResult( array $prompt ): GenerativeAiResult { |
| 43 | // KNOWN LIMITATIONS (to address as we harden this gateway): |
| 44 | // |
| 45 | // 1. Phase 4 TODO — validate model-specific constraints before |
| 46 | // dispatching. E.g. cap `maxTokens` to the model's real ceiling |
| 47 | // (AI Engine stores it on each model), reject `temperature` on |
| 48 | // models that forbid it (o1 family), give a clear error up-front |
| 49 | // instead of a downstream API rejection. |
| 50 | // 2. File parts (image, document, audio) are now round-tripped via |
| 51 | // `extract_parts` → `Meow_MWAI_Query_DroppedFile` → `$query->add_file`. |
| 52 | // Function-call / function-response parts on inbound messages are |
| 53 | // still ignored on purpose: AI Engine handles tools through its own |
| 54 | // filter pipeline (`mwai_functions_list`, `mwai_ai_feedback`). |
| 55 | // 3. Streaming is not exposed. AiClient has no streaming hook today, |
| 56 | // but if it ever does, we need to bridge to `run_query( $q, $cb )`. |
| 57 | // 4. Errors from `run_query()` surface as thrown RuntimeException; |
| 58 | // partial failures (e.g. a function-call tool erroring mid-loop) |
| 59 | // aren't translated into the `GenerativeAiResult` error shape. |
| 60 | // |
| 61 | $core = Meow_MWAI_Labs_WPAI_Gateway::$core; |
| 62 | if ( ! $core ) { |
| 63 | throw new RuntimeException( 'AI Engine core unavailable.' ); |
| 64 | } |
| 65 | |
| 66 | $model_id = $this->modelMetadata->getId(); |
| 67 | $env = $this->resolve_env_for_model( $core, $model_id ); |
| 68 | if ( ! $env ) { |
| 69 | throw new RuntimeException( sprintf( |
| 70 | 'No AI Engine environment is configured for model "%s".', |
| 71 | $model_id |
| 72 | ) ); |
| 73 | } |
| 74 | |
| 75 | // Translate AiClient messages → AI Engine role/content pairs, and collect |
| 76 | // file parts (images, documents, audio) for attachment on the query. |
| 77 | $messages = []; |
| 78 | $files = []; |
| 79 | foreach ( $prompt as $msg ) { |
| 80 | if ( ! $msg instanceof Message ) { |
| 81 | continue; |
| 82 | } |
| 83 | $role = $msg->getRole()->isModel() ? 'assistant' : 'user'; |
| 84 | list( $text, $msg_files ) = $this->extract_parts( $msg ); |
| 85 | foreach ( $msg_files as $f ) { |
| 86 | $files[] = $f; |
| 87 | } |
| 88 | if ( $text === '' && empty( $msg_files ) ) { |
| 89 | continue; |
| 90 | } |
| 91 | // AI Engine expects a non-empty content per message; if the message |
| 92 | // carried only files, leave a placeholder so the message slot exists. |
| 93 | if ( $text === '' ) { |
| 94 | $text = '[attachment]'; |
| 95 | } |
| 96 | $messages[] = [ 'role' => $role, 'content' => $text ]; |
| 97 | } |
| 98 | |
| 99 | // Split off the last user turn as the main "message"; everything before |
| 100 | // is history. |
| 101 | $last_user_idx = null; |
| 102 | for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) { |
| 103 | if ( $messages[ $i ]['role'] === 'user' ) { |
| 104 | $last_user_idx = $i; |
| 105 | break; |
| 106 | } |
| 107 | } |
| 108 | $current = $last_user_idx !== null ? $messages[ $last_user_idx ]['content'] : ''; |
| 109 | $history = $last_user_idx !== null |
| 110 | ? array_merge( array_slice( $messages, 0, $last_user_idx ), array_slice( $messages, $last_user_idx + 1 ) ) |
| 111 | : $messages; |
| 112 | |
| 113 | $query = new Meow_MWAI_Query_Text( $current ); |
| 114 | // Stamp the call with the framework name so it's identifiable in Insights. |
| 115 | $query->set_scope( 'wp-ai-client' ); |
| 116 | $query->set_env_id( $env['id'] ); |
| 117 | $query->set_model( $model_id ); |
| 118 | if ( ! empty( $history ) ) { |
| 119 | $query->set_messages( $history ); |
| 120 | } |
| 121 | foreach ( $files as $dropped ) { |
| 122 | $query->add_file( $dropped ); |
| 123 | } |
| 124 | $system = $this->config->getSystemInstruction(); |
| 125 | if ( $system ) { |
| 126 | $query->set_instructions( $system ); |
| 127 | } |
| 128 | $maxTokens = $this->config->getMaxTokens(); |
| 129 | if ( $maxTokens !== null ) { |
| 130 | $query->set_max_tokens( (int) $maxTokens ); |
| 131 | } |
| 132 | $temperature = $this->config->getTemperature(); |
| 133 | if ( $temperature !== null ) { |
| 134 | $query->set_temperature( (float) $temperature ); |
| 135 | } |
| 136 | |
| 137 | $reply = $core->run_query( $query ); |
| 138 | |
| 139 | return $this->build_result( $reply ); |
| 140 | } |
| 141 | |
| 142 | // ─────────────────────────────────────────────────────────────────────── |
| 143 | |
| 144 | private function resolve_env_for_model( $core, string $model_id ): ?array { |
| 145 | $options = $core->get_all_options(); |
| 146 | $engines = $options['ai_engines'] ?? []; |
| 147 | $engine_type_for_model = null; |
| 148 | foreach ( $engines as $engine ) { |
| 149 | foreach ( $engine['models'] ?? [] as $m ) { |
| 150 | if ( ( $m['model'] ?? '' ) === $model_id ) { |
| 151 | $engine_type_for_model = $engine['type'] ?? null; |
| 152 | break 2; |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | if ( ! $engine_type_for_model ) { |
| 157 | return null; |
| 158 | } |
| 159 | foreach ( $options['ai_envs'] ?? [] as $env ) { |
| 160 | if ( ( $env['type'] ?? '' ) === $engine_type_for_model ) { |
| 161 | return $env; |
| 162 | } |
| 163 | } |
| 164 | return null; |
| 165 | } |
| 166 | |
| 167 | /** |
| 168 | * Pulls text and file attachments out of an AiClient Message. |
| 169 | * Function-call / function-response parts are intentionally ignored — |
| 170 | * AI Engine handles tool-calling through its own filter pipeline |
| 171 | * (`mwai_functions_list`, `mwai_ai_feedback`), not via inbound messages. |
| 172 | * |
| 173 | * @return array{0: string, 1: array<int, Meow_MWAI_Query_DroppedFile>} |
| 174 | */ |
| 175 | private function extract_parts( Message $msg ): array { |
| 176 | $text = ''; |
| 177 | $files = []; |
| 178 | foreach ( $msg->getParts() as $part ) { |
| 179 | if ( ! $part instanceof MessagePart ) { |
| 180 | continue; |
| 181 | } |
| 182 | $type = $part->getType(); |
| 183 | if ( $type->isText() ) { |
| 184 | $text .= (string) $part->getText(); |
| 185 | } |
| 186 | elseif ( $type->isFile() ) { |
| 187 | $file = $part->getFile(); |
| 188 | if ( $file === null ) { |
| 189 | continue; |
| 190 | } |
| 191 | $dropped = $this->file_to_dropped( $file ); |
| 192 | if ( $dropped !== null ) { |
| 193 | $files[] = $dropped; |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | return [ $text, $files ]; |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Converts an AiClient `File` (URL, base64, or data URI) into AI Engine's |
| 202 | * `Meow_MWAI_Query_DroppedFile` so the underlying engine can attach it to |
| 203 | * the request (vision payload, document upload, etc.). |
| 204 | */ |
| 205 | private function file_to_dropped( $file ): ?Meow_MWAI_Query_DroppedFile { |
| 206 | try { |
| 207 | $mime = $file->getMimeType(); |
| 208 | $url = $file->getUrl(); |
| 209 | if ( $url ) { |
| 210 | return Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mime ); |
| 211 | } |
| 212 | $b64 = $file->getBase64Data(); |
| 213 | if ( $b64 ) { |
| 214 | return Meow_MWAI_Query_DroppedFile::from_data( base64_decode( $b64 ), 'analysis', $mime ); |
| 215 | } |
| 216 | } |
| 217 | catch ( \Throwable $e ) { |
| 218 | // Skip the file rather than failing the whole prompt. |
| 219 | } |
| 220 | return null; |
| 221 | } |
| 222 | |
| 223 | private function build_result( $reply ): GenerativeAiResult { |
| 224 | $text = (string) ( $reply->result ?? '' ); |
| 225 | $part = new MessagePart( $text ); |
| 226 | $message = new ModelMessage( [ $part ] ); |
| 227 | $candidate = new Candidate( $message, FinishReasonEnum::stop() ); |
| 228 | |
| 229 | $usage = $reply->usage ?? []; |
| 230 | $prompt = (int) ( $usage['prompt_tokens'] ?? 0 ); |
| 231 | $completion = (int) ( $usage['completion_tokens'] ?? 0 ); |
| 232 | $total = (int) ( $usage['total_tokens'] ?? ( $prompt + $completion ) ); |
| 233 | $tokenUsage = new TokenUsage( $prompt, $completion, $total ); |
| 234 | |
| 235 | $id = (string) ( $reply->id ?? uniqid( 'mwai-', true ) ); |
| 236 | return new GenerativeAiResult( |
| 237 | $id, |
| 238 | [ $candidate ], |
| 239 | $tokenUsage, |
| 240 | $this->providerMetadata, |
| 241 | $this->modelMetadata |
| 242 | ); |
| 243 | } |
| 244 | } |
| 245 |