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