PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.5
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 2 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 2 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
2312 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 // GPT-5 and o1 models don't support temperature parameter
296 if ( !$this->is_o1_model( $query->model ) && !$this->is_gpt5_model( $query->model ) ) {
297 $body['temperature'] = $query->temperature;
298 }
299 else if ( $this->is_o1_model( $query->model ) ) {
300 // o1 models require temperature to be 1 if specified
301 $body['temperature'] = 1;
302 }
303 // For GPT-5 models, we simply don't include the temperature parameter
304 }
305
306 if ( !empty( $query->maxResults ) ) {
307 $body['n'] = $query->maxResults;
308 }
309
310 if ( !empty( $query->stop ) ) {
311 $body['stop'] = $query->stop;
312 }
313
314 if ( !empty( $query->responseFormat ) ) {
315 if ( $query->responseFormat === 'json' ) {
316 $body['response_format'] = [ 'type' => 'json_object' ];
317 }
318 }
319
320 // Usage Data (only for OpenAI)
321 // https://cookbook.openai.com/examples/how_to_stream_completions#4-how-to-get-token-usage-data-for-streamed-chat-completion-response
322 if ( !empty( $streamCallback ) && $this->envType === 'openai' ) {
323 $body['stream_options'] = [
324 'include_usage' => true,
325 ];
326 }
327
328 if ( !empty( $query->functions ) ) {
329 $model = $this->retrieve_model_info( $query->model );
330 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
331 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support Function Calling.' );
332 }
333 else if ( strpos( $query->model, 'ft:' ) === 0 ) {
334 Meow_MWAI_Logging::warn( 'OpenAI doesn\'t support Function Calling with fine-tuned models yet.' );
335 }
336 else {
337 $body['tools'] = [];
338 // Dynamic function: they will interactively enhance the completion (tools).
339 foreach ( $query->functions as $function ) {
340 $body['tools'][] = [
341 'type' => 'function',
342 'function' => $function->serializeForOpenAI()
343 ];
344 }
345 // Static functions: they will be executed at the end of the completion.
346 //$body['function_call'] = $query->functionCall;
347 }
348 }
349 $body['messages'] = $this->build_messages( $query );
350
351 // Add the feedback if it's a feedback query.
352 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
353 if ( !empty( $query->blocks ) ) {
354 foreach ( $query->blocks as $feedback_block ) {
355 $body['messages'][] = $feedback_block['rawMessage'];
356 foreach ( $feedback_block['feedbacks'] as $feedback ) {
357 // Ensure content is a string for the API
358 $content = $feedback['reply']['value'];
359 if ( !is_string( $content ) ) {
360 $content = json_encode( $content );
361 }
362
363 $body['messages'][] = [
364 'tool_call_id' => $feedback['request']['toolId'],
365 'role' => 'tool',
366 'name' => $feedback['request']['name'],
367 'content' => $content
368 ];
369
370 // Note: Function result events are now emitted centrally in core.php
371 // when the function is actually executed
372 }
373 }
374 }
375 return $body;
376 }
377
378 return $body;
379 }
380 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
381 // Determine filename and MIME type
382 $filename = 'audio.mp3'; // default
383 $mimeType = 'audio/mpeg'; // default
384 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
385 if ( !empty( $attachments ) ) {
386 $firstAttachment = $attachments[0];
387 if ( method_exists( $firstAttachment, 'get_filename' ) ) {
388 $filename = $firstAttachment->get_filename();
389 }
390 if ( method_exists( $firstAttachment, 'get_mimeType' ) ) {
391 $detectedMime = $firstAttachment->get_mimeType();
392 if ( !empty( $detectedMime ) ) {
393 $mimeType = $detectedMime;
394 }
395 }
396 }
397 else if ( !empty( $query->url ) ) {
398 $filename = basename( $query->url );
399 }
400
401 // Guard against malformed filenames coming from form prompts/placeholders
402 if ( empty( $filename ) ) {
403 $filename = 'audio.mp3';
404 }
405 $filename = preg_replace( "/[\r\n]+/", '', $filename );
406 $mimeType = preg_replace( "/[\r\n]+/", '', $mimeType );
407 if ( empty( $mimeType ) ) {
408 $mimeType = 'audio/mpeg';
409 }
410
411 $body = [
412 'prompt' => $query->message,
413 'model' => $query->model,
414 'response_format' => 'text',
415 'file' => $filename,
416 'data' => $extra,
417 'mime' => $mimeType
418 ];
419 return $body;
420 }
421 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
422 $body = [ 'input' => $query->message, 'model' => $query->model ];
423 if ( $this->envType === 'azure' ) {
424 $body = [ 'input' => $query->message ];
425 }
426 // Dimensions are only supported by v3 models
427 if ( !empty( $query->dimensions ) && strpos( $query->model, 'ada-002' ) === false ) {
428 $body['dimensions'] = $query->dimensions;
429 }
430 return $body;
431 }
432 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
433 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
434 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
435 $firstFile = !empty( $attachments ) ? $attachments[0] : null;
436 $filename = $firstFile ? $firstFile->get_filename() : '';
437 $mimeType = $firstFile ? $firstFile->get_mimeType() : null;
438 $body = [
439 'prompt' => $query->message,
440 'n' => $query->maxResults,
441 'size' => $resolution,
442 'image' => $filename,
443 'data' => $extra
444 ];
445 if ( !empty( $mimeType ) ) {
446 $body['mime'] = $mimeType;
447 }
448
449 // Add mask if provided
450 if ( !empty( $query->mask ) ) {
451 $maskData = $query->mask->get_data();
452 $maskFilename = 'mask.png';
453 $maskMimeType = $query->mask->get_mimeType();
454
455 $body['mask'] = $maskFilename;
456 $body['mask_data'] = $maskData;
457 if ( !empty( $maskMimeType ) ) {
458 $body['mask_mime'] = $maskMimeType;
459 }
460 }
461 // 'response_format' => 'b64_json',
462 if ( !empty( $query->model ) ) {
463 $body['model'] = $query->model;
464 }
465 return $body;
466 }
467 else if ( $query instanceof Meow_MWAI_Query_Image ) {
468 $model = $query->model;
469 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
470 $body = [
471 'model' => $model,
472 'prompt' => $query->message,
473 'n' => $query->maxResults,
474 'size' => $resolution,
475 'moderation' => 'low',
476 ];
477 // Only forward 'quality' when the caller (or final_checks) resolved one.
478 // Letting the API default decide ('auto' for gpt-image-*) keeps costs predictable
479 // and avoids forcing 'high' on every request.
480 if ( !empty( $query->quality ) ) {
481 $body['quality'] = $query->quality;
482 }
483 return $body;
484 }
485 }
486
487 protected function build_url( $query, $endpoint = null ) {
488 $url = '';
489 $env = $this->env;
490 // This endpoint is basically OpenAI or Azure, but in the case this class
491 // is overriden, we can pass the endpoint directly (for OpenRouter or HuggingFace, for example).
492 if ( empty( $endpoint ) ) {
493 if ( $this->envType === 'openai' ) {
494 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
495 $this->organizationId = isset( $env['organizationId'] ) ? $env['organizationId'] : null;
496 }
497 else if ( $this->envType === 'azure' ) {
498 $endpoint = isset( $env['endpoint'] ) ? $env['endpoint'] : null;
499 // Ensure the endpoint has the proper protocol if it's just a domain
500 if ( $endpoint && strpos( $endpoint, 'http' ) !== 0 ) {
501 $endpoint = 'https://' . $endpoint;
502 }
503 }
504 else {
505 if ( empty( $this->envType ) ) {
506 throw new Exception( 'Endpoint is not defined, and this envType is not known.' );
507 }
508 throw new Exception( 'Endpoint is not defined, and this envType is not known: ' . $this->envType );
509 }
510 }
511 // Add the base API to the URL
512 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
513 if ( $this->envType === 'azure' ) {
514 $deployment_name = $this->get_azure_deployment_name( $query->model );
515 $url = trailingslashit( $endpoint ) . 'openai/deployments/' . $deployment_name;
516 $url .= '/chat/completions?' . $this->azureApiVersion;
517 }
518 else {
519 $url .= trailingslashit( $endpoint ) . 'chat/completions';
520 }
521 return $url;
522 }
523 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
524 $modeEndpoint = $query->feature === 'translation' ? 'translations' : 'transcriptions';
525 $url .= trailingslashit( $endpoint ) . 'audio/' . $modeEndpoint;
526 return $url;
527 }
528 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
529 $url .= trailingslashit( $endpoint ) . 'embeddings';
530 if ( $this->envType === 'azure' ) {
531 $deployment_name = $this->get_azure_deployment_name( $query->model );
532 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
533 $deployment_name . '/embeddings?' . $this->azureApiVersion;
534 }
535 return $url;
536 }
537 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
538 $url .= trailingslashit( $endpoint ) . 'images/edits';
539 if ( $this->envType === 'azure' ) {
540 $deployment_name = $this->get_azure_deployment_name( $query->model );
541 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
542 $deployment_name . '/images/edits?' . $this->azureApiVersion;
543 }
544 return $url;
545 }
546 else if ( $query instanceof Meow_MWAI_Query_Image ) {
547 $url .= trailingslashit( $endpoint ) . 'images/generations';
548 if ( $this->envType === 'azure' ) {
549 $deployment_name = $this->get_azure_deployment_name( $query->model );
550 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
551 $deployment_name . '/images/generations?' . $this->azureApiVersion;
552 }
553 return $url;
554 }
555 throw new Exception( 'The query is not supported by build_url().' );
556 }
557
558 protected function build_headers( $query ) {
559 if ( $query->apiKey ) {
560 $this->apiKey = $query->apiKey;
561 }
562 if ( empty( $this->apiKey ) ) {
563 throw new Exception( 'No API Key provided. Please visit the Settings. (ChatML Engine)' );
564 }
565 $headers = [
566 'Content-Type' => 'application/json',
567 'Authorization' => 'Bearer ' . $this->apiKey,
568 ];
569 if ( $this->organizationId ) {
570 $headers['OpenAI-Organization'] = $this->organizationId;
571 }
572 if ( $this->envType === 'azure' ) {
573 $headers = [ 'Content-Type' => 'application/json', 'api-key' => $this->apiKey ];
574 }
575 return $headers;
576 }
577
578 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
579 $body = null;
580 if ( !empty( $forms ) ) {
581 $boundary = wp_generate_password( 24, false );
582 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
583 $body = $this->build_form_body( $forms, $boundary );
584 }
585 else if ( !empty( $json ) ) {
586 $body = $this->safe_json_encode( $json, 'request body' );
587 }
588 $options = [
589 'headers' => $headers,
590 'method' => $method,
591 'timeout' => MWAI_TIMEOUT,
592 'body' => $body,
593 'sslverify' => MWAI_SSL_VERIFY
594 ];
595 return $options;
596 }
597 // object: "thread.message.delta"
598 protected function stream_data_handler( $json ) {
599 $content = null;
600 $handledCondition = false; // Track if we entered any condition
601
602 // Get additional data from the JSON
603 if ( isset( $json['model'] ) ) {
604 $this->inModel = $json['model'];
605 }
606 if ( isset( $json['id'] ) ) {
607 $this->inId = $json['id'];
608
609 // Send start event if debug mode is enabled and not already sent
610 if ( $this->currentDebugMode && $this->streamCallback && !$this->streamStartEmitted ) {
611 $this->streamStartEmitted = true;
612 $event = Meow_MWAI_Event::status( 'Starting stream...' )
613 ->set_metadata( 'model', $this->inModel )
614 ->set_metadata( 'id', $this->inId );
615 call_user_func( $this->streamCallback, $event );
616 }
617 }
618
619 // Providers send no-op frames (keepalive, ping) purely to hold the SSE connection
620 // open through proxies. They carry no content and there is nothing to handle, so
621 // return before the "unhandled streaming JSON structure" log at the end of this
622 // method reports them as a parsing problem. They are expected traffic, not errors.
623 $eventType = $json['type'] ?? null;
624 if ( in_array( $eventType, [ 'keepalive', 'ping' ], true ) ) {
625 return null;
626 }
627
628 $object = $json['object'] ?? null;
629
630 if ( $object === 'thread.run' ) {
631 $handledCondition = true;
632 $this->inThreadId = $json['thread_id'];
633 if ( $json['status'] === 'failed' ) {
634 $error = $json['last_error']['message'] ?? 'The run failed.';
635 throw new Exception( $error );
636 }
637 }
638 else if ( $object === 'thread.run.step.delta' ) {
639 $handledCondition = true;
640 if ( $json['delta']['step_details']['type'] === 'tool_calls' ) {
641 foreach ( $json['delta']['step_details']['tool_calls'] as $tool_call ) {
642 $index = $tool_call['index'] ?? null;
643 // unset (not "= null") to break the reference from the previous loop
644 // iteration. Assigning null would null out the tool call we just stored
645 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
646 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
647 unset( $currentStreamToolCall );
648 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
649 $currentStreamToolCall = &$this->streamToolCalls[$index];
650 }
651 else {
652 $this->streamToolCalls[] = [
653 'id' => null,
654 'type' => null,
655 'function' => [ 'name' => '', 'arguments' => '' ],
656 'code_interpreter' => [ 'input' => '', 'outputs' => [] ],
657 ];
658 end( $this->streamToolCalls );
659 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
660
661 // Send tool call initiated event
662 if ( $this->currentDebugMode && $this->streamCallback ) {
663 $event = Meow_MWAI_Event::status( 'Initiating tool call...' );
664 call_user_func( $this->streamCallback, $event );
665 }
666 }
667 if ( !empty( $tool_call['id'] ) ) {
668 $currentStreamToolCall['id'] = $tool_call['id'];
669 }
670 if ( !empty( $tool_call['type'] ) ) {
671 $currentStreamToolCall['type'] = $tool_call['type'];
672 }
673 if ( isset( $tool_call['function'] ) ) {
674 $function = $tool_call['function'];
675 if ( isset( $function['name'] ) ) {
676 $currentStreamToolCall['function']['name'] .= $function['name'];
677 }
678 if ( isset( $function['arguments'] ) ) {
679 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
680 }
681 }
682 if ( isset( $tool_call['code_interpreter'] ) ) {
683 $code_interpreter = $tool_call['code_interpreter'];
684 if ( isset( $code_interpreter['input'] ) ) {
685 $currentStreamToolCall['code_interpreter']['input'] .= $code_interpreter['input'];
686 }
687 if ( isset( $code_interpreter['outputs'] ) ) {
688 $currentStreamToolCall['code_interpreter']['outputs'] = $code_interpreter['outputs'];
689 }
690 }
691 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
692 }
693 }
694 }
695 else if ( $object === 'thread.message.delta' ) {
696 $handledCondition = true;
697 $delta = $json['delta']['content'][0] ?? null;
698 if ( $delta ) {
699 switch ( $delta['type'] ?? null ) {
700 case 'text':
701 if ( !empty( $delta['value'] ) && is_string( $delta['value'] ) ) {
702 $content = $delta['value'];
703 }
704 else if ( !empty( $delta['text'] ) && is_string( $delta['text'] ) ) {
705 $content = $delta['text'];
706 }
707 else if ( !empty( $delta['text'] ) && is_array( $delta['text'] ) ) {
708 $text = $delta['text'];
709 if ( !empty( $text['annotations'] ) ) {
710 $this->streamAnnotations = array_merge( $this->streamAnnotations, $text['annotations'] );
711 }
712 if ( isset( $text['value'] ) ) {
713 $content = $text['value'];
714 }
715 }
716 else {
717 error_log( 'AI Engine: Unknown text format: ' . json_encode( $delta ) );
718 }
719 break;
720 case 'image':
721 $content = $delta['url'];
722 break;
723 case 'image_file':
724 $fileId = $delta['image_file']['file_id'];
725 $content = '<!-- IMG #' . $fileId . ' -->';
726 $this->streamImageIds[] = $fileId;
727 break;
728 case 'function_call':
729 // TODO: Re-evaluate after 2027-01-26. This is the deprecated OpenAI
730 // "function_call" streaming format, superseded by "tool_calls". It decodes
731 // each delta and replaces (instead of concatenating like the tool_calls
732 // path), so a provider that streams fragmented args here would lose them.
733 // Confirm no supported provider still uses this format, then remove it.
734 if ( empty( $this->streamFunctionCall ) ) {
735 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
736 }
737 $this->streamFunctionCall['name'] = $delta['function_call']['name'] ?? $this->streamFunctionCall['name'];
738 if ( isset( $delta['function_call']['arguments'] ) ) {
739 $args = json_decode( $delta['function_call']['arguments'], true );
740 $this->streamFunctionCall['arguments'] = $args ?? [];
741 }
742 break;
743 case 'tool_call':
744 $tool_call = $delta['tool_call'];
745 $index = $tool_call['index'] ?? null;
746 // unset (not "= null") to break the reference from the previous loop
747 // iteration. Assigning null would null out the tool call we just stored
748 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
749 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
750 unset( $currentStreamToolCall );
751 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
752 $currentStreamToolCall = &$this->streamToolCalls[$index];
753 }
754 else {
755 $this->streamToolCalls[] = [
756 'id' => null,
757 'type' => null,
758 'function' => [ 'name' => '', 'arguments' => '' ]
759 ];
760 end( $this->streamToolCalls );
761 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
762 }
763 break;
764 }
765 }
766 }
767 else if ( $object === 'thread.run.step' ) {
768 $handledCondition = true;
769 //$type = $json['step'];
770 // Could be tool_calls, means an OpenAI Assistant is doing something.
771 }
772 else {
773 if ( isset( $json['choices'][0]['text'] ) ) {
774 $handledCondition = true;
775 $content = $json['choices'][0]['text'];
776 }
777 else if ( isset( $json['choices'][0]['delta']['content'] ) ) {
778 $handledCondition = true;
779 $content = $json['choices'][0]['delta']['content'];
780 }
781 else if ( isset( $json['choices'][0]['delta']['function_call'] ) ) {
782 // TODO: Re-evaluate after 2027-01-26. Deprecated "function_call" stream path
783 // (same deprecation as the object-based case above); remove once no provider uses it.
784 $handledCondition = true;
785 if ( empty( $this->streamFunctionCall ) ) {
786 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
787 }
788 $this->streamFunctionCall['name'] = $json['choices'][0]['delta']['function_call']['name'] ?? $this->streamFunctionCall['name'];
789 if ( isset( $json['choices'][0]['delta']['function_call']['arguments'] ) ) {
790 $args = json_decode( $json['choices'][0]['delta']['function_call']['arguments'], true );
791 $this->streamFunctionCall['arguments'] = $args ?? [];
792 }
793 }
794 else if ( isset( $json['choices'][0]['delta']['tool_calls'] ) ) {
795 $handledCondition = true;
796 // New schema detected – drop any half-built legacy call to prevent duplicates
797 $this->streamFunctionCall = null;
798
799 foreach ( $json['choices'][0]['delta']['tool_calls'] as $tool_call ) {
800 $index = $tool_call['index'] ?? null;
801 // unset (not "= null") to break the reference from the previous loop
802 // iteration. Assigning null would null out the tool call we just stored
803 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
804 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
805 unset( $currentStreamToolCall );
806 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
807 $currentStreamToolCall = &$this->streamToolCalls[$index];
808 }
809 else {
810 $this->streamToolCalls[] = [
811 'id' => null,
812 'type' => null,
813 'function' => [ 'name' => '', 'arguments' => '' ]
814 ];
815 end( $this->streamToolCalls );
816 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
817 }
818 if ( !empty( $tool_call['id'] ) ) {
819 $currentStreamToolCall['id'] = $tool_call['id'];
820 }
821 if ( !empty( $tool_call['type'] ) ) {
822 $currentStreamToolCall['type'] = $tool_call['type'];
823 }
824 if ( isset( $tool_call['function'] ) ) {
825 $function = $tool_call['function'];
826 if ( isset( $function['name'] ) ) {
827 $currentStreamToolCall['function']['name'] .= $function['name'];
828 }
829 if ( isset( $function['arguments'] ) ) {
830 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
831 }
832 }
833 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
834 }
835 }
836 else if ( isset( $json['choices'][0]['delta']['role'] ) ) {
837 $handledCondition = true;
838 $this->streamLastMessage = [
839 'role' => $json['choices'][0]['delta']['role'],
840 'content' => null
841 ];
842 }
843
844 // Handle thinking/reasoning content (from Ollama and potentially other models)
845 // This can appear alongside or instead of regular content
846 if ( isset( $json['choices'][0]['delta']['reasoning'] ) ) {
847 $handledCondition = true;
848 $thinking = $json['choices'][0]['delta']['reasoning'];
849 if ( !empty( $thinking ) ) {
850 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
851 }
852 }
853 // Also check for 'thinking' field (OpenAI's o1 models use this)
854 else if ( isset( $json['choices'][0]['delta']['thinking'] ) ) {
855 $handledCondition = true;
856 $thinking = $json['choices'][0]['delta']['thinking'];
857 if ( !empty( $thinking ) ) {
858 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
859 }
860 }
861 }
862
863 $usage = $json['usage'] ?? [];
864 if ( isset( $usage['prompt_tokens'], $usage['completion_tokens'] ) ) {
865 $this->streamInTokens = (int) $usage['prompt_tokens'];
866 $this->streamOutTokens = (int) $usage['completion_tokens'];
867
868 if ( isset( $usage['cost'] ) ) {
869 $this->streamCost = (float) $usage['cost'];
870 }
871 }
872
873 // If content is an array, let's try to convert it into a string. Normally, there would be a 'value' key.
874 if ( is_array( $content ) ) {
875 if ( isset( $content['value'] ) ) {
876 $content = $content['value'];
877 }
878 else {
879 throw new Exception( 'Could not read this: ' . json_encode( $content ) );
880 }
881 }
882
883 // Log unhandled JSON in dev mode
884 if ( !$handledCondition && $this->core->get_option( 'dev_mode' ) ) {
885 error_log( '[AI Engine] Unhandled streaming JSON structure: ' . json_encode( $json ) );
886 }
887
888 // Avoid some endings
889 $endings = [ '', '</s>' ];
890 if ( in_array( $content, $endings ) ) {
891 $content = null;
892 }
893
894 if ( $content !== '0' && empty( $content ) ) {
895 return null;
896 }
897 if ( is_string( $content ) ) {
898 $content = $this->filter_inline_thinking( $content );
899 if ( $content === '' ) {
900 return null;
901 }
902 }
903 return $content;
904 }
905
906 /**
907 * Qwen-style open models emit their reasoning INLINE in the content as
908 * <think>...</think>. Route it to streamThinking (with a single thinking
909 * event per block) so the visible reply only contains the actual answer.
910 */
911 protected function filter_inline_thinking( $content ) {
912 if ( $this->streamThinkTag === 0 &&
913 strpos( $content, '<think>' ) === false && strpos( $content, '</think>' ) === false ) {
914 return $content;
915 }
916 $out = '';
917 $thought = '';
918 $remaining = $content;
919 while ( $remaining !== '' ) {
920 if ( $this->streamThinkTag === 0 ) {
921 $pos = strpos( $remaining, '<think>' );
922 if ( $pos === false ) {
923 $out .= $remaining;
924 break;
925 }
926 $out .= substr( $remaining, 0, $pos );
927 $remaining = substr( $remaining, $pos + 7 );
928 $this->streamThinkTag = 1;
929 if ( $this->currentDebugMode && !empty( $this->streamCallback ) ) {
930 call_user_func( $this->streamCallback, Meow_MWAI_Event::thinking( 'Thinking...' ) );
931 }
932 }
933 else {
934 $pos = strpos( $remaining, '</think>' );
935 if ( $pos === false ) {
936 $thought .= $remaining;
937 break;
938 }
939 $thought .= substr( $remaining, 0, $pos );
940 // The answer usually follows the block after blank lines.
941 $remaining = ltrim( substr( $remaining, $pos + 8 ), "\n" );
942 $this->streamThinkTag = 0;
943 }
944 }
945 if ( $thought !== '' ) {
946 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thought;
947 }
948 return $out;
949 }
950
951 /**
952 * Same cleanup for non-streaming replies: closed blocks are removed, and a
953 * trailing unclosed block (the model spent its whole budget thinking) too.
954 */
955 public static function strip_inline_thinking( $text ) {
956 if ( !is_string( $text ) || strpos( $text, '<think>' ) === false ) {
957 return $text;
958 }
959 $text = preg_replace( '/<think>.*?<\/think>/s', '', $text );
960 $text = preg_replace( '/<think>.*$/s', '', $text );
961 return ltrim( $text );
962 }
963
964 public function run( $query, $streamCallback = null, $maxDepth = 5 ) {
965 // Check if this is a realtime model being used with chat completions
966 if ( $this->is_realtime_model( $query->model ) ) {
967 throw new Exception(
968 'Realtime models (like ' . $query->model . ') are designed for voice/audio interactions and cannot be used with this API.'
969 );
970 }
971
972 return parent::run( $query, $streamCallback, $maxDepth );
973 }
974
975 public function run_query( $url, $options, $isStream = false ) {
976 try {
977 $options['stream'] = $isStream;
978 if ( $isStream ) {
979 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
980 }
981
982 // Check if queries debug is enabled
983 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
984
985 // Log the request if queries debug is enabled
986 if ( $queries_debug ) {
987 error_log( '[AI Engine Queries] --> Request to: ' . $url );
988
989 if ( isset( $options['body'] ) ) {
990 // This is the actual body being sent to the AI service
991 $body_log = is_string( $options['body'] ) ? $options['body'] : json_encode( $options['body'] );
992
993 // Pretty print JSON if possible
994 $decoded = json_decode( $body_log, true );
995 if ( json_last_error() === JSON_ERROR_NONE ) {
996 error_log( json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
997 }
998 else {
999 error_log( $body_log );
1000 }
1001 }
1002 }
1003
1004 $res = wp_remote_get( $url, $options );
1005
1006 if ( is_wp_error( $res ) ) {
1007 throw new Exception( $res->get_error_message() );
1008 }
1009
1010 $responseCode = wp_remote_retrieve_response_code( $res );
1011
1012 // For streaming requests the response body is written to a temp file, so
1013 // wp_remote_retrieve_body() comes back empty. Read it back here so provider
1014 // error details (e.g. a 422 from Mistral) aren't lost on streaming requests.
1015 $errorBody = wp_remote_retrieve_body( $res );
1016 if ( empty( $errorBody ) && $isStream && !empty( $options['filename'] ) && file_exists( $options['filename'] ) ) {
1017 $errorBody = file_get_contents( $options['filename'] );
1018 }
1019
1020 if ( $responseCode === 404 ) {
1021 // A 404 here is almost always an invalid model name for this provider
1022 // (the endpoint URL is correct), so surface the provider's own message.
1023 $detail = !empty( $errorBody ) ? ' Response: ' . $errorBody : '';
1024 throw new Exception( 'The API returned 404 for ' . $url . '. This usually means the model is not valid for this provider.' . $detail );
1025 }
1026 else if ( $responseCode === 400 ) {
1027 error_log( '[AI Engine] 400 Bad Request - Full response body: ' . $errorBody );
1028 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
1029 if ( empty( $message ) ) {
1030 $message = 'Bad Request';
1031 }
1032 throw new Exception( $message );
1033 }
1034 else if ( $responseCode >= 400 ) {
1035 // Any other 4xx/5xx (401, 403, 422, 429, 500, 502...). The body is thrown
1036 // raw on purpose: the callers run it through try_decode_error() to pull out
1037 // the provider's message, whatever its shape. Before this branch was generic,
1038 // a Mistral 403 (tier_not_allowed) fell through to the JSON path and only
1039 // ever surfaced as "Invalid response (no model information)".
1040 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
1041 if ( empty( $message ) ) {
1042 $message = 'HTTP ' . $responseCode;
1043 }
1044 throw new Exception( $message );
1045 }
1046
1047 if ( $isStream ) {
1048 return [ 'stream' => true ];
1049 }
1050
1051 $response = wp_remote_retrieve_body( $res );
1052 $headersRes = wp_remote_retrieve_headers( $res );
1053 $headers = $headersRes->getAll();
1054
1055 // Check if Content-Type is 'multipart/form-data' or 'text/plain'
1056 // If so, we don't need to decode the response
1057 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
1058 $resContentType = $normalizedHeaders['content-type'] ?? '';
1059 if ( strpos( $resContentType, 'multipart/form-data' ) !== false || strpos( $resContentType, 'text/plain' ) !== false ) {
1060 // Log the response if queries debug is enabled
1061 if ( $queries_debug && !$isStream ) {
1062 error_log( '[AI Engine Queries] Response Headers: ' . json_encode( $headers ) );
1063 error_log( '[AI Engine Queries] Response Body (raw): ' . substr( $response, 0, 1000 ) . '...' );
1064 }
1065 return [ 'stream' => false, 'headers' => $headers, 'data' => $response ];
1066 }
1067
1068 $data = json_decode( $response, true );
1069 $this->handle_response_errors( $data );
1070
1071 // Log the response if queries debug is enabled
1072 if ( $queries_debug && !$isStream ) {
1073 // Log the raw response as received from the AI service
1074 error_log( '[AI Engine Queries] <-- Response:' );
1075
1076 // Pretty print JSON if possible
1077 if ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) {
1078 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1079 }
1080 else {
1081 error_log( $response );
1082 }
1083 }
1084
1085 return [ 'headers' => $headers, 'data' => $data ];
1086 }
1087 catch ( Exception $e ) {
1088 $service = $this->get_service_name();
1089 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1090
1091 // Log error response if queries debug is enabled
1092 if ( $queries_debug ) {
1093 error_log( '[AI Engine Queries] Error occurred: ' . $e->getMessage() );
1094 }
1095
1096 throw $e;
1097 }
1098 finally {
1099 if ( $isStream && file_exists( $options['filename'] ) ) {
1100 unlink( $options['filename'] );
1101 }
1102 }
1103 }
1104
1105 private function get_audio( $url ) {
1106 require_once( ABSPATH . 'wp-admin/includes/media.php' );
1107
1108 // Validate URL scheme to prevent SSRF attacks
1109 $parts = wp_parse_url( $url );
1110 if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
1111 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
1112 }
1113
1114 // Use wp_safe_remote_get to block localhost/private IPs (SSRF protection)
1115 $response = wp_safe_remote_get( $url, [
1116 'timeout' => 60,
1117 'redirection' => 0 // Prevent redirect-based bypass
1118 ] );
1119 if ( is_wp_error( $response ) ) {
1120 throw new Exception( 'Failed to download audio: ' . $response->get_error_message() );
1121 }
1122 $audio_data = wp_remote_retrieve_body( $response );
1123 if ( empty( $audio_data ) ) {
1124 throw new Exception( 'Failed to download audio: empty response.' );
1125 }
1126
1127 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
1128 file_put_contents( $tmpFile, $audio_data );
1129 $length = null;
1130 $metadata = wp_read_audio_metadata( $tmpFile );
1131 if ( isset( $metadata['length'] ) ) {
1132 $length = $metadata['length'];
1133 }
1134 $data = file_get_contents( $tmpFile );
1135 unlink( $tmpFile );
1136 return [ 'data' => $data, 'length' => $length ];
1137 }
1138
1139 public function run_transcribe_query( $query ) {
1140 $audioData = null;
1141
1142 // Priority 1: Direct audio data
1143 if ( !empty( $query->audioData ) ) {
1144 $audioData = [
1145 'data' => $query->audioData,
1146 'length' => strlen( $query->audioData ) / 1024 // KB
1147 ];
1148 }
1149 // Priority 2: File path
1150 else if ( !empty( $query->path ) ) {
1151 // Last line of defense: REST callers never get to set this (the routes strip the
1152 // key), but simpleTranscribeAudio() is public PHP API, so a path can legitimately
1153 // arrive from site code. Confinement is not applied here on purpose: real callers
1154 // do transcribe files from outside the uploads directory. Stream wrappers are the
1155 // part that is never legitimate.
1156 $path = Meow_MWAI_Core::sanitize_file_path( $query->path );
1157 if ( !file_exists( $path ) ) {
1158 throw new Exception( 'Audio file not found: ' . $path );
1159 }
1160 if ( !is_readable( $path ) ) {
1161 throw new Exception( 'Audio file is not readable: ' . $path );
1162 }
1163 $audioData = [
1164 'data' => file_get_contents( $path ),
1165 'length' => filesize( $path ) / 1024 // KB
1166 ];
1167 }
1168 // Priority 3: Attached file object
1169 else if ( method_exists( $query, 'getAttachments' ) ) {
1170 $attachments = $query->getAttachments();
1171 if ( !empty( $attachments ) ) {
1172 $file = $attachments[0];
1173 $audioData = [
1174 'data' => $file->get_data(),
1175 'length' => strlen( $file->get_data() ) / 1024 // KB
1176 ];
1177 }
1178 }
1179 // Priority 4: URL (backward compatibility)
1180 else if ( !empty( $query->url ) ) {
1181 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1182 throw new Exception( 'Invalid URL for transcription.' );
1183 }
1184 $audioData = $this->get_audio( $query->url );
1185 }
1186 else {
1187 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1188 }
1189
1190 $body = $this->build_body( $query, null, $audioData['data'] );
1191 $url = $this->build_url( $query );
1192 $headers = $this->build_headers( $query );
1193 $options = $this->build_options( $headers, null, $body );
1194
1195 // Perform the request
1196 try {
1197 $res = $this->run_query( $url, $options );
1198 $data = $res['data'];
1199 if ( empty( $data ) ) {
1200 throw new Exception( 'Invalid data for transcription.' );
1201 }
1202 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1203 $reply = new Meow_MWAI_Reply( $query );
1204 $reply->set_usage( $usage );
1205 $reply->set_choices( $data );
1206 return $reply;
1207 }
1208 catch ( Exception $e ) {
1209 $service = $this->get_service_name();
1210 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1211 throw new Exception( "$service: " . $e->getMessage() );
1212 }
1213 }
1214
1215 public function run_embedding_query( $query ) {
1216 // Pre-validate token count against the embedding model's limit.
1217 // OpenAI embedding models (text-embedding-3-small/large, ada-002) all cap
1218 // at 8192 input tokens; sending a longer chunk yields an opaque
1219 // "Invalid 'input': maximum context length is 8192 tokens" error. Count
1220 // locally via tiktoken (bundled) so we fail with a clear message up front.
1221 $embedding_max_tokens = apply_filters( 'mwai_embedding_max_tokens', 8192, $query->model );
1222 if ( !empty( $query->message ) && $embedding_max_tokens > 0 ) {
1223 $token_count = Meow_MWAI_Core::estimate_tokens( $query->message, $query->model );
1224 if ( $token_count > $embedding_max_tokens ) {
1225 throw new Exception( sprintf(
1226 'Embedding input is %d tokens, exceeds the %d-token limit for %s. Split this entry into smaller chunks and try again.',
1227 $token_count,
1228 $embedding_max_tokens,
1229 $query->model
1230 ) );
1231 }
1232 }
1233
1234 $body = $this->build_body( $query );
1235 $url = $this->build_url( $query );
1236 $headers = $this->build_headers( $query );
1237 $options = $this->build_options( $headers, $body );
1238
1239 try {
1240 $res = $this->run_query( $url, $options );
1241 $data = $res['data'];
1242 if ( empty( $data ) || !isset( $data['data'] ) ) {
1243 throw new Exception( 'Invalid data for embedding.' );
1244 }
1245 $usage = $data['usage'];
1246 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1247 $reply = new Meow_MWAI_Reply( $query );
1248 $reply->set_usage( $usage );
1249 $reply->set_choices( $data['data'] );
1250 return $reply;
1251 }
1252 catch ( Exception $e ) {
1253 $message = $e->getMessage();
1254 $error = $this->try_decode_error( $message );
1255 if ( !is_null( $error ) ) {
1256 $message = $error;
1257 }
1258 $service = $this->get_service_name();
1259 Meow_MWAI_Logging::error( "$service: " . $message );
1260 throw new Exception( "$service: " . $message );
1261 }
1262 }
1263
1264 public function try_decode_error( $data ) {
1265 $json = json_decode( $data, true );
1266 return is_array( $json ) ? $this->extract_error_message( $json ) : null;
1267 }
1268
1269 /**
1270 * Pulls the human-readable message out of a decoded provider error body, or null
1271 * when the body does not look like an error. Two shapes exist in the wild:
1272 * - OpenAI-style, nested: {"error": {"message": "...", "type": "..."}} (or "error" as a string)
1273 * - Mistral-style, flat: {"object": "error", "message": "...", "type": "...", "code": "..."}
1274 */
1275 protected function extract_error_message( array $json ): ?string {
1276 if ( !empty( $json['error'] ) ) {
1277 $err = $json['error'];
1278 if ( is_array( $err ) ) {
1279 return isset( $err['message'] ) ? (string) $err['message'] : json_encode( $err );
1280 }
1281 return (string) $err;
1282 }
1283 $isFlatError = ( $json['object'] ?? '' ) === 'error' || ( isset( $json['type'] ) && !isset( $json['choices'] ) );
1284 if ( $isFlatError && !empty( $json['message'] ) && is_string( $json['message'] ) ) {
1285 return $json['message'];
1286 }
1287 return null;
1288 }
1289
1290 protected function finalize_choices( $choices, $responseData, $query ) {
1291 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1292 foreach ( $choices as &$choice ) {
1293 if ( isset( $choice['message'] ) ) {
1294 // If we have both tool_calls and function_call, remove function_call
1295 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1296 isset( $choice['message']['function_call'] ) ) {
1297 unset( $choice['message']['function_call'] );
1298 }
1299 }
1300 }
1301 return $choices;
1302 }
1303
1304 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1305 // Check if this is a GPT-5 model - they don't support Chat Completions API
1306 if ( $this->is_gpt5_model( $query->model ) ) {
1307 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1308 }
1309
1310 $isStreaming = !is_null( $streamCallback );
1311
1312 // Initialize debug mode
1313 $this->init_debug_mode( $query );
1314
1315 // Store the callback for event emission (both streaming and non-streaming debug mode)
1316 if ( !is_null( $streamCallback ) ) {
1317 $this->streamCallback = $streamCallback;
1318 }
1319
1320 if ( $isStreaming ) {
1321 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1322 }
1323
1324 $this->reset_stream();
1325 $body = $this->build_body( $query, $streamCallback );
1326 $url = $this->build_url( $query );
1327 $headers = $this->build_headers( $query );
1328 $options = $this->build_options( $headers, $body );
1329
1330 // Emit "Request sent" event for feedback queries
1331 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1332 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1333 $event = Meow_MWAI_Event::request_sent()
1334 ->set_metadata( 'is_feedback', true )
1335 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1336 call_user_func( $streamCallback, $event );
1337 }
1338
1339 try {
1340 $res = $this->run_query( $url, $options, $streamCallback );
1341 $reply = new Meow_MWAI_Reply( $query );
1342
1343 $returned_id = null;
1344 $returned_model = $this->inModel;
1345 $returned_in_tokens = null;
1346 $returned_out_tokens = null;
1347 $returned_price = null;
1348 $returned_choices = [];
1349
1350 // Streaming Mode
1351 if ( $isStreaming ) {
1352 if ( empty( $this->streamContent ) ) {
1353 $error = $this->try_decode_error( $this->streamBuffer );
1354 if ( !is_null( $error ) ) {
1355 throw new Exception( $error );
1356 }
1357 // A stream that ends without a single chunk (no id, no content, no
1358 // tool call, no thinking) is a provider failure, not a reply. OVH
1359 // answers 200 with a lone [DONE] when it rejects a parameter, which
1360 // used to be saved as a silent empty message.
1361 if ( empty( $this->streamToolCalls ) && empty( $this->streamFunctionCall ) &&
1362 empty( $this->streamThinking ) && is_null( $this->inId ) ) {
1363 throw new Exception( 'The AI service closed the stream without sending a reply. A parameter (often Max Tokens) may exceed what this model supports.' );
1364 }
1365 }
1366 $returned_id = $this->inId;
1367 $returned_model = $this->inModel ? $this->inModel : $query->model;
1368
1369 // Use regular content if available, otherwise fall back to thinking/reasoning
1370 $finalContent = $this->streamContent;
1371 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1372 // Use thinking content as fallback when there's no regular content
1373 // This happens with Ollama when it returns only reasoning/thinking
1374 // Wrap in asterisks to show as italics in markdown
1375 $finalContent = '*' . $this->streamThinking . '*';
1376
1377 // Log this for debugging
1378 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1379 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1380 }
1381 }
1382
1383 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1384 // Prefer tool_calls; fall back to legacy only if necessary
1385 if ( !empty( $this->streamToolCalls ) ) {
1386 $message['tool_calls'] = $this->streamToolCalls;
1387 }
1388 elseif ( !empty( $this->streamFunctionCall ) ) {
1389 $message['function_call'] = $this->streamFunctionCall;
1390 }
1391
1392 // Optionally include thinking as metadata if both content and thinking exist
1393 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1394 $message['thinking'] = $this->streamThinking;
1395 }
1396 if ( !is_null( $this->streamInTokens ) ) {
1397 $returned_in_tokens = $this->streamInTokens;
1398 }
1399 if ( !is_null( $this->streamOutTokens ) ) {
1400 $returned_out_tokens = $this->streamOutTokens;
1401 }
1402 if ( !is_null( $this->streamCost ) ) {
1403 $returned_price = $this->streamCost;
1404 }
1405 $returned_choices = [ [ 'message' => $message ] ];
1406 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1407
1408 // Log streaming response data if queries debug is enabled
1409 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1410 if ( $queries_debug ) {
1411 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1412 $streaming_data = [
1413 'id' => $returned_id,
1414 'model' => $returned_model,
1415 'content_length' => strlen( $this->streamContent ),
1416 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1417 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1418 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1419 'usage' => [
1420 'input_tokens' => $returned_in_tokens,
1421 'output_tokens' => $returned_out_tokens,
1422 'cost' => $returned_price
1423 ]
1424 ];
1425
1426 // Log tool calls details if present
1427 if ( !empty( $this->streamToolCalls ) ) {
1428 $streaming_data['tool_calls_details'] = [];
1429 foreach ( $this->streamToolCalls as $tool_call ) {
1430 $streaming_data['tool_calls_details'][] = [
1431 'id' => $tool_call['id'] ?? 'unknown',
1432 'name' => $tool_call['function']['name'] ?? 'unknown',
1433 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1434 ];
1435 }
1436 }
1437
1438 // Log function call if present
1439 if ( !empty( $this->streamFunctionCall ) ) {
1440 $streaming_data['function_call'] = [
1441 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1442 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1443 ];
1444 }
1445
1446 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1447 }
1448 }
1449 // Standard Mode
1450 else {
1451 $data = $res['data'];
1452 if ( empty( $data ) ) {
1453 throw new Exception( 'No content received (res is null).' );
1454 }
1455
1456 // Comprehensive logging for non-streaming mode - capture FULL response
1457 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1458 if ( $queries_debug ) {
1459 error_log( '[AI Engine Queries] ========================================' );
1460 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1461 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1462 error_log( '[AI Engine Queries] ========================================' );
1463
1464 // Look specifically for container_id
1465 $this->search_for_container_id_recursive( $data, '' );
1466 }
1467
1468 if ( !$data['model'] ) {
1469 $service = $this->get_service_name();
1470 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1471 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1472 throw new Exception( 'Invalid response (no model information).' );
1473 }
1474 $returned_id = $data['id'];
1475 $returned_model = $data['model'];
1476 $usage = $data['usage'] ?? [];
1477 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1478 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1479 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1480 $returned_choices = $data['choices'];
1481 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1482
1483 // Strip inline <think> reasoning (Qwen-style models) from the reply.
1484 foreach ( $returned_choices as &$choice ) {
1485 if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) ) {
1486 $choice['message']['content'] = self::strip_inline_thinking( $choice['message']['content'] );
1487 }
1488 }
1489 unset( $choice );
1490 }
1491
1492 // Set the results.
1493 $reply->set_choices( $returned_choices );
1494 if ( !empty( $returned_id ) ) {
1495 $reply->set_id( $returned_id );
1496 }
1497 if ( !empty( $returned_id ) ) {
1498 $reply->set_id( $returned_id );
1499 }
1500
1501 // Handle tokens.
1502 $this->handle_tokens_usage(
1503 $reply,
1504 $query,
1505 $returned_model,
1506 $returned_in_tokens,
1507 $returned_out_tokens,
1508 $returned_price
1509 );
1510
1511 return $reply;
1512 }
1513 catch ( Exception $e ) {
1514 $service = $this->get_service_name();
1515 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1516 $message = "$service: " . $e->getMessage();
1517 throw new Exception( $message );
1518 }
1519 finally {
1520 if ( !is_null( $streamCallback ) ) {
1521 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1522 }
1523 }
1524 }
1525
1526 public function handle_tokens_usage(
1527 $reply,
1528 $query,
1529 $returned_model,
1530 $returned_in_tokens,
1531 $returned_out_tokens,
1532 $returned_price = null
1533 ) {
1534 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1535 $reply->get_in_tokens( $query );
1536 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1537 $reply->get_out_tokens();
1538 $returned_price = !is_null( $returned_price ) ? $returned_price :
1539 $reply->get_price();
1540 $usage = $this->core->record_tokens_usage(
1541 $returned_model,
1542 $returned_in_tokens,
1543 $returned_out_tokens,
1544 $returned_price
1545 );
1546 $reply->set_usage( $usage );
1547
1548 // Store the actual model returned by the API
1549 $reply->set_model( $returned_model );
1550
1551 // Set default accuracy to 'estimated' for engines that don't override
1552 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1553 $reply->set_usage_accuracy( 'estimated' );
1554 }
1555
1556 // Request to the OpenAI Images API (gpt-image models, token-based pricing)
1557 public function run_image_query( $query, $streamCallback = null ) {
1558 $body = $this->build_body( $query );
1559 $url = $this->build_url( $query );
1560 $headers = $this->build_headers( $query );
1561 $options = $this->build_options( $headers, $body );
1562
1563 try {
1564 $res = $this->run_query( $url, $options );
1565 $data = $res['data'];
1566 $choices = $data['data'];
1567 $reply = new Meow_MWAI_Reply( $query );
1568 $model = $query->model;
1569
1570 // gpt-image models always return usage with input/output tokens
1571 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1572 $usage = $data['usage'] ?? [];
1573 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1574 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1575 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1576 $usage['queries'] = 1;
1577 $usage['accuracy'] = 'tokens';
1578 $reply->set_usage( $usage );
1579 $reply->set_usage_accuracy( 'tokens' );
1580
1581 $reply->set_choices( $choices );
1582 $reply->set_type( 'images' );
1583
1584 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1585 foreach ( $reply->results as &$result ) {
1586 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1587 'query_envId' => $query->envId,
1588 'query_session' => $query->session,
1589 'query_model' => $query->model,
1590 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1591 $fileUrl = $this->core->files->get_url( $fileId );
1592 $result = $fileUrl;
1593 }
1594 }
1595 $reply->result = $reply->results[0];
1596 return $reply;
1597 }
1598 catch ( Exception $e ) {
1599 $service = $this->get_service_name();
1600 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1601 throw new Exception( "$service: " . $e->getMessage() );
1602 }
1603 }
1604
1605 public function run_editimage_query( $query ) {
1606 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1607 if ( empty( $attachments ) ) {
1608 throw new Exception( 'No image provided for editing.' );
1609 }
1610 // Ensure the model supports image editing
1611 $modelInfo = $this->retrieve_model_info( $query->model );
1612 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1613 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1614 }
1615 $file = $attachments[0];
1616 $imageData = $file->get_data();
1617 $body = $this->build_body( $query, null, $imageData );
1618 $url = $this->build_url( $query );
1619 $headers = $this->build_headers( $query );
1620 $options = $this->build_options( $headers, null, $body );
1621
1622 try {
1623 $res = $this->run_query( $url, $options );
1624 $data = $res['data'];
1625 $choices = $data['data'];
1626 $reply = new Meow_MWAI_Reply( $query );
1627 $model = $query->model;
1628
1629 // gpt-image models always return usage with input/output tokens
1630 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1631 $usage = $data['usage'] ?? [];
1632 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1633 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1634 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1635 $usage['queries'] = 1;
1636 $usage['accuracy'] = 'tokens';
1637 $reply->set_usage( $usage );
1638 $reply->set_usage_accuracy( 'tokens' );
1639
1640 $reply->set_choices( $choices );
1641 $reply->set_type( 'images' );
1642
1643 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1644 foreach ( $reply->results as &$result ) {
1645 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1646 'query_envId' => $query->envId,
1647 'query_session' => $query->session,
1648 'query_model' => $query->model,
1649 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1650 $fileUrl = $this->core->files->get_url( $fileId );
1651 $result = $fileUrl;
1652 }
1653 }
1654 $reply->result = $reply->results[0];
1655 return $reply;
1656 }
1657 catch ( Exception $e ) {
1658 $service = $this->get_service_name();
1659 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1660 throw new Exception( "$service: " . $e->getMessage() );
1661 }
1662 }
1663
1664 /*
1665 This is the rest of the OpenAI API support, not related to the models directly.
1666 */
1667
1668 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1669 // OpenAI uses { error: { message: ... } }; some compatible providers (xAI, etc.) use a
1670 // plain string in the error field — handle both shapes.
1671 protected function handle_response_errors( $data ) {
1672 $message = is_array( $data ) ? $this->extract_error_message( $data ) : null;
1673 if ( is_null( $message ) ) {
1674 return;
1675 }
1676 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1677 $message = str_replace( $matches[1], '', $message );
1678 }
1679 throw new Exception( $message );
1680 }
1681
1682 public function list_files( $purposeFilter = null ) {
1683 if ( empty( $purposeFilter ) ) {
1684 return $this->execute( 'GET', '/files' );
1685 }
1686 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1687 }
1688
1689 public static function get_suffix_for_model( $model ) {
1690 // Legacy fine-tuned models
1691 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1692 if ( count( $matches ) > 0 ) {
1693 return $matches[1];
1694 }
1695
1696 // New fine-tuned models
1697 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1698 if ( count( $matches ) > 0 ) {
1699 return $matches[1];
1700 }
1701
1702 return 'N/A';
1703 }
1704
1705 public static function get_model_without_release_date( $model ) {
1706 if ( empty( $model ) ) {
1707 return null;
1708 }
1709 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1710 }
1711
1712 // 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.
1713 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1714 $finetunes = $this->list_finetunes( $legacy );
1715 $deleted = [];
1716
1717 foreach ( $finetunes as $finetune ) {
1718 $name = $finetune['model'];
1719 $isSucceeded = $finetune['status'] === 'succeeded';
1720 if ( $isSucceeded ) {
1721 try {
1722 $finetune = $this->get_model( $name );
1723 }
1724 catch ( Exception $e ) {
1725 $deleted[] = $name;
1726 }
1727 }
1728 }
1729 if ( $legacy ) {
1730 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1731 }
1732 else {
1733 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1734 }
1735 return $deleted;
1736 }
1737
1738 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1739 // retrieve all the models since Summer 2023, let's see! WIP.
1740 public function list_finetunes( $legacy = false ) {
1741 if ( $legacy ) {
1742 $res = $this->execute( 'GET', '/fine-tunes' );
1743 }
1744 else {
1745 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1746 }
1747 $finetunes = $res['data'];
1748
1749 // Add suffix
1750 $finetunes = array_map( function ( $finetune ) {
1751 if ( isset( $finetune['user_provided_suffix'] ) ) {
1752 $finetune['suffix'] = $finetune['user_provided_suffix'];
1753 }
1754 else {
1755 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1756 }
1757 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1758 if ( isset( $finetune['estimated_finish'] ) ) {
1759 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1760 }
1761 else {
1762 $finetune['estimatedOn'] = null;
1763 }
1764 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1765 $finetune['base_model'] = $finetune['model'];
1766 $finetune['model'] = $finetune['fine_tuned_model'];
1767 unset( $finetune['object'] );
1768 unset( $finetune['hyperparams'] );
1769 unset( $finetune['result_files'] );
1770 unset( $finetune['training_files'] );
1771 unset( $finetune['validation_files'] );
1772 unset( $finetune['created_at'] );
1773 unset( $finetune['updated_at'] );
1774 unset( $finetune['fine_tuned_model'] );
1775 return $finetune;
1776 }, $finetunes );
1777
1778 usort( $finetunes, function ( $a, $b ) {
1779 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1780 } );
1781
1782 if ( $legacy ) {
1783 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1784 }
1785 else {
1786 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1787 }
1788
1789 return $finetunes;
1790 }
1791
1792 public function moderate( $input ) {
1793 $result = $this->execute( 'POST', '/moderations', [
1794 'input' => $input
1795 ] );
1796 return $result;
1797 }
1798
1799 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1800 global $wp_filter;
1801
1802 // Build multipart form data
1803 $boundary = wp_generate_password( 24, false );
1804 $body = '';
1805 $body .= "--$boundary\r\n";
1806 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1807 $body .= $purpose . "\r\n";
1808 $body .= "--$boundary\r\n";
1809 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1810 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1811 $body .= $data . "\r\n";
1812 $body .= "--$boundary--\r\n";
1813
1814 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1815 // Save current hooks
1816 $saved_hooks = null;
1817 if ( isset( $wp_filter['http_api_curl'] ) ) {
1818 $saved_hooks = $wp_filter['http_api_curl'];
1819 unset( $wp_filter['http_api_curl'] );
1820 }
1821
1822 // Upload using WordPress HTTP API
1823 $url = 'https://api.openai.com/v1/files';
1824 $response = wp_remote_post( $url, [
1825 'headers' => [
1826 'Authorization' => 'Bearer ' . $this->apiKey,
1827 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1828 ],
1829 'body' => $body,
1830 'timeout' => 60
1831 ] );
1832
1833 // Restore hooks
1834 if ( $saved_hooks !== null ) {
1835 $wp_filter['http_api_curl'] = $saved_hooks;
1836 }
1837
1838 if ( is_wp_error( $response ) ) {
1839 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1840 }
1841
1842 $response_body = wp_remote_retrieve_body( $response );
1843 return json_decode( $response_body, true );
1844 }
1845
1846 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1847 $body = [
1848 'name' => !empty( $name ) ? $name : 'default',
1849 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1850 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1851 ];
1852 if ( $expiry !== 'never' ) {
1853 if ( is_string( $expiry ) ) {
1854 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1855 $expiry = 7;
1856 }
1857 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1858 if ( $expiry && is_numeric( $expiry ) ) {
1859 $body['expires_after'] = [
1860 'anchor' => 'last_active_at',
1861 'days' => $expiryInDays
1862 ];
1863 }
1864 }
1865 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1866 return $result['id'];
1867 }
1868
1869 public function get_vector_store( $vectorStoreId ) {
1870 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1871 }
1872
1873 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1874 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1875 'file_id' => $fileId
1876 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1877 return $result['id'];
1878
1879 }
1880
1881 public function delete_file( $fileId ) {
1882 return $this->execute( 'DELETE', '/files/' . $fileId );
1883 }
1884
1885 public function get_model( $modelId ) {
1886 return $this->execute( 'GET', '/models/' . $modelId );
1887 }
1888
1889 public function cancel_finetune( $fineTuneId ) {
1890 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1891 }
1892
1893 public function delete_finetune( $modelId ) {
1894 return $this->execute( 'DELETE', '/models/' . $modelId );
1895 }
1896
1897 public function download_file( $fileId, $newFile = null ) {
1898 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1899 $fileInfo = json_decode( (string) $fileInfo, true );
1900 if ( empty( $fileInfo ) ) {
1901 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1902 }
1903 $filename = $fileInfo['filename'];
1904 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1905 if ( empty( $newFile ) ) {
1906 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1907 $tempFile = wp_tempnam( $filename );
1908 if ( !$tempFile ) {
1909 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1910 }
1911 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1912 $newFile = $tempFile . '.' . $extension;
1913 }
1914 else {
1915 $newFile = $tempFile;
1916 }
1917 }
1918 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1919 file_put_contents( $newFile, $data );
1920 return $newFile;
1921 }
1922
1923 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1924 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1925 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1926 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1927 (float) $hyperparams['learningRateMultiplier'] : null;
1928 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1929 (float) $hyperparams['promptLossWeight'] : null;
1930 $arguments = [
1931 'training_file' => $fileId,
1932 'model' => $model,
1933 'suffix' => $suffix
1934 ];
1935 if ( $legacy ) {
1936 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1937 }
1938 else {
1939 if ( $n_epochs ) {
1940 $arguments['hyperparams'] = [];
1941 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1942 }
1943 if ( $batch_size ) {
1944 if ( empty( $arguments['hyperparams'] ) ) {
1945 $arguments['hyperparams'] = [];
1946 }
1947 $arguments['hyperparams']['batch_size'] = $batch_size;
1948 }
1949 if ( $learning_rate_multiplier ) {
1950 if ( empty( $arguments['hyperparams'] ) ) {
1951 $arguments['hyperparams'] = [];
1952 }
1953 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1954 }
1955 if ( $prompt_loss_weight ) {
1956 if ( empty( $arguments['hyperparams'] ) ) {
1957 $arguments['hyperparams'] = [];
1958 }
1959 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1960 }
1961 if ( $model === 'turbo' ) {
1962 $arguments['model'] = 'gpt-3.5-turbo';
1963 }
1964 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1965 }
1966 return $result;
1967 }
1968
1969 /**
1970 * Build the body of a form request.
1971 * If the field name is 'file', then the field value is the filename of the file to upload.
1972 * The file contents are taken from the 'data' field.
1973 *
1974 * @param array $fields
1975 * @param string $boundary
1976 * @return string
1977 */
1978 public function build_form_body( $fields, $boundary ) {
1979 $body = '';
1980 foreach ( $fields as $name => $value ) {
1981 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1982 continue;
1983 }
1984 $body .= "--$boundary\r\n";
1985 $body .= "Content-Disposition: form-data; name=\"$name\"";
1986 if ( $name === 'image' || $name === 'file' ) {
1987 $body .= "; filename=\"{$value}\"\r\n";
1988 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1989 $body .= "Content-Type: {$mime}\r\n\r\n";
1990 $body .= $fields['data'] . "\r\n";
1991 }
1992 else if ( $name === 'mask' ) {
1993 $body .= "; filename=\"{$value}\"\r\n";
1994 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1995 $body .= "Content-Type: {$mime}\r\n\r\n";
1996 $body .= $fields['mask_data'] . "\r\n";
1997 }
1998 else {
1999 // JSON encode arrays and objects, keep scalars as-is
2000 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
2001 $body .= "\r\n\r\n$encodedValue\r\n";
2002 }
2003 }
2004 $body .= "--$boundary--\r\n";
2005 return $body;
2006 }
2007
2008 /**
2009 * Run a request to the OpenAI API.
2010 * Fore more information about the $formFields, refer to the build_form_body method.
2011 *
2012 * @param string $method POST, PUT, GET, DELETE...
2013 * @param string $url The API endpoint
2014 * @param array $query The query parameters (json)
2015 * @param array $formFields The form fields (multipart/form-data)
2016 * @param bool $json Whether to return the response as json or not
2017 * @return array
2018 */
2019 public function execute(
2020 $method,
2021 $url,
2022 $query = null,
2023 $formFields = null,
2024 $json = true,
2025 $extraHeaders = null,
2026 $streamCallback = null
2027 ) {
2028 $isAzure = $this->envType === 'azure';
2029 $isOpenAI = !$isAzure;
2030
2031 // Prepare the headers
2032 $headers = "Content-Type: application/json\r\n";
2033 if ( $isOpenAI ) {
2034 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
2035 if ( $this->organizationId ) {
2036 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
2037 }
2038 }
2039 else if ( $isAzure ) {
2040 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
2041 }
2042
2043 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
2044 if ( !empty( $query ) && !is_string( $query ) ) {
2045 $body = $this->safe_json_encode( $query, 'query body' );
2046 }
2047 else {
2048 $body = $query;
2049 }
2050
2051 // If we have form fields, we need to change the headers and the body.
2052 if ( !empty( $formFields ) ) {
2053 $boundary = wp_generate_password( 24, false );
2054 $headers = [
2055 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2056 ];
2057 if ( $isOpenAI ) {
2058 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
2059 if ( $this->organizationId ) {
2060 $headers['OpenAI-Organization'] = $this->organizationId;
2061 }
2062 }
2063 else if ( $isAzure ) {
2064 $headers['api-key'] = $this->apiKey;
2065 }
2066 $body = $this->build_form_body( $formFields, $boundary );
2067 }
2068
2069 // Maybe we should have headers always as an array... not sure why we have it as a string.
2070 if ( !empty( $extraHeaders ) ) {
2071 foreach ( $extraHeaders as $key => $value ) {
2072 if ( is_array( $headers ) ) {
2073 $headers[$key] = $value;
2074 }
2075 else {
2076 $headers .= "$key: $value\r\n";
2077 }
2078 }
2079 }
2080
2081 // Create the URL
2082 if ( $isOpenAI ) {
2083 $url = 'https://api.openai.com/v1' . $url;
2084 }
2085 else if ( $isAzure ) {
2086 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
2087 $hasQuery = strpos( $url, '?' ) !== false;
2088 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
2089 }
2090
2091 // If it's a GET, body should be null, and we should append the query to the URL.
2092 if ( $method === 'GET' ) {
2093 if ( !empty( $query ) ) {
2094 $hasQuery = strpos( $url, '?' ) !== false;
2095 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
2096 }
2097 $body = null;
2098 }
2099
2100 $options = [
2101 'headers' => $headers,
2102 'method' => $method,
2103 'timeout' => MWAI_TIMEOUT,
2104 'body' => $body,
2105 'sslverify' => MWAI_SSL_VERIFY
2106 ];
2107
2108 // Check if queries debug is enabled
2109 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
2110
2111 // Log the request if queries debug is enabled
2112 if ( $queries_debug ) {
2113 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
2114
2115 if ( !empty( $body ) ) {
2116 error_log( '[AI Engine Queries] Request Body:' );
2117 error_log( $body );
2118 }
2119
2120 if ( !is_null( $streamCallback ) ) {
2121 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
2122 }
2123 }
2124
2125 try {
2126 if ( !is_null( $streamCallback ) ) {
2127 $options['stream'] = true;
2128 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
2129 // The stream handler calls the streamCallback every time there is content
2130 // TODO: For assistants, we should probably have a different stream handler to
2131 // handle the assistant's specific reply and perform the necessary actions.
2132 $this->streamCallback = $streamCallback;
2133 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
2134 }
2135 $res = wp_remote_request( $url, $options );
2136 if ( is_wp_error( $res ) ) {
2137 throw new Exception( $res->get_error_message() );
2138 }
2139
2140 $res = wp_remote_retrieve_body( $res );
2141
2142 // Handle empty responses for container LIST API only (not for file content downloads)
2143 if ( strpos( $url, '/containers/' ) !== false &&
2144 strpos( $url, '/files' ) !== false &&
2145 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
2146 empty( $res ) ) {
2147 // Return empty array for empty container files LIST response
2148 $data = $json ? [] : '';
2149 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
2150 }
2151 else {
2152 $data = $json ? json_decode( $res, true ) : $res;
2153 }
2154
2155 // Debug logging for decoded data (skip for content downloads)
2156 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
2157 error_log( '[AI Engine] After json_decode:' );
2158 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
2159 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
2160 if ( $data !== null && is_array( $data ) ) {
2161 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
2162 error_log( '[AI Engine] - Data count: ' . count( $data ) );
2163 }
2164 if ( $json && $data === null && !empty( $res ) ) {
2165 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
2166 }
2167 }
2168
2169 $this->handle_response_errors( $data );
2170
2171 // Log the response if queries debug is enabled
2172 if ( $queries_debug && is_null( $streamCallback ) ) {
2173 error_log( '[AI Engine Queries] Response Body:' );
2174 error_log( $res );
2175 }
2176 return $data;
2177 }
2178 catch ( Exception $e ) {
2179 $service = $this->get_service_name();
2180 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
2181 throw new Exception( "$service: " . $e->getMessage() );
2182 }
2183 finally {
2184 if ( !is_null( $streamCallback ) ) {
2185 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2186 }
2187 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
2188 unlink( $options['filename'] );
2189 }
2190 }
2191 }
2192
2193 public function get_models() {
2194 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
2195 // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
2196 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
2197 foreach ( $finetunes as $finetune ) {
2198 if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
2199 continue;
2200 }
2201 if ( empty( $finetune['base_model'] ) || empty( $finetune['model'] ) || empty( $finetune['suffix'] ) ) {
2202 continue;
2203 }
2204 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
2205 if ( !empty( $baseModel ) ) {
2206 $model = null;
2207 foreach ( $models as $currentModel ) {
2208 if ( $currentModel['model'] === $baseModel ) {
2209 $model = $currentModel;
2210 break;
2211 }
2212 }
2213 if ( !empty( $model ) ) {
2214 $model['model'] = $finetune['model'];
2215 $model['name'] = $finetune['suffix'];
2216 $models[] = $model;
2217 }
2218 }
2219 }
2220 return $models;
2221 }
2222
2223 public static function get_models_static() {
2224 return MWAI_OPENAI_MODELS;
2225 }
2226
2227 /**
2228 * Recursively search for container_id in the response data
2229 */
2230 protected function search_for_container_id_recursive( $data, $path = '' ) {
2231 if ( is_array( $data ) || is_object( $data ) ) {
2232 foreach ( $data as $key => $value ) {
2233 $currentPath = $path ? $path . '.' . $key : $key;
2234
2235 // Check if this key is container_id
2236 if ( $key === 'container_id' ) {
2237 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2238 }
2239
2240 // Recursively search in nested structures
2241 if ( is_array( $value ) || is_object( $value ) ) {
2242 $this->search_for_container_id_recursive( $value, $currentPath );
2243 }
2244 }
2245 }
2246 }
2247
2248 // 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().
2249 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2250 $modelFamily = self::get_model_without_release_date( $modelFamily );
2251 $models = $this->get_models();
2252 foreach ( $models as $currentModel ) {
2253 if ( $currentModel['model'] === $modelFamily ) {
2254 if ( $finetune ) {
2255 if ( isset( $currentModel['finetune']['price'] ) ) {
2256 $currentModel['price'] = $currentModel['finetune']['price'];
2257 }
2258 else if ( isset( $currentModel['finetune']['in'] ) ) {
2259 $currentModel['price'] = [
2260 'in' => $currentModel['finetune']['in'],
2261 'out' => $currentModel['finetune']['out']
2262 ];
2263 }
2264 }
2265 $inPrice = $currentModel['price'];
2266 $outPrice = $currentModel['price'];
2267 if ( is_array( $currentModel['price'] ) ) {
2268 $inPrice = $currentModel['price']['in'];
2269 $outPrice = $currentModel['price']['out'];
2270 }
2271 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2272 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2273 return $inTotalPrice + $outTotalPrice;
2274 }
2275 }
2276 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2277 return null;
2278 }
2279
2280 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2281 $model = $query->model;
2282 $units = 0;
2283 $finetune = false;
2284 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2285 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2286 $finetune = true;
2287 }
2288 $inUnits = $reply->get_in_tokens( $query );
2289 $outUnits = $reply->get_out_tokens();
2290 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2291 }
2292 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2293 // gpt-image models are billed by input/output tokens like text models.
2294 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2295 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2296 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2297 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2298 }
2299 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2300 $model = 'whisper';
2301 $units = $reply->get_units();
2302 return $this->calculate_price( $model, 0, $units, null, $finetune );
2303 }
2304 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2305 $units = $reply->get_total_tokens();
2306 return $this->calculate_price( $model, 0, $units, null, $finetune );
2307 }
2308 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2309 return null;
2310 }
2311 }
2312