PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.6
3.7.6 3.7.5 3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / chatml.php
ai-engine / classes / engines Last commit date
anthropic.php 4 days ago chatml.php 2 days ago core.php 1 month ago custom.php 1 month ago factory.php 2 months ago google-interactions.php 1 month ago google.php 1 month ago mistral.php 2 months ago open-router.php 4 days ago openai.php 2 weeks ago ovh.php 1 month ago perplexity.php 8 months ago replicate.php 2 weeks ago xai.php 3 months ago
chatml.php
2317 lines
1 <?php
2
3 /**
4 * Base implementation of the ChatML API.
5 * This was first introduced by OpenAI and many providers keep compatibility with it.
6 * Engines relying on this original API can extend this class.
7 *
8 */
9
10 class Meow_MWAI_Engines_ChatML extends Meow_MWAI_Engines_Core {
11 // Base (OpenAI)
12 protected $apiKey = null;
13 protected $organizationId = null;
14
15 // Azure
16 private $azureDeployments = null;
17 protected $azureApiVersion = 'api-version=2024-12-01-preview';
18
19 // Response
20 protected $inModel = null;
21 protected $inId = null;
22 protected $inThreadId = null;
23
24 // Streaming
25 protected $streamFunctionCall = null;
26 protected $streamToolCalls = [];
27 protected $streamLastMessage = null;
28 protected $streamAnnotations = [];
29 protected $streamImageIds = [];
30 protected $streamThinking = null; // For reasoning/thinking content
31 protected $streamThinkTag = 0; // Inside an inline <think> block (Qwen-style models)
32
33 protected $streamInTokens = null;
34 protected $streamOutTokens = null;
35 protected $streamCost = null;
36 protected $streamStartEmitted = false;
37
38 public function __construct( $core, $env ) {
39 parent::__construct( $core, $env );
40 $this->set_environment();
41 }
42
43 public function reset_stream() {
44 $this->streamContent = null;
45 $this->streamBuffer = null;
46 $this->streamFunctionCall = null;
47 $this->streamToolCalls = [];
48 $this->streamLastMessage = null;
49 $this->streamThinking = null;
50 $this->streamThinkTag = 0;
51 $this->streamInTokens = null;
52 $this->streamOutTokens = null;
53 $this->inModel = null;
54 $this->inId = null;
55 $this->emittedFunctionResults = [];
56 $this->streamStartEmitted = false;
57 }
58
59 protected function set_environment() {
60 $env = $this->env;
61 $this->apiKey = $env['apikey'];
62
63 if ( isset( $env['organizationId'] ) ) {
64 $this->organizationId = $env['organizationId'];
65 }
66 if ( $this->envType === 'azure' ) {
67 $this->azureDeployments = isset( $env['deployments'] ) ? $env['deployments'] : [];
68 }
69 }
70
71 protected function get_azure_deployment_name( $model ) {
72 foreach ( $this->azureDeployments as $deployment ) {
73 if ( $deployment['model'] === $model && !empty( $deployment['name'] ) ) {
74 return $deployment['name'];
75 }
76 }
77 throw new Exception( 'Unknown deployment for model: ' . $model );
78 }
79
80 protected function get_service_name() {
81 return $this->envType === 'azure' ? 'Azure' : 'OpenAI';
82 }
83
84 private function is_o1_model( $model ) {
85 $modelDef = $this->retrieve_model_info( $model );
86 return !empty( $modelDef['tags'] ) && in_array( 'o1-model', $modelDef['tags'] );
87 }
88
89 private function is_gpt5_model( $model ) {
90 // Check if the model is a GPT-5 variant
91 return strpos( $model, 'gpt-5' ) === 0;
92 }
93
94 private function requires_developer_roles( $model ) {
95 return false;
96 }
97
98 private function is_realtime_model( $model ) {
99 $modelDef = $this->retrieve_model_info( $model );
100 return !empty( $modelDef['family'] ) && $modelDef['family'] === 'realtime';
101 }
102
103 protected function build_messages( $query ) {
104 $messages = [];
105
106 // First, we need to add the first message (the instructions).
107 if ( !empty( $query->instructions ) ) {
108 //if ( !$this->is_o1_model( $query->model ) ) {
109 $messages[] = [ 'role' => 'system', 'content' => $query->instructions ];
110 //}
111 }
112
113 // Then, if any, we need to add the 'messages', they are already formatted.
114 foreach ( $query->messages as $message ) {
115 $messages[] = $message;
116 }
117
118 // If there is a context, we need to add it with proper framing.
119 if ( !empty( $query->context ) ) {
120 $framedContext = $this->core->frame_context( $query->context );
121 $messages[] = [ 'role' => 'system', 'content' => $framedContext ];
122 }
123
124 // Finally, we need to add the message, but if there is an image, we need to add it as a system message.
125 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
126 if ( !empty( $attachments ) ) {
127 // The message can be empty when the user only sends files; in that case the
128 // text part is skipped entirely, as some providers reject empty text parts.
129 $content = [];
130 $message = $query->get_message();
131 if ( $message !== null && $message !== '' ) {
132 $content[] = [
133 'type' => 'text',
134 'text' => $message
135 ];
136 }
137
138 // Handle all attachments (unified approach)
139 foreach ( $attachments as $file ) {
140 // Check file type BEFORE trying to access file data
141 // Images can be loaded via URL or base64, but PDFs might use OpenAI file_id references
142 $mimeType = $file->get_mimeType() ?? '';
143 $isImage = strpos( $mimeType, 'image/' ) === 0;
144
145 if ( $isImage ) {
146 $finalUrl = null;
147 if ( $query->image_remote_upload === 'url' ) {
148 $finalUrl = $file->get_url();
149 }
150 else {
151 $finalUrl = $file->get_inline_base64_url();
152 }
153
154 $content[] = [
155 'type' => 'image_url',
156 'image_url' => [
157 'url' => $finalUrl
158 ]
159 ];
160 }
161 // Skip non-images for Chat Completions API
162 }
163
164 // If every attachment was skipped and there was no message, fall back to an
165 // empty text part rather than sending an invalid empty content array.
166 if ( empty( $content ) ) {
167 $content[] = [ 'type' => 'text', 'text' => '' ];
168 }
169
170 $messages[] = [
171 'role' => 'user',
172 'content' => $content
173 ];
174 }
175 else {
176 $messages[] = [ 'role' => 'user', 'content' => $query->get_message() ];
177 }
178
179 // We need to convert all the 'system' role into 'developer' role.
180 if ( $this->requires_developer_roles( $query->model ) ) {
181 foreach ( $messages as &$message ) {
182 if ( $message['role'] === 'system' ) {
183 $message['role'] = 'developer';
184 }
185 }
186 }
187 // But otherwise, if it's o1, we need to remove the message which are 'system'
188 else if ( $this->is_o1_model( $query->model ) ) {
189 $hasChanges = false;
190 foreach ( $messages as $index => $message ) {
191 if ( $message['role'] === 'system' ) {
192 unset( $messages[$index] );
193 $hasChanges = true;
194 }
195 }
196 if ( $hasChanges ) {
197 $messages = array_values( $messages );
198 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support System nor Developer messages. They were removed.' );
199 }
200 }
201
202 return $messages;
203 }
204
205 /**
206 * Convert functions to Responses API tools format.
207 * Shared by OpenAI and OpenRouter engines.
208 */
209 protected function build_responses_tools( $functions ) {
210 $tools = [];
211 foreach ( $functions as $function ) {
212 $functionData = $function->serializeForOpenAI();
213 if ( !isset( $functionData['name'] ) || empty( $functionData['name'] ) ) {
214 Meow_MWAI_Logging::warn( 'Function missing required name field' );
215 continue;
216 }
217 $parameters = $functionData['parameters'] ?? null;
218 if ( !$parameters ) {
219 $parameters = [
220 'type' => 'object',
221 'properties' => new stdClass(),
222 'required' => []
223 ];
224 }
225 else {
226 if ( isset( $parameters['properties'] ) &&
227 is_array( $parameters['properties'] ) &&
228 empty( $parameters['properties'] ) ) {
229 $parameters['properties'] = new stdClass();
230 }
231 }
232 $tool = [
233 'type' => 'function',
234 'name' => $functionData['name'],
235 'description' => $functionData['description'] ?? '',
236 'parameters' => $parameters,
237 'strict' => false
238 ];
239 $tools[] = $tool;
240 }
241 return $tools;
242 }
243
244 /**
245 * Built-in tools this engine can honor on the Chat Completions path. Empty here: the
246 * OpenAI engine only honors them on its Responses path, which never reaches this builder.
247 * Subclasses that map a tool onto something native (OpenRouter's web plugin) override it.
248 */
249 protected function supported_builtin_tools(): array {
250 return [];
251 }
252
253 /**
254 * The OpenAI-style built-in tools (web_search, image_generation, code_interpreter) only
255 * exist on the Responses API. A Chat Completions body has nowhere to put them, so any that
256 * reach this builder are dropped. Dropping them silently produced reports of "Web Search
257 * is enabled but the model says it cannot browse" with nothing in the logs, because the
258 * model never even saw the tool. Say what happened and why, once per request.
259 *
260 * Logged as an error on purpose: warn() only reaches the plugin's own log when Dev Tools
261 * and Server Debug Mode are both on, which nobody has on by default. error() always
262 * reaches PHP's error_log, which is the first place anyone looks.
263 */
264 protected function warn_dropped_builtin_tools( $query ): void {
265 if ( empty( $query->tools ) || !is_array( $query->tools ) ) {
266 return;
267 }
268 $builtin = [ 'web_search', 'image_generation', 'code_interpreter' ];
269 $dropped = array_values( array_diff( array_intersect( $query->tools, $builtin ), $this->supported_builtin_tools() ) );
270 if ( empty( $dropped ) ) {
271 return;
272 }
273 Meow_MWAI_Logging::error( sprintf(
274 'Built-in tool(s) %s were enabled for model "%s", but this request went through the Chat Completions API, which cannot carry them, so they were dropped. They need the native OpenAI environment with a Responses-capable model whose id matches a built-in entry exactly (for example gpt-5.6-luna), or OpenRouter for web search.',
275 implode( ', ', $dropped ),
276 $query->model
277 ) );
278 }
279
280 protected function build_body( $query, $streamCallback = null, $extra = null ) {
281 if ( $query instanceof Meow_MWAI_Query_Text ) {
282 $this->warn_dropped_builtin_tools( $query );
283 $body = [
284 'model' => $query->model,
285 'stream' => !is_null( $streamCallback ),
286 ];
287
288 if ( !empty( $query->maxTokens ) ) {
289 // max_tokens has been deprecated in favor of max_completion_tokens in 2025.
290 $body['max_completion_tokens'] = $query->maxTokens;
291 }
292
293 // Null means "not set"; 0 is a valid temperature and must still be sent.
294 if ( isset( $query->temperature ) ) {
295 // Models that reject temperature say so with the 'no-temperature' tag. The o1 and
296 // GPT-5 name checks predate the tag and stay for the entries that do not carry it;
297 // a name check alone misses every future family (GPT-6 rejects it too).
298 $modelInfo = $this->retrieve_model_info( $query->model );
299 $noTemperature = !empty( $modelInfo['tags'] ) &&
300 in_array( 'no-temperature', $modelInfo['tags'], true );
301 if ( !$noTemperature && !$this->is_o1_model( $query->model ) && !$this->is_gpt5_model( $query->model ) ) {
302 $body['temperature'] = $query->temperature;
303 }
304 else if ( !$noTemperature && $this->is_o1_model( $query->model ) ) {
305 // o1 models require temperature to be 1 if specified
306 $body['temperature'] = 1;
307 }
308 // For GPT-5, GPT-6 and other no-temperature models, we simply don't include it.
309 }
310
311 if ( !empty( $query->maxResults ) ) {
312 $body['n'] = $query->maxResults;
313 }
314
315 if ( !empty( $query->stop ) ) {
316 $body['stop'] = $query->stop;
317 }
318
319 if ( !empty( $query->responseFormat ) ) {
320 if ( $query->responseFormat === 'json' ) {
321 $body['response_format'] = [ 'type' => 'json_object' ];
322 }
323 }
324
325 // Usage Data (only for OpenAI)
326 // https://cookbook.openai.com/examples/how_to_stream_completions#4-how-to-get-token-usage-data-for-streamed-chat-completion-response
327 if ( !empty( $streamCallback ) && $this->envType === 'openai' ) {
328 $body['stream_options'] = [
329 'include_usage' => true,
330 ];
331 }
332
333 if ( !empty( $query->functions ) ) {
334 $model = $this->retrieve_model_info( $query->model );
335 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
336 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support Function Calling.' );
337 }
338 else if ( strpos( $query->model, 'ft:' ) === 0 ) {
339 Meow_MWAI_Logging::warn( 'OpenAI doesn\'t support Function Calling with fine-tuned models yet.' );
340 }
341 else {
342 $body['tools'] = [];
343 // Dynamic function: they will interactively enhance the completion (tools).
344 foreach ( $query->functions as $function ) {
345 $body['tools'][] = [
346 'type' => 'function',
347 'function' => $function->serializeForOpenAI()
348 ];
349 }
350 // Static functions: they will be executed at the end of the completion.
351 //$body['function_call'] = $query->functionCall;
352 }
353 }
354 $body['messages'] = $this->build_messages( $query );
355
356 // Add the feedback if it's a feedback query.
357 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
358 if ( !empty( $query->blocks ) ) {
359 foreach ( $query->blocks as $feedback_block ) {
360 $body['messages'][] = $feedback_block['rawMessage'];
361 foreach ( $feedback_block['feedbacks'] as $feedback ) {
362 // Ensure content is a string for the API
363 $content = $feedback['reply']['value'];
364 if ( !is_string( $content ) ) {
365 $content = json_encode( $content );
366 }
367
368 $body['messages'][] = [
369 'tool_call_id' => $feedback['request']['toolId'],
370 'role' => 'tool',
371 'name' => $feedback['request']['name'],
372 'content' => $content
373 ];
374
375 // Note: Function result events are now emitted centrally in core.php
376 // when the function is actually executed
377 }
378 }
379 }
380 return $body;
381 }
382
383 return $body;
384 }
385 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
386 // Determine filename and MIME type
387 $filename = 'audio.mp3'; // default
388 $mimeType = 'audio/mpeg'; // default
389 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
390 if ( !empty( $attachments ) ) {
391 $firstAttachment = $attachments[0];
392 if ( method_exists( $firstAttachment, 'get_filename' ) ) {
393 $filename = $firstAttachment->get_filename();
394 }
395 if ( method_exists( $firstAttachment, 'get_mimeType' ) ) {
396 $detectedMime = $firstAttachment->get_mimeType();
397 if ( !empty( $detectedMime ) ) {
398 $mimeType = $detectedMime;
399 }
400 }
401 }
402 else if ( !empty( $query->url ) ) {
403 $filename = basename( $query->url );
404 }
405
406 // Guard against malformed filenames coming from form prompts/placeholders
407 if ( empty( $filename ) ) {
408 $filename = 'audio.mp3';
409 }
410 $filename = preg_replace( "/[\r\n]+/", '', $filename );
411 $mimeType = preg_replace( "/[\r\n]+/", '', $mimeType );
412 if ( empty( $mimeType ) ) {
413 $mimeType = 'audio/mpeg';
414 }
415
416 $body = [
417 'prompt' => $query->message,
418 'model' => $query->model,
419 'response_format' => 'text',
420 'file' => $filename,
421 'data' => $extra,
422 'mime' => $mimeType
423 ];
424 return $body;
425 }
426 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
427 $body = [ 'input' => $query->message, 'model' => $query->model ];
428 if ( $this->envType === 'azure' ) {
429 $body = [ 'input' => $query->message ];
430 }
431 // Dimensions are only supported by v3 models
432 if ( !empty( $query->dimensions ) && strpos( $query->model, 'ada-002' ) === false ) {
433 $body['dimensions'] = $query->dimensions;
434 }
435 return $body;
436 }
437 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
438 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
439 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
440 $firstFile = !empty( $attachments ) ? $attachments[0] : null;
441 $filename = $firstFile ? $firstFile->get_filename() : '';
442 $mimeType = $firstFile ? $firstFile->get_mimeType() : null;
443 $body = [
444 'prompt' => $query->message,
445 'n' => $query->maxResults,
446 'size' => $resolution,
447 'image' => $filename,
448 'data' => $extra
449 ];
450 if ( !empty( $mimeType ) ) {
451 $body['mime'] = $mimeType;
452 }
453
454 // Add mask if provided
455 if ( !empty( $query->mask ) ) {
456 $maskData = $query->mask->get_data();
457 $maskFilename = 'mask.png';
458 $maskMimeType = $query->mask->get_mimeType();
459
460 $body['mask'] = $maskFilename;
461 $body['mask_data'] = $maskData;
462 if ( !empty( $maskMimeType ) ) {
463 $body['mask_mime'] = $maskMimeType;
464 }
465 }
466 // 'response_format' => 'b64_json',
467 if ( !empty( $query->model ) ) {
468 $body['model'] = $query->model;
469 }
470 return $body;
471 }
472 else if ( $query instanceof Meow_MWAI_Query_Image ) {
473 $model = $query->model;
474 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
475 $body = [
476 'model' => $model,
477 'prompt' => $query->message,
478 'n' => $query->maxResults,
479 'size' => $resolution,
480 'moderation' => 'low',
481 ];
482 // Only forward 'quality' when the caller (or final_checks) resolved one.
483 // Letting the API default decide ('auto' for gpt-image-*) keeps costs predictable
484 // and avoids forcing 'high' on every request.
485 if ( !empty( $query->quality ) ) {
486 $body['quality'] = $query->quality;
487 }
488 return $body;
489 }
490 }
491
492 protected function build_url( $query, $endpoint = null ) {
493 $url = '';
494 $env = $this->env;
495 // This endpoint is basically OpenAI or Azure, but in the case this class
496 // is overriden, we can pass the endpoint directly (for OpenRouter or HuggingFace, for example).
497 if ( empty( $endpoint ) ) {
498 if ( $this->envType === 'openai' ) {
499 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
500 $this->organizationId = isset( $env['organizationId'] ) ? $env['organizationId'] : null;
501 }
502 else if ( $this->envType === 'azure' ) {
503 $endpoint = isset( $env['endpoint'] ) ? $env['endpoint'] : null;
504 // Ensure the endpoint has the proper protocol if it's just a domain
505 if ( $endpoint && strpos( $endpoint, 'http' ) !== 0 ) {
506 $endpoint = 'https://' . $endpoint;
507 }
508 }
509 else {
510 if ( empty( $this->envType ) ) {
511 throw new Exception( 'Endpoint is not defined, and this envType is not known.' );
512 }
513 throw new Exception( 'Endpoint is not defined, and this envType is not known: ' . $this->envType );
514 }
515 }
516 // Add the base API to the URL
517 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
518 if ( $this->envType === 'azure' ) {
519 $deployment_name = $this->get_azure_deployment_name( $query->model );
520 $url = trailingslashit( $endpoint ) . 'openai/deployments/' . $deployment_name;
521 $url .= '/chat/completions?' . $this->azureApiVersion;
522 }
523 else {
524 $url .= trailingslashit( $endpoint ) . 'chat/completions';
525 }
526 return $url;
527 }
528 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
529 $modeEndpoint = $query->feature === 'translation' ? 'translations' : 'transcriptions';
530 $url .= trailingslashit( $endpoint ) . 'audio/' . $modeEndpoint;
531 return $url;
532 }
533 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
534 $url .= trailingslashit( $endpoint ) . 'embeddings';
535 if ( $this->envType === 'azure' ) {
536 $deployment_name = $this->get_azure_deployment_name( $query->model );
537 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
538 $deployment_name . '/embeddings?' . $this->azureApiVersion;
539 }
540 return $url;
541 }
542 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
543 $url .= trailingslashit( $endpoint ) . 'images/edits';
544 if ( $this->envType === 'azure' ) {
545 $deployment_name = $this->get_azure_deployment_name( $query->model );
546 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
547 $deployment_name . '/images/edits?' . $this->azureApiVersion;
548 }
549 return $url;
550 }
551 else if ( $query instanceof Meow_MWAI_Query_Image ) {
552 $url .= trailingslashit( $endpoint ) . 'images/generations';
553 if ( $this->envType === 'azure' ) {
554 $deployment_name = $this->get_azure_deployment_name( $query->model );
555 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
556 $deployment_name . '/images/generations?' . $this->azureApiVersion;
557 }
558 return $url;
559 }
560 throw new Exception( 'The query is not supported by build_url().' );
561 }
562
563 protected function build_headers( $query ) {
564 if ( $query->apiKey ) {
565 $this->apiKey = $query->apiKey;
566 }
567 if ( empty( $this->apiKey ) ) {
568 throw new Exception( 'No API Key provided. Please visit the Settings. (ChatML Engine)' );
569 }
570 $headers = [
571 'Content-Type' => 'application/json',
572 'Authorization' => 'Bearer ' . $this->apiKey,
573 ];
574 if ( $this->organizationId ) {
575 $headers['OpenAI-Organization'] = $this->organizationId;
576 }
577 if ( $this->envType === 'azure' ) {
578 $headers = [ 'Content-Type' => 'application/json', 'api-key' => $this->apiKey ];
579 }
580 return $headers;
581 }
582
583 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
584 $body = null;
585 if ( !empty( $forms ) ) {
586 $boundary = wp_generate_password( 24, false );
587 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
588 $body = $this->build_form_body( $forms, $boundary );
589 }
590 else if ( !empty( $json ) ) {
591 $body = $this->safe_json_encode( $json, 'request body' );
592 }
593 $options = [
594 'headers' => $headers,
595 'method' => $method,
596 'timeout' => MWAI_TIMEOUT,
597 'body' => $body,
598 'sslverify' => MWAI_SSL_VERIFY
599 ];
600 return $options;
601 }
602 // object: "thread.message.delta"
603 protected function stream_data_handler( $json ) {
604 $content = null;
605 $handledCondition = false; // Track if we entered any condition
606
607 // Get additional data from the JSON
608 if ( isset( $json['model'] ) ) {
609 $this->inModel = $json['model'];
610 }
611 if ( isset( $json['id'] ) ) {
612 $this->inId = $json['id'];
613
614 // Send start event if debug mode is enabled and not already sent
615 if ( $this->currentDebugMode && $this->streamCallback && !$this->streamStartEmitted ) {
616 $this->streamStartEmitted = true;
617 $event = Meow_MWAI_Event::status( 'Starting stream...' )
618 ->set_metadata( 'model', $this->inModel )
619 ->set_metadata( 'id', $this->inId );
620 call_user_func( $this->streamCallback, $event );
621 }
622 }
623
624 // Providers send no-op frames (keepalive, ping) purely to hold the SSE connection
625 // open through proxies. They carry no content and there is nothing to handle, so
626 // return before the "unhandled streaming JSON structure" log at the end of this
627 // method reports them as a parsing problem. They are expected traffic, not errors.
628 $eventType = $json['type'] ?? null;
629 if ( in_array( $eventType, [ 'keepalive', 'ping' ], true ) ) {
630 return null;
631 }
632
633 $object = $json['object'] ?? null;
634
635 if ( $object === 'thread.run' ) {
636 $handledCondition = true;
637 $this->inThreadId = $json['thread_id'];
638 if ( $json['status'] === 'failed' ) {
639 $error = $json['last_error']['message'] ?? 'The run failed.';
640 throw new Exception( $error );
641 }
642 }
643 else if ( $object === 'thread.run.step.delta' ) {
644 $handledCondition = true;
645 if ( $json['delta']['step_details']['type'] === 'tool_calls' ) {
646 foreach ( $json['delta']['step_details']['tool_calls'] as $tool_call ) {
647 $index = $tool_call['index'] ?? null;
648 // unset (not "= null") to break the reference from the previous loop
649 // iteration. Assigning null would null out the tool call we just stored
650 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
651 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
652 unset( $currentStreamToolCall );
653 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
654 $currentStreamToolCall = &$this->streamToolCalls[$index];
655 }
656 else {
657 $this->streamToolCalls[] = [
658 'id' => null,
659 'type' => null,
660 'function' => [ 'name' => '', 'arguments' => '' ],
661 'code_interpreter' => [ 'input' => '', 'outputs' => [] ],
662 ];
663 end( $this->streamToolCalls );
664 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
665
666 // Send tool call initiated event
667 if ( $this->currentDebugMode && $this->streamCallback ) {
668 $event = Meow_MWAI_Event::status( 'Initiating tool call...' );
669 call_user_func( $this->streamCallback, $event );
670 }
671 }
672 if ( !empty( $tool_call['id'] ) ) {
673 $currentStreamToolCall['id'] = $tool_call['id'];
674 }
675 if ( !empty( $tool_call['type'] ) ) {
676 $currentStreamToolCall['type'] = $tool_call['type'];
677 }
678 if ( isset( $tool_call['function'] ) ) {
679 $function = $tool_call['function'];
680 if ( isset( $function['name'] ) ) {
681 $currentStreamToolCall['function']['name'] .= $function['name'];
682 }
683 if ( isset( $function['arguments'] ) ) {
684 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
685 }
686 }
687 if ( isset( $tool_call['code_interpreter'] ) ) {
688 $code_interpreter = $tool_call['code_interpreter'];
689 if ( isset( $code_interpreter['input'] ) ) {
690 $currentStreamToolCall['code_interpreter']['input'] .= $code_interpreter['input'];
691 }
692 if ( isset( $code_interpreter['outputs'] ) ) {
693 $currentStreamToolCall['code_interpreter']['outputs'] = $code_interpreter['outputs'];
694 }
695 }
696 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
697 }
698 }
699 }
700 else if ( $object === 'thread.message.delta' ) {
701 $handledCondition = true;
702 $delta = $json['delta']['content'][0] ?? null;
703 if ( $delta ) {
704 switch ( $delta['type'] ?? null ) {
705 case 'text':
706 if ( !empty( $delta['value'] ) && is_string( $delta['value'] ) ) {
707 $content = $delta['value'];
708 }
709 else if ( !empty( $delta['text'] ) && is_string( $delta['text'] ) ) {
710 $content = $delta['text'];
711 }
712 else if ( !empty( $delta['text'] ) && is_array( $delta['text'] ) ) {
713 $text = $delta['text'];
714 if ( !empty( $text['annotations'] ) ) {
715 $this->streamAnnotations = array_merge( $this->streamAnnotations, $text['annotations'] );
716 }
717 if ( isset( $text['value'] ) ) {
718 $content = $text['value'];
719 }
720 }
721 else {
722 error_log( 'AI Engine: Unknown text format: ' . json_encode( $delta ) );
723 }
724 break;
725 case 'image':
726 $content = $delta['url'];
727 break;
728 case 'image_file':
729 $fileId = $delta['image_file']['file_id'];
730 $content = '<!-- IMG #' . $fileId . ' -->';
731 $this->streamImageIds[] = $fileId;
732 break;
733 case 'function_call':
734 // TODO: Re-evaluate after 2027-01-26. This is the deprecated OpenAI
735 // "function_call" streaming format, superseded by "tool_calls". It decodes
736 // each delta and replaces (instead of concatenating like the tool_calls
737 // path), so a provider that streams fragmented args here would lose them.
738 // Confirm no supported provider still uses this format, then remove it.
739 if ( empty( $this->streamFunctionCall ) ) {
740 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
741 }
742 $this->streamFunctionCall['name'] = $delta['function_call']['name'] ?? $this->streamFunctionCall['name'];
743 if ( isset( $delta['function_call']['arguments'] ) ) {
744 $args = json_decode( $delta['function_call']['arguments'], true );
745 $this->streamFunctionCall['arguments'] = $args ?? [];
746 }
747 break;
748 case 'tool_call':
749 $tool_call = $delta['tool_call'];
750 $index = $tool_call['index'] ?? null;
751 // unset (not "= null") to break the reference from the previous loop
752 // iteration. Assigning null would null out the tool call we just stored
753 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
754 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
755 unset( $currentStreamToolCall );
756 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
757 $currentStreamToolCall = &$this->streamToolCalls[$index];
758 }
759 else {
760 $this->streamToolCalls[] = [
761 'id' => null,
762 'type' => null,
763 'function' => [ 'name' => '', 'arguments' => '' ]
764 ];
765 end( $this->streamToolCalls );
766 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
767 }
768 break;
769 }
770 }
771 }
772 else if ( $object === 'thread.run.step' ) {
773 $handledCondition = true;
774 //$type = $json['step'];
775 // Could be tool_calls, means an OpenAI Assistant is doing something.
776 }
777 else {
778 if ( isset( $json['choices'][0]['text'] ) ) {
779 $handledCondition = true;
780 $content = $json['choices'][0]['text'];
781 }
782 else if ( isset( $json['choices'][0]['delta']['content'] ) ) {
783 $handledCondition = true;
784 $content = $json['choices'][0]['delta']['content'];
785 }
786 else if ( isset( $json['choices'][0]['delta']['function_call'] ) ) {
787 // TODO: Re-evaluate after 2027-01-26. Deprecated "function_call" stream path
788 // (same deprecation as the object-based case above); remove once no provider uses it.
789 $handledCondition = true;
790 if ( empty( $this->streamFunctionCall ) ) {
791 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
792 }
793 $this->streamFunctionCall['name'] = $json['choices'][0]['delta']['function_call']['name'] ?? $this->streamFunctionCall['name'];
794 if ( isset( $json['choices'][0]['delta']['function_call']['arguments'] ) ) {
795 $args = json_decode( $json['choices'][0]['delta']['function_call']['arguments'], true );
796 $this->streamFunctionCall['arguments'] = $args ?? [];
797 }
798 }
799 else if ( isset( $json['choices'][0]['delta']['tool_calls'] ) ) {
800 $handledCondition = true;
801 // New schema detected – drop any half-built legacy call to prevent duplicates
802 $this->streamFunctionCall = null;
803
804 foreach ( $json['choices'][0]['delta']['tool_calls'] as $tool_call ) {
805 $index = $tool_call['index'] ?? null;
806 // unset (not "= null") to break the reference from the previous loop
807 // iteration. Assigning null would null out the tool call we just stored
808 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
809 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
810 unset( $currentStreamToolCall );
811 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
812 $currentStreamToolCall = &$this->streamToolCalls[$index];
813 }
814 else {
815 $this->streamToolCalls[] = [
816 'id' => null,
817 'type' => null,
818 'function' => [ 'name' => '', 'arguments' => '' ]
819 ];
820 end( $this->streamToolCalls );
821 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
822 }
823 if ( !empty( $tool_call['id'] ) ) {
824 $currentStreamToolCall['id'] = $tool_call['id'];
825 }
826 if ( !empty( $tool_call['type'] ) ) {
827 $currentStreamToolCall['type'] = $tool_call['type'];
828 }
829 if ( isset( $tool_call['function'] ) ) {
830 $function = $tool_call['function'];
831 if ( isset( $function['name'] ) ) {
832 $currentStreamToolCall['function']['name'] .= $function['name'];
833 }
834 if ( isset( $function['arguments'] ) ) {
835 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
836 }
837 }
838 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
839 }
840 }
841 else if ( isset( $json['choices'][0]['delta']['role'] ) ) {
842 $handledCondition = true;
843 $this->streamLastMessage = [
844 'role' => $json['choices'][0]['delta']['role'],
845 'content' => null
846 ];
847 }
848
849 // Handle thinking/reasoning content (from Ollama and potentially other models)
850 // This can appear alongside or instead of regular content
851 if ( isset( $json['choices'][0]['delta']['reasoning'] ) ) {
852 $handledCondition = true;
853 $thinking = $json['choices'][0]['delta']['reasoning'];
854 if ( !empty( $thinking ) ) {
855 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
856 }
857 }
858 // Also check for 'thinking' field (OpenAI's o1 models use this)
859 else if ( isset( $json['choices'][0]['delta']['thinking'] ) ) {
860 $handledCondition = true;
861 $thinking = $json['choices'][0]['delta']['thinking'];
862 if ( !empty( $thinking ) ) {
863 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
864 }
865 }
866 }
867
868 $usage = $json['usage'] ?? [];
869 if ( isset( $usage['prompt_tokens'], $usage['completion_tokens'] ) ) {
870 $this->streamInTokens = (int) $usage['prompt_tokens'];
871 $this->streamOutTokens = (int) $usage['completion_tokens'];
872
873 if ( isset( $usage['cost'] ) ) {
874 $this->streamCost = (float) $usage['cost'];
875 }
876 }
877
878 // If content is an array, let's try to convert it into a string. Normally, there would be a 'value' key.
879 if ( is_array( $content ) ) {
880 if ( isset( $content['value'] ) ) {
881 $content = $content['value'];
882 }
883 else {
884 throw new Exception( 'Could not read this: ' . json_encode( $content ) );
885 }
886 }
887
888 // Log unhandled JSON in dev mode
889 if ( !$handledCondition && $this->core->get_option( 'dev_mode' ) ) {
890 error_log( '[AI Engine] Unhandled streaming JSON structure: ' . json_encode( $json ) );
891 }
892
893 // Avoid some endings
894 $endings = [ '', '</s>' ];
895 if ( in_array( $content, $endings ) ) {
896 $content = null;
897 }
898
899 if ( $content !== '0' && empty( $content ) ) {
900 return null;
901 }
902 if ( is_string( $content ) ) {
903 $content = $this->filter_inline_thinking( $content );
904 if ( $content === '' ) {
905 return null;
906 }
907 }
908 return $content;
909 }
910
911 /**
912 * Qwen-style open models emit their reasoning INLINE in the content as
913 * <think>...</think>. Route it to streamThinking (with a single thinking
914 * event per block) so the visible reply only contains the actual answer.
915 */
916 protected function filter_inline_thinking( $content ) {
917 if ( $this->streamThinkTag === 0 &&
918 strpos( $content, '<think>' ) === false && strpos( $content, '</think>' ) === false ) {
919 return $content;
920 }
921 $out = '';
922 $thought = '';
923 $remaining = $content;
924 while ( $remaining !== '' ) {
925 if ( $this->streamThinkTag === 0 ) {
926 $pos = strpos( $remaining, '<think>' );
927 if ( $pos === false ) {
928 $out .= $remaining;
929 break;
930 }
931 $out .= substr( $remaining, 0, $pos );
932 $remaining = substr( $remaining, $pos + 7 );
933 $this->streamThinkTag = 1;
934 if ( $this->currentDebugMode && !empty( $this->streamCallback ) ) {
935 call_user_func( $this->streamCallback, Meow_MWAI_Event::thinking( 'Thinking...' ) );
936 }
937 }
938 else {
939 $pos = strpos( $remaining, '</think>' );
940 if ( $pos === false ) {
941 $thought .= $remaining;
942 break;
943 }
944 $thought .= substr( $remaining, 0, $pos );
945 // The answer usually follows the block after blank lines.
946 $remaining = ltrim( substr( $remaining, $pos + 8 ), "\n" );
947 $this->streamThinkTag = 0;
948 }
949 }
950 if ( $thought !== '' ) {
951 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thought;
952 }
953 return $out;
954 }
955
956 /**
957 * Same cleanup for non-streaming replies: closed blocks are removed, and a
958 * trailing unclosed block (the model spent its whole budget thinking) too.
959 */
960 public static function strip_inline_thinking( $text ) {
961 if ( !is_string( $text ) || strpos( $text, '<think>' ) === false ) {
962 return $text;
963 }
964 $text = preg_replace( '/<think>.*?<\/think>/s', '', $text );
965 $text = preg_replace( '/<think>.*$/s', '', $text );
966 return ltrim( $text );
967 }
968
969 public function run( $query, $streamCallback = null, $maxDepth = 5 ) {
970 // Check if this is a realtime model being used with chat completions
971 if ( $this->is_realtime_model( $query->model ) ) {
972 throw new Exception(
973 'Realtime models (like ' . $query->model . ') are designed for voice/audio interactions and cannot be used with this API.'
974 );
975 }
976
977 return parent::run( $query, $streamCallback, $maxDepth );
978 }
979
980 public function run_query( $url, $options, $isStream = false ) {
981 try {
982 $options['stream'] = $isStream;
983 if ( $isStream ) {
984 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
985 }
986
987 // Check if queries debug is enabled
988 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
989
990 // Log the request if queries debug is enabled
991 if ( $queries_debug ) {
992 error_log( '[AI Engine Queries] --> Request to: ' . $url );
993
994 if ( isset( $options['body'] ) ) {
995 // This is the actual body being sent to the AI service
996 $body_log = is_string( $options['body'] ) ? $options['body'] : json_encode( $options['body'] );
997
998 // Pretty print JSON if possible
999 $decoded = json_decode( $body_log, true );
1000 if ( json_last_error() === JSON_ERROR_NONE ) {
1001 error_log( json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1002 }
1003 else {
1004 error_log( $body_log );
1005 }
1006 }
1007 }
1008
1009 $res = wp_remote_get( $url, $options );
1010
1011 if ( is_wp_error( $res ) ) {
1012 throw new Exception( $res->get_error_message() );
1013 }
1014
1015 $responseCode = wp_remote_retrieve_response_code( $res );
1016
1017 // For streaming requests the response body is written to a temp file, so
1018 // wp_remote_retrieve_body() comes back empty. Read it back here so provider
1019 // error details (e.g. a 422 from Mistral) aren't lost on streaming requests.
1020 $errorBody = wp_remote_retrieve_body( $res );
1021 if ( empty( $errorBody ) && $isStream && !empty( $options['filename'] ) && file_exists( $options['filename'] ) ) {
1022 $errorBody = file_get_contents( $options['filename'] );
1023 }
1024
1025 if ( $responseCode === 404 ) {
1026 // A 404 here is almost always an invalid model name for this provider
1027 // (the endpoint URL is correct), so surface the provider's own message.
1028 $detail = !empty( $errorBody ) ? ' Response: ' . $errorBody : '';
1029 throw new Exception( 'The API returned 404 for ' . $url . '. This usually means the model is not valid for this provider.' . $detail );
1030 }
1031 else if ( $responseCode === 400 ) {
1032 error_log( '[AI Engine] 400 Bad Request - Full response body: ' . $errorBody );
1033 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
1034 if ( empty( $message ) ) {
1035 $message = 'Bad Request';
1036 }
1037 throw new Exception( $message );
1038 }
1039 else if ( $responseCode >= 400 ) {
1040 // Any other 4xx/5xx (401, 403, 422, 429, 500, 502...). The body is thrown
1041 // raw on purpose: the callers run it through try_decode_error() to pull out
1042 // the provider's message, whatever its shape. Before this branch was generic,
1043 // a Mistral 403 (tier_not_allowed) fell through to the JSON path and only
1044 // ever surfaced as "Invalid response (no model information)".
1045 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
1046 if ( empty( $message ) ) {
1047 $message = 'HTTP ' . $responseCode;
1048 }
1049 throw new Exception( $message );
1050 }
1051
1052 if ( $isStream ) {
1053 return [ 'stream' => true ];
1054 }
1055
1056 $response = wp_remote_retrieve_body( $res );
1057 $headersRes = wp_remote_retrieve_headers( $res );
1058 $headers = $headersRes->getAll();
1059
1060 // Check if Content-Type is 'multipart/form-data' or 'text/plain'
1061 // If so, we don't need to decode the response
1062 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
1063 $resContentType = $normalizedHeaders['content-type'] ?? '';
1064 if ( strpos( $resContentType, 'multipart/form-data' ) !== false || strpos( $resContentType, 'text/plain' ) !== false ) {
1065 // Log the response if queries debug is enabled
1066 if ( $queries_debug && !$isStream ) {
1067 error_log( '[AI Engine Queries] Response Headers: ' . json_encode( $headers ) );
1068 error_log( '[AI Engine Queries] Response Body (raw): ' . substr( $response, 0, 1000 ) . '...' );
1069 }
1070 return [ 'stream' => false, 'headers' => $headers, 'data' => $response ];
1071 }
1072
1073 $data = json_decode( $response, true );
1074 $this->handle_response_errors( $data );
1075
1076 // Log the response if queries debug is enabled
1077 if ( $queries_debug && !$isStream ) {
1078 // Log the raw response as received from the AI service
1079 error_log( '[AI Engine Queries] <-- Response:' );
1080
1081 // Pretty print JSON if possible
1082 if ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) {
1083 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1084 }
1085 else {
1086 error_log( $response );
1087 }
1088 }
1089
1090 return [ 'headers' => $headers, 'data' => $data ];
1091 }
1092 catch ( Exception $e ) {
1093 $service = $this->get_service_name();
1094 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1095
1096 // Log error response if queries debug is enabled
1097 if ( $queries_debug ) {
1098 error_log( '[AI Engine Queries] Error occurred: ' . $e->getMessage() );
1099 }
1100
1101 throw $e;
1102 }
1103 finally {
1104 if ( $isStream && file_exists( $options['filename'] ) ) {
1105 unlink( $options['filename'] );
1106 }
1107 }
1108 }
1109
1110 private function get_audio( $url ) {
1111 require_once( ABSPATH . 'wp-admin/includes/media.php' );
1112
1113 // Validate URL scheme to prevent SSRF attacks
1114 $parts = wp_parse_url( $url );
1115 if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
1116 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
1117 }
1118
1119 // Use wp_safe_remote_get to block localhost/private IPs (SSRF protection)
1120 $response = wp_safe_remote_get( $url, [
1121 'timeout' => 60,
1122 'redirection' => 0 // Prevent redirect-based bypass
1123 ] );
1124 if ( is_wp_error( $response ) ) {
1125 throw new Exception( 'Failed to download audio: ' . $response->get_error_message() );
1126 }
1127 $audio_data = wp_remote_retrieve_body( $response );
1128 if ( empty( $audio_data ) ) {
1129 throw new Exception( 'Failed to download audio: empty response.' );
1130 }
1131
1132 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
1133 file_put_contents( $tmpFile, $audio_data );
1134 $length = null;
1135 $metadata = wp_read_audio_metadata( $tmpFile );
1136 if ( isset( $metadata['length'] ) ) {
1137 $length = $metadata['length'];
1138 }
1139 $data = file_get_contents( $tmpFile );
1140 unlink( $tmpFile );
1141 return [ 'data' => $data, 'length' => $length ];
1142 }
1143
1144 public function run_transcribe_query( $query ) {
1145 $audioData = null;
1146
1147 // Priority 1: Direct audio data
1148 if ( !empty( $query->audioData ) ) {
1149 $audioData = [
1150 'data' => $query->audioData,
1151 'length' => strlen( $query->audioData ) / 1024 // KB
1152 ];
1153 }
1154 // Priority 2: File path
1155 else if ( !empty( $query->path ) ) {
1156 // Last line of defense: REST callers never get to set this (the routes strip the
1157 // key), but simpleTranscribeAudio() is public PHP API, so a path can legitimately
1158 // arrive from site code. Confinement is not applied here on purpose: real callers
1159 // do transcribe files from outside the uploads directory. Stream wrappers are the
1160 // part that is never legitimate.
1161 $path = Meow_MWAI_Core::sanitize_file_path( $query->path );
1162 if ( !file_exists( $path ) ) {
1163 throw new Exception( 'Audio file not found: ' . $path );
1164 }
1165 if ( !is_readable( $path ) ) {
1166 throw new Exception( 'Audio file is not readable: ' . $path );
1167 }
1168 $audioData = [
1169 'data' => file_get_contents( $path ),
1170 'length' => filesize( $path ) / 1024 // KB
1171 ];
1172 }
1173 // Priority 3: Attached file object
1174 else if ( method_exists( $query, 'getAttachments' ) ) {
1175 $attachments = $query->getAttachments();
1176 if ( !empty( $attachments ) ) {
1177 $file = $attachments[0];
1178 $audioData = [
1179 'data' => $file->get_data(),
1180 'length' => strlen( $file->get_data() ) / 1024 // KB
1181 ];
1182 }
1183 }
1184 // Priority 4: URL (backward compatibility)
1185 else if ( !empty( $query->url ) ) {
1186 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1187 throw new Exception( 'Invalid URL for transcription.' );
1188 }
1189 $audioData = $this->get_audio( $query->url );
1190 }
1191 else {
1192 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1193 }
1194
1195 $body = $this->build_body( $query, null, $audioData['data'] );
1196 $url = $this->build_url( $query );
1197 $headers = $this->build_headers( $query );
1198 $options = $this->build_options( $headers, null, $body );
1199
1200 // Perform the request
1201 try {
1202 $res = $this->run_query( $url, $options );
1203 $data = $res['data'];
1204 if ( empty( $data ) ) {
1205 throw new Exception( 'Invalid data for transcription.' );
1206 }
1207 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1208 $reply = new Meow_MWAI_Reply( $query );
1209 $reply->set_usage( $usage );
1210 $reply->set_choices( $data );
1211 return $reply;
1212 }
1213 catch ( Exception $e ) {
1214 $service = $this->get_service_name();
1215 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1216 throw new Exception( "$service: " . $e->getMessage() );
1217 }
1218 }
1219
1220 public function run_embedding_query( $query ) {
1221 // Pre-validate token count against the embedding model's limit.
1222 // OpenAI embedding models (text-embedding-3-small/large, ada-002) all cap
1223 // at 8192 input tokens; sending a longer chunk yields an opaque
1224 // "Invalid 'input': maximum context length is 8192 tokens" error. Count
1225 // locally via tiktoken (bundled) so we fail with a clear message up front.
1226 $embedding_max_tokens = apply_filters( 'mwai_embedding_max_tokens', 8192, $query->model );
1227 if ( !empty( $query->message ) && $embedding_max_tokens > 0 ) {
1228 $token_count = Meow_MWAI_Core::estimate_tokens( $query->message, $query->model );
1229 if ( $token_count > $embedding_max_tokens ) {
1230 throw new Exception( sprintf(
1231 'Embedding input is %d tokens, exceeds the %d-token limit for %s. Split this entry into smaller chunks and try again.',
1232 $token_count,
1233 $embedding_max_tokens,
1234 $query->model
1235 ) );
1236 }
1237 }
1238
1239 $body = $this->build_body( $query );
1240 $url = $this->build_url( $query );
1241 $headers = $this->build_headers( $query );
1242 $options = $this->build_options( $headers, $body );
1243
1244 try {
1245 $res = $this->run_query( $url, $options );
1246 $data = $res['data'];
1247 if ( empty( $data ) || !isset( $data['data'] ) ) {
1248 throw new Exception( 'Invalid data for embedding.' );
1249 }
1250 $usage = $data['usage'];
1251 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1252 $reply = new Meow_MWAI_Reply( $query );
1253 $reply->set_usage( $usage );
1254 $reply->set_choices( $data['data'] );
1255 return $reply;
1256 }
1257 catch ( Exception $e ) {
1258 $message = $e->getMessage();
1259 $error = $this->try_decode_error( $message );
1260 if ( !is_null( $error ) ) {
1261 $message = $error;
1262 }
1263 $service = $this->get_service_name();
1264 Meow_MWAI_Logging::error( "$service: " . $message );
1265 throw new Exception( "$service: " . $message );
1266 }
1267 }
1268
1269 public function try_decode_error( $data ) {
1270 $json = json_decode( $data, true );
1271 return is_array( $json ) ? $this->extract_error_message( $json ) : null;
1272 }
1273
1274 /**
1275 * Pulls the human-readable message out of a decoded provider error body, or null
1276 * when the body does not look like an error. Two shapes exist in the wild:
1277 * - OpenAI-style, nested: {"error": {"message": "...", "type": "..."}} (or "error" as a string)
1278 * - Mistral-style, flat: {"object": "error", "message": "...", "type": "...", "code": "..."}
1279 */
1280 protected function extract_error_message( array $json ): ?string {
1281 if ( !empty( $json['error'] ) ) {
1282 $err = $json['error'];
1283 if ( is_array( $err ) ) {
1284 return isset( $err['message'] ) ? (string) $err['message'] : json_encode( $err );
1285 }
1286 return (string) $err;
1287 }
1288 $isFlatError = ( $json['object'] ?? '' ) === 'error' || ( isset( $json['type'] ) && !isset( $json['choices'] ) );
1289 if ( $isFlatError && !empty( $json['message'] ) && is_string( $json['message'] ) ) {
1290 return $json['message'];
1291 }
1292 return null;
1293 }
1294
1295 protected function finalize_choices( $choices, $responseData, $query ) {
1296 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1297 foreach ( $choices as &$choice ) {
1298 if ( isset( $choice['message'] ) ) {
1299 // If we have both tool_calls and function_call, remove function_call
1300 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1301 isset( $choice['message']['function_call'] ) ) {
1302 unset( $choice['message']['function_call'] );
1303 }
1304 }
1305 }
1306 return $choices;
1307 }
1308
1309 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1310 // Check if this is a GPT-5 model - they don't support Chat Completions API
1311 if ( $this->is_gpt5_model( $query->model ) ) {
1312 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1313 }
1314
1315 $isStreaming = !is_null( $streamCallback );
1316
1317 // Initialize debug mode
1318 $this->init_debug_mode( $query );
1319
1320 // Store the callback for event emission (both streaming and non-streaming debug mode)
1321 if ( !is_null( $streamCallback ) ) {
1322 $this->streamCallback = $streamCallback;
1323 }
1324
1325 if ( $isStreaming ) {
1326 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1327 }
1328
1329 $this->reset_stream();
1330 $body = $this->build_body( $query, $streamCallback );
1331 $url = $this->build_url( $query );
1332 $headers = $this->build_headers( $query );
1333 $options = $this->build_options( $headers, $body );
1334
1335 // Emit "Request sent" event for feedback queries
1336 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1337 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1338 $event = Meow_MWAI_Event::request_sent()
1339 ->set_metadata( 'is_feedback', true )
1340 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1341 call_user_func( $streamCallback, $event );
1342 }
1343
1344 try {
1345 $res = $this->run_query( $url, $options, $streamCallback );
1346 $reply = new Meow_MWAI_Reply( $query );
1347
1348 $returned_id = null;
1349 $returned_model = $this->inModel;
1350 $returned_in_tokens = null;
1351 $returned_out_tokens = null;
1352 $returned_price = null;
1353 $returned_choices = [];
1354
1355 // Streaming Mode
1356 if ( $isStreaming ) {
1357 if ( empty( $this->streamContent ) ) {
1358 $error = $this->try_decode_error( $this->streamBuffer );
1359 if ( !is_null( $error ) ) {
1360 throw new Exception( $error );
1361 }
1362 // A stream that ends without a single chunk (no id, no content, no
1363 // tool call, no thinking) is a provider failure, not a reply. OVH
1364 // answers 200 with a lone [DONE] when it rejects a parameter, which
1365 // used to be saved as a silent empty message.
1366 if ( empty( $this->streamToolCalls ) && empty( $this->streamFunctionCall ) &&
1367 empty( $this->streamThinking ) && is_null( $this->inId ) ) {
1368 throw new Exception( 'The AI service closed the stream without sending a reply. A parameter (often Max Tokens) may exceed what this model supports.' );
1369 }
1370 }
1371 $returned_id = $this->inId;
1372 $returned_model = $this->inModel ? $this->inModel : $query->model;
1373
1374 // Use regular content if available, otherwise fall back to thinking/reasoning
1375 $finalContent = $this->streamContent;
1376 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1377 // Use thinking content as fallback when there's no regular content
1378 // This happens with Ollama when it returns only reasoning/thinking
1379 // Wrap in asterisks to show as italics in markdown
1380 $finalContent = '*' . $this->streamThinking . '*';
1381
1382 // Log this for debugging
1383 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1384 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1385 }
1386 }
1387
1388 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1389 // Prefer tool_calls; fall back to legacy only if necessary
1390 if ( !empty( $this->streamToolCalls ) ) {
1391 $message['tool_calls'] = $this->streamToolCalls;
1392 }
1393 elseif ( !empty( $this->streamFunctionCall ) ) {
1394 $message['function_call'] = $this->streamFunctionCall;
1395 }
1396
1397 // Optionally include thinking as metadata if both content and thinking exist
1398 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1399 $message['thinking'] = $this->streamThinking;
1400 }
1401 if ( !is_null( $this->streamInTokens ) ) {
1402 $returned_in_tokens = $this->streamInTokens;
1403 }
1404 if ( !is_null( $this->streamOutTokens ) ) {
1405 $returned_out_tokens = $this->streamOutTokens;
1406 }
1407 if ( !is_null( $this->streamCost ) ) {
1408 $returned_price = $this->streamCost;
1409 }
1410 $returned_choices = [ [ 'message' => $message ] ];
1411 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1412
1413 // Log streaming response data if queries debug is enabled
1414 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1415 if ( $queries_debug ) {
1416 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1417 $streaming_data = [
1418 'id' => $returned_id,
1419 'model' => $returned_model,
1420 'content_length' => strlen( $this->streamContent ),
1421 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1422 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1423 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1424 'usage' => [
1425 'input_tokens' => $returned_in_tokens,
1426 'output_tokens' => $returned_out_tokens,
1427 'cost' => $returned_price
1428 ]
1429 ];
1430
1431 // Log tool calls details if present
1432 if ( !empty( $this->streamToolCalls ) ) {
1433 $streaming_data['tool_calls_details'] = [];
1434 foreach ( $this->streamToolCalls as $tool_call ) {
1435 $streaming_data['tool_calls_details'][] = [
1436 'id' => $tool_call['id'] ?? 'unknown',
1437 'name' => $tool_call['function']['name'] ?? 'unknown',
1438 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1439 ];
1440 }
1441 }
1442
1443 // Log function call if present
1444 if ( !empty( $this->streamFunctionCall ) ) {
1445 $streaming_data['function_call'] = [
1446 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1447 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1448 ];
1449 }
1450
1451 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1452 }
1453 }
1454 // Standard Mode
1455 else {
1456 $data = $res['data'];
1457 if ( empty( $data ) ) {
1458 throw new Exception( 'No content received (res is null).' );
1459 }
1460
1461 // Comprehensive logging for non-streaming mode - capture FULL response
1462 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1463 if ( $queries_debug ) {
1464 error_log( '[AI Engine Queries] ========================================' );
1465 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1466 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1467 error_log( '[AI Engine Queries] ========================================' );
1468
1469 // Look specifically for container_id
1470 $this->search_for_container_id_recursive( $data, '' );
1471 }
1472
1473 if ( !$data['model'] ) {
1474 $service = $this->get_service_name();
1475 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1476 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1477 throw new Exception( 'Invalid response (no model information).' );
1478 }
1479 $returned_id = $data['id'];
1480 $returned_model = $data['model'];
1481 $usage = $data['usage'] ?? [];
1482 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1483 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1484 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1485 $returned_choices = $data['choices'];
1486 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1487
1488 // Strip inline <think> reasoning (Qwen-style models) from the reply.
1489 foreach ( $returned_choices as &$choice ) {
1490 if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) ) {
1491 $choice['message']['content'] = self::strip_inline_thinking( $choice['message']['content'] );
1492 }
1493 }
1494 unset( $choice );
1495 }
1496
1497 // Set the results.
1498 $reply->set_choices( $returned_choices );
1499 if ( !empty( $returned_id ) ) {
1500 $reply->set_id( $returned_id );
1501 }
1502 if ( !empty( $returned_id ) ) {
1503 $reply->set_id( $returned_id );
1504 }
1505
1506 // Handle tokens.
1507 $this->handle_tokens_usage(
1508 $reply,
1509 $query,
1510 $returned_model,
1511 $returned_in_tokens,
1512 $returned_out_tokens,
1513 $returned_price
1514 );
1515
1516 return $reply;
1517 }
1518 catch ( Exception $e ) {
1519 $service = $this->get_service_name();
1520 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1521 $message = "$service: " . $e->getMessage();
1522 throw new Exception( $message );
1523 }
1524 finally {
1525 if ( !is_null( $streamCallback ) ) {
1526 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1527 }
1528 }
1529 }
1530
1531 public function handle_tokens_usage(
1532 $reply,
1533 $query,
1534 $returned_model,
1535 $returned_in_tokens,
1536 $returned_out_tokens,
1537 $returned_price = null
1538 ) {
1539 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1540 $reply->get_in_tokens( $query );
1541 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1542 $reply->get_out_tokens();
1543 $returned_price = !is_null( $returned_price ) ? $returned_price :
1544 $reply->get_price();
1545 $usage = $this->core->record_tokens_usage(
1546 $returned_model,
1547 $returned_in_tokens,
1548 $returned_out_tokens,
1549 $returned_price
1550 );
1551 $reply->set_usage( $usage );
1552
1553 // Store the actual model returned by the API
1554 $reply->set_model( $returned_model );
1555
1556 // Set default accuracy to 'estimated' for engines that don't override
1557 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1558 $reply->set_usage_accuracy( 'estimated' );
1559 }
1560
1561 // Request to the OpenAI Images API (gpt-image models, token-based pricing)
1562 public function run_image_query( $query, $streamCallback = null ) {
1563 $body = $this->build_body( $query );
1564 $url = $this->build_url( $query );
1565 $headers = $this->build_headers( $query );
1566 $options = $this->build_options( $headers, $body );
1567
1568 try {
1569 $res = $this->run_query( $url, $options );
1570 $data = $res['data'];
1571 $choices = $data['data'];
1572 $reply = new Meow_MWAI_Reply( $query );
1573 $model = $query->model;
1574
1575 // gpt-image models always return usage with input/output tokens
1576 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1577 $usage = $data['usage'] ?? [];
1578 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1579 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1580 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1581 $usage['queries'] = 1;
1582 $usage['accuracy'] = 'tokens';
1583 $reply->set_usage( $usage );
1584 $reply->set_usage_accuracy( 'tokens' );
1585
1586 $reply->set_choices( $choices );
1587 $reply->set_type( 'images' );
1588
1589 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1590 foreach ( $reply->results as &$result ) {
1591 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1592 'query_envId' => $query->envId,
1593 'query_session' => $query->session,
1594 'query_model' => $query->model,
1595 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1596 $fileUrl = $this->core->files->get_url( $fileId );
1597 $result = $fileUrl;
1598 }
1599 }
1600 $reply->result = $reply->results[0];
1601 return $reply;
1602 }
1603 catch ( Exception $e ) {
1604 $service = $this->get_service_name();
1605 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1606 throw new Exception( "$service: " . $e->getMessage() );
1607 }
1608 }
1609
1610 public function run_editimage_query( $query ) {
1611 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1612 if ( empty( $attachments ) ) {
1613 throw new Exception( 'No image provided for editing.' );
1614 }
1615 // Ensure the model supports image editing
1616 $modelInfo = $this->retrieve_model_info( $query->model );
1617 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1618 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1619 }
1620 $file = $attachments[0];
1621 $imageData = $file->get_data();
1622 $body = $this->build_body( $query, null, $imageData );
1623 $url = $this->build_url( $query );
1624 $headers = $this->build_headers( $query );
1625 $options = $this->build_options( $headers, null, $body );
1626
1627 try {
1628 $res = $this->run_query( $url, $options );
1629 $data = $res['data'];
1630 $choices = $data['data'];
1631 $reply = new Meow_MWAI_Reply( $query );
1632 $model = $query->model;
1633
1634 // gpt-image models always return usage with input/output tokens
1635 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1636 $usage = $data['usage'] ?? [];
1637 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1638 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1639 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1640 $usage['queries'] = 1;
1641 $usage['accuracy'] = 'tokens';
1642 $reply->set_usage( $usage );
1643 $reply->set_usage_accuracy( 'tokens' );
1644
1645 $reply->set_choices( $choices );
1646 $reply->set_type( 'images' );
1647
1648 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1649 foreach ( $reply->results as &$result ) {
1650 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1651 'query_envId' => $query->envId,
1652 'query_session' => $query->session,
1653 'query_model' => $query->model,
1654 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1655 $fileUrl = $this->core->files->get_url( $fileId );
1656 $result = $fileUrl;
1657 }
1658 }
1659 $reply->result = $reply->results[0];
1660 return $reply;
1661 }
1662 catch ( Exception $e ) {
1663 $service = $this->get_service_name();
1664 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1665 throw new Exception( "$service: " . $e->getMessage() );
1666 }
1667 }
1668
1669 /*
1670 This is the rest of the OpenAI API support, not related to the models directly.
1671 */
1672
1673 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1674 // OpenAI uses { error: { message: ... } }; some compatible providers (xAI, etc.) use a
1675 // plain string in the error field — handle both shapes.
1676 protected function handle_response_errors( $data ) {
1677 $message = is_array( $data ) ? $this->extract_error_message( $data ) : null;
1678 if ( is_null( $message ) ) {
1679 return;
1680 }
1681 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1682 $message = str_replace( $matches[1], '', $message );
1683 }
1684 throw new Exception( $message );
1685 }
1686
1687 public function list_files( $purposeFilter = null ) {
1688 if ( empty( $purposeFilter ) ) {
1689 return $this->execute( 'GET', '/files' );
1690 }
1691 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1692 }
1693
1694 public static function get_suffix_for_model( $model ) {
1695 // Legacy fine-tuned models
1696 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1697 if ( count( $matches ) > 0 ) {
1698 return $matches[1];
1699 }
1700
1701 // New fine-tuned models
1702 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1703 if ( count( $matches ) > 0 ) {
1704 return $matches[1];
1705 }
1706
1707 return 'N/A';
1708 }
1709
1710 public static function get_model_without_release_date( $model ) {
1711 if ( empty( $model ) ) {
1712 return null;
1713 }
1714 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1715 }
1716
1717 // TODO: Remove every list_finetunes / cancel_finetune / delete_finetune / run_finetune method below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06). The `finetunes` env keys in init.php and the `finetune` key on every entry in constants/models.php go with them.
1718 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1719 $finetunes = $this->list_finetunes( $legacy );
1720 $deleted = [];
1721
1722 foreach ( $finetunes as $finetune ) {
1723 $name = $finetune['model'];
1724 $isSucceeded = $finetune['status'] === 'succeeded';
1725 if ( $isSucceeded ) {
1726 try {
1727 $finetune = $this->get_model( $name );
1728 }
1729 catch ( Exception $e ) {
1730 $deleted[] = $name;
1731 }
1732 }
1733 }
1734 if ( $legacy ) {
1735 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1736 }
1737 else {
1738 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1739 }
1740 return $deleted;
1741 }
1742
1743 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1744 // retrieve all the models since Summer 2023, let's see! WIP.
1745 public function list_finetunes( $legacy = false ) {
1746 if ( $legacy ) {
1747 $res = $this->execute( 'GET', '/fine-tunes' );
1748 }
1749 else {
1750 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1751 }
1752 $finetunes = $res['data'];
1753
1754 // Add suffix
1755 $finetunes = array_map( function ( $finetune ) {
1756 if ( isset( $finetune['user_provided_suffix'] ) ) {
1757 $finetune['suffix'] = $finetune['user_provided_suffix'];
1758 }
1759 else {
1760 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1761 }
1762 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1763 if ( isset( $finetune['estimated_finish'] ) ) {
1764 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1765 }
1766 else {
1767 $finetune['estimatedOn'] = null;
1768 }
1769 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1770 $finetune['base_model'] = $finetune['model'];
1771 $finetune['model'] = $finetune['fine_tuned_model'];
1772 unset( $finetune['object'] );
1773 unset( $finetune['hyperparams'] );
1774 unset( $finetune['result_files'] );
1775 unset( $finetune['training_files'] );
1776 unset( $finetune['validation_files'] );
1777 unset( $finetune['created_at'] );
1778 unset( $finetune['updated_at'] );
1779 unset( $finetune['fine_tuned_model'] );
1780 return $finetune;
1781 }, $finetunes );
1782
1783 usort( $finetunes, function ( $a, $b ) {
1784 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1785 } );
1786
1787 if ( $legacy ) {
1788 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1789 }
1790 else {
1791 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1792 }
1793
1794 return $finetunes;
1795 }
1796
1797 public function moderate( $input ) {
1798 $result = $this->execute( 'POST', '/moderations', [
1799 'input' => $input
1800 ] );
1801 return $result;
1802 }
1803
1804 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1805 global $wp_filter;
1806
1807 // Build multipart form data
1808 $boundary = wp_generate_password( 24, false );
1809 $body = '';
1810 $body .= "--$boundary\r\n";
1811 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1812 $body .= $purpose . "\r\n";
1813 $body .= "--$boundary\r\n";
1814 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1815 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1816 $body .= $data . "\r\n";
1817 $body .= "--$boundary--\r\n";
1818
1819 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1820 // Save current hooks
1821 $saved_hooks = null;
1822 if ( isset( $wp_filter['http_api_curl'] ) ) {
1823 $saved_hooks = $wp_filter['http_api_curl'];
1824 unset( $wp_filter['http_api_curl'] );
1825 }
1826
1827 // Upload using WordPress HTTP API
1828 $url = 'https://api.openai.com/v1/files';
1829 $response = wp_remote_post( $url, [
1830 'headers' => [
1831 'Authorization' => 'Bearer ' . $this->apiKey,
1832 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1833 ],
1834 'body' => $body,
1835 'timeout' => 60
1836 ] );
1837
1838 // Restore hooks
1839 if ( $saved_hooks !== null ) {
1840 $wp_filter['http_api_curl'] = $saved_hooks;
1841 }
1842
1843 if ( is_wp_error( $response ) ) {
1844 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1845 }
1846
1847 $response_body = wp_remote_retrieve_body( $response );
1848 return json_decode( $response_body, true );
1849 }
1850
1851 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1852 $body = [
1853 'name' => !empty( $name ) ? $name : 'default',
1854 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1855 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1856 ];
1857 if ( $expiry !== 'never' ) {
1858 if ( is_string( $expiry ) ) {
1859 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1860 $expiry = 7;
1861 }
1862 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1863 if ( $expiry && is_numeric( $expiry ) ) {
1864 $body['expires_after'] = [
1865 'anchor' => 'last_active_at',
1866 'days' => $expiryInDays
1867 ];
1868 }
1869 }
1870 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1871 return $result['id'];
1872 }
1873
1874 public function get_vector_store( $vectorStoreId ) {
1875 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1876 }
1877
1878 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1879 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1880 'file_id' => $fileId
1881 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1882 return $result['id'];
1883
1884 }
1885
1886 public function delete_file( $fileId ) {
1887 return $this->execute( 'DELETE', '/files/' . $fileId );
1888 }
1889
1890 public function get_model( $modelId ) {
1891 return $this->execute( 'GET', '/models/' . $modelId );
1892 }
1893
1894 public function cancel_finetune( $fineTuneId ) {
1895 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1896 }
1897
1898 public function delete_finetune( $modelId ) {
1899 return $this->execute( 'DELETE', '/models/' . $modelId );
1900 }
1901
1902 public function download_file( $fileId, $newFile = null ) {
1903 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1904 $fileInfo = json_decode( (string) $fileInfo, true );
1905 if ( empty( $fileInfo ) ) {
1906 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1907 }
1908 $filename = $fileInfo['filename'];
1909 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1910 if ( empty( $newFile ) ) {
1911 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1912 $tempFile = wp_tempnam( $filename );
1913 if ( !$tempFile ) {
1914 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1915 }
1916 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1917 $newFile = $tempFile . '.' . $extension;
1918 }
1919 else {
1920 $newFile = $tempFile;
1921 }
1922 }
1923 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1924 file_put_contents( $newFile, $data );
1925 return $newFile;
1926 }
1927
1928 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1929 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1930 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1931 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1932 (float) $hyperparams['learningRateMultiplier'] : null;
1933 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1934 (float) $hyperparams['promptLossWeight'] : null;
1935 $arguments = [
1936 'training_file' => $fileId,
1937 'model' => $model,
1938 'suffix' => $suffix
1939 ];
1940 if ( $legacy ) {
1941 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1942 }
1943 else {
1944 if ( $n_epochs ) {
1945 $arguments['hyperparams'] = [];
1946 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1947 }
1948 if ( $batch_size ) {
1949 if ( empty( $arguments['hyperparams'] ) ) {
1950 $arguments['hyperparams'] = [];
1951 }
1952 $arguments['hyperparams']['batch_size'] = $batch_size;
1953 }
1954 if ( $learning_rate_multiplier ) {
1955 if ( empty( $arguments['hyperparams'] ) ) {
1956 $arguments['hyperparams'] = [];
1957 }
1958 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1959 }
1960 if ( $prompt_loss_weight ) {
1961 if ( empty( $arguments['hyperparams'] ) ) {
1962 $arguments['hyperparams'] = [];
1963 }
1964 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1965 }
1966 if ( $model === 'turbo' ) {
1967 $arguments['model'] = 'gpt-3.5-turbo';
1968 }
1969 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1970 }
1971 return $result;
1972 }
1973
1974 /**
1975 * Build the body of a form request.
1976 * If the field name is 'file', then the field value is the filename of the file to upload.
1977 * The file contents are taken from the 'data' field.
1978 *
1979 * @param array $fields
1980 * @param string $boundary
1981 * @return string
1982 */
1983 public function build_form_body( $fields, $boundary ) {
1984 $body = '';
1985 foreach ( $fields as $name => $value ) {
1986 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1987 continue;
1988 }
1989 $body .= "--$boundary\r\n";
1990 $body .= "Content-Disposition: form-data; name=\"$name\"";
1991 if ( $name === 'image' || $name === 'file' ) {
1992 $body .= "; filename=\"{$value}\"\r\n";
1993 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1994 $body .= "Content-Type: {$mime}\r\n\r\n";
1995 $body .= $fields['data'] . "\r\n";
1996 }
1997 else if ( $name === 'mask' ) {
1998 $body .= "; filename=\"{$value}\"\r\n";
1999 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
2000 $body .= "Content-Type: {$mime}\r\n\r\n";
2001 $body .= $fields['mask_data'] . "\r\n";
2002 }
2003 else {
2004 // JSON encode arrays and objects, keep scalars as-is
2005 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
2006 $body .= "\r\n\r\n$encodedValue\r\n";
2007 }
2008 }
2009 $body .= "--$boundary--\r\n";
2010 return $body;
2011 }
2012
2013 /**
2014 * Run a request to the OpenAI API.
2015 * Fore more information about the $formFields, refer to the build_form_body method.
2016 *
2017 * @param string $method POST, PUT, GET, DELETE...
2018 * @param string $url The API endpoint
2019 * @param array $query The query parameters (json)
2020 * @param array $formFields The form fields (multipart/form-data)
2021 * @param bool $json Whether to return the response as json or not
2022 * @return array
2023 */
2024 public function execute(
2025 $method,
2026 $url,
2027 $query = null,
2028 $formFields = null,
2029 $json = true,
2030 $extraHeaders = null,
2031 $streamCallback = null
2032 ) {
2033 $isAzure = $this->envType === 'azure';
2034 $isOpenAI = !$isAzure;
2035
2036 // Prepare the headers
2037 $headers = "Content-Type: application/json\r\n";
2038 if ( $isOpenAI ) {
2039 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
2040 if ( $this->organizationId ) {
2041 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
2042 }
2043 }
2044 else if ( $isAzure ) {
2045 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
2046 }
2047
2048 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
2049 if ( !empty( $query ) && !is_string( $query ) ) {
2050 $body = $this->safe_json_encode( $query, 'query body' );
2051 }
2052 else {
2053 $body = $query;
2054 }
2055
2056 // If we have form fields, we need to change the headers and the body.
2057 if ( !empty( $formFields ) ) {
2058 $boundary = wp_generate_password( 24, false );
2059 $headers = [
2060 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2061 ];
2062 if ( $isOpenAI ) {
2063 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
2064 if ( $this->organizationId ) {
2065 $headers['OpenAI-Organization'] = $this->organizationId;
2066 }
2067 }
2068 else if ( $isAzure ) {
2069 $headers['api-key'] = $this->apiKey;
2070 }
2071 $body = $this->build_form_body( $formFields, $boundary );
2072 }
2073
2074 // Maybe we should have headers always as an array... not sure why we have it as a string.
2075 if ( !empty( $extraHeaders ) ) {
2076 foreach ( $extraHeaders as $key => $value ) {
2077 if ( is_array( $headers ) ) {
2078 $headers[$key] = $value;
2079 }
2080 else {
2081 $headers .= "$key: $value\r\n";
2082 }
2083 }
2084 }
2085
2086 // Create the URL
2087 if ( $isOpenAI ) {
2088 $url = 'https://api.openai.com/v1' . $url;
2089 }
2090 else if ( $isAzure ) {
2091 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
2092 $hasQuery = strpos( $url, '?' ) !== false;
2093 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
2094 }
2095
2096 // If it's a GET, body should be null, and we should append the query to the URL.
2097 if ( $method === 'GET' ) {
2098 if ( !empty( $query ) ) {
2099 $hasQuery = strpos( $url, '?' ) !== false;
2100 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
2101 }
2102 $body = null;
2103 }
2104
2105 $options = [
2106 'headers' => $headers,
2107 'method' => $method,
2108 'timeout' => MWAI_TIMEOUT,
2109 'body' => $body,
2110 'sslverify' => MWAI_SSL_VERIFY
2111 ];
2112
2113 // Check if queries debug is enabled
2114 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
2115
2116 // Log the request if queries debug is enabled
2117 if ( $queries_debug ) {
2118 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
2119
2120 if ( !empty( $body ) ) {
2121 error_log( '[AI Engine Queries] Request Body:' );
2122 error_log( $body );
2123 }
2124
2125 if ( !is_null( $streamCallback ) ) {
2126 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
2127 }
2128 }
2129
2130 try {
2131 if ( !is_null( $streamCallback ) ) {
2132 $options['stream'] = true;
2133 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
2134 // The stream handler calls the streamCallback every time there is content
2135 // TODO: For assistants, we should probably have a different stream handler to
2136 // handle the assistant's specific reply and perform the necessary actions.
2137 $this->streamCallback = $streamCallback;
2138 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
2139 }
2140 $res = wp_remote_request( $url, $options );
2141 if ( is_wp_error( $res ) ) {
2142 throw new Exception( $res->get_error_message() );
2143 }
2144
2145 $res = wp_remote_retrieve_body( $res );
2146
2147 // Handle empty responses for container LIST API only (not for file content downloads)
2148 if ( strpos( $url, '/containers/' ) !== false &&
2149 strpos( $url, '/files' ) !== false &&
2150 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
2151 empty( $res ) ) {
2152 // Return empty array for empty container files LIST response
2153 $data = $json ? [] : '';
2154 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
2155 }
2156 else {
2157 $data = $json ? json_decode( $res, true ) : $res;
2158 }
2159
2160 // Debug logging for decoded data (skip for content downloads)
2161 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
2162 error_log( '[AI Engine] After json_decode:' );
2163 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
2164 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
2165 if ( $data !== null && is_array( $data ) ) {
2166 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
2167 error_log( '[AI Engine] - Data count: ' . count( $data ) );
2168 }
2169 if ( $json && $data === null && !empty( $res ) ) {
2170 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
2171 }
2172 }
2173
2174 $this->handle_response_errors( $data );
2175
2176 // Log the response if queries debug is enabled
2177 if ( $queries_debug && is_null( $streamCallback ) ) {
2178 error_log( '[AI Engine Queries] Response Body:' );
2179 error_log( $res );
2180 }
2181 return $data;
2182 }
2183 catch ( Exception $e ) {
2184 $service = $this->get_service_name();
2185 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
2186 throw new Exception( "$service: " . $e->getMessage() );
2187 }
2188 finally {
2189 if ( !is_null( $streamCallback ) ) {
2190 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2191 }
2192 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
2193 unlink( $options['filename'] );
2194 }
2195 }
2196 }
2197
2198 public function get_models() {
2199 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
2200 // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
2201 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
2202 foreach ( $finetunes as $finetune ) {
2203 if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
2204 continue;
2205 }
2206 if ( empty( $finetune['base_model'] ) || empty( $finetune['model'] ) || empty( $finetune['suffix'] ) ) {
2207 continue;
2208 }
2209 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
2210 if ( !empty( $baseModel ) ) {
2211 $model = null;
2212 foreach ( $models as $currentModel ) {
2213 if ( $currentModel['model'] === $baseModel ) {
2214 $model = $currentModel;
2215 break;
2216 }
2217 }
2218 if ( !empty( $model ) ) {
2219 $model['model'] = $finetune['model'];
2220 $model['name'] = $finetune['suffix'];
2221 $models[] = $model;
2222 }
2223 }
2224 }
2225 return $models;
2226 }
2227
2228 public static function get_models_static() {
2229 return MWAI_OPENAI_MODELS;
2230 }
2231
2232 /**
2233 * Recursively search for container_id in the response data
2234 */
2235 protected function search_for_container_id_recursive( $data, $path = '' ) {
2236 if ( is_array( $data ) || is_object( $data ) ) {
2237 foreach ( $data as $key => $value ) {
2238 $currentPath = $path ? $path . '.' . $key : $key;
2239
2240 // Check if this key is container_id
2241 if ( $key === 'container_id' ) {
2242 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2243 }
2244
2245 // Recursively search in nested structures
2246 if ( is_array( $value ) || is_object( $value ) ) {
2247 $this->search_for_container_id_recursive( $value, $currentPath );
2248 }
2249 }
2250 }
2251 }
2252
2253 // TODO: After 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06), drop the $finetune branch from calculate_price() and the finetune-detection block in get_price().
2254 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2255 $modelFamily = self::get_model_without_release_date( $modelFamily );
2256 $models = $this->get_models();
2257 foreach ( $models as $currentModel ) {
2258 if ( $currentModel['model'] === $modelFamily ) {
2259 if ( $finetune ) {
2260 if ( isset( $currentModel['finetune']['price'] ) ) {
2261 $currentModel['price'] = $currentModel['finetune']['price'];
2262 }
2263 else if ( isset( $currentModel['finetune']['in'] ) ) {
2264 $currentModel['price'] = [
2265 'in' => $currentModel['finetune']['in'],
2266 'out' => $currentModel['finetune']['out']
2267 ];
2268 }
2269 }
2270 $inPrice = $currentModel['price'];
2271 $outPrice = $currentModel['price'];
2272 if ( is_array( $currentModel['price'] ) ) {
2273 $inPrice = $currentModel['price']['in'];
2274 $outPrice = $currentModel['price']['out'];
2275 }
2276 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2277 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2278 return $inTotalPrice + $outTotalPrice;
2279 }
2280 }
2281 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2282 return null;
2283 }
2284
2285 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2286 $model = $query->model;
2287 $units = 0;
2288 $finetune = false;
2289 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2290 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2291 $finetune = true;
2292 }
2293 $inUnits = $reply->get_in_tokens( $query );
2294 $outUnits = $reply->get_out_tokens();
2295 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2296 }
2297 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2298 // gpt-image models are billed by input/output tokens like text models.
2299 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2300 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2301 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2302 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2303 }
2304 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2305 $model = 'whisper';
2306 $units = $reply->get_units();
2307 return $this->calculate_price( $model, 0, $units, null, $finetune );
2308 }
2309 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2310 $units = $reply->get_total_tokens();
2311 return $this->calculate_price( $model, 0, $units, null, $finetune );
2312 }
2313 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2314 return null;
2315 }
2316 }
2317