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