PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
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 17 hours ago chatml.php 1 week ago core.php 2 days ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 2 days ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
chatml.php
2154 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 throw new Exception( 'The model\'s API URL was not found: ' . $url );
899 }
900 else if ( $responseCode === 400 ) {
901 error_log( '[AI Engine] 400 Bad Request - Full response body: ' . $errorBody );
902 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
903 if ( empty( $message ) ) {
904 $message = 'Bad Request';
905 }
906 throw new Exception( $message );
907 }
908 else if ( $responseCode === 422 ) {
909 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
910 if ( empty( $message ) ) {
911 $message = 'Unprocessable Entity';
912 }
913 throw new Exception( $message );
914 }
915 else if ( $responseCode === 500 ) {
916 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
917 if ( empty( $message ) ) {
918 $message = 'Internal Server Error';
919 }
920 throw new Exception( $message );
921 }
922
923 if ( $isStream ) {
924 return [ 'stream' => true ];
925 }
926
927 $response = wp_remote_retrieve_body( $res );
928 $headersRes = wp_remote_retrieve_headers( $res );
929 $headers = $headersRes->getAll();
930
931 // Check if Content-Type is 'multipart/form-data' or 'text/plain'
932 // If so, we don't need to decode the response
933 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
934 $resContentType = $normalizedHeaders['content-type'] ?? '';
935 if ( strpos( $resContentType, 'multipart/form-data' ) !== false || strpos( $resContentType, 'text/plain' ) !== false ) {
936 // Log the response if queries debug is enabled
937 if ( $queries_debug && !$isStream ) {
938 error_log( '[AI Engine Queries] Response Headers: ' . json_encode( $headers ) );
939 error_log( '[AI Engine Queries] Response Body (raw): ' . substr( $response, 0, 1000 ) . '...' );
940 }
941 return [ 'stream' => false, 'headers' => $headers, 'data' => $response ];
942 }
943
944 $data = json_decode( $response, true );
945 $this->handle_response_errors( $data );
946
947 // Log the response if queries debug is enabled
948 if ( $queries_debug && !$isStream ) {
949 // Log the raw response as received from the AI service
950 error_log( '[AI Engine Queries] <-- Response:' );
951
952 // Pretty print JSON if possible
953 if ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) {
954 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
955 }
956 else {
957 error_log( $response );
958 }
959 }
960
961 return [ 'headers' => $headers, 'data' => $data ];
962 }
963 catch ( Exception $e ) {
964 $service = $this->get_service_name();
965 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
966
967 // Log error response if queries debug is enabled
968 if ( $queries_debug ) {
969 error_log( '[AI Engine Queries] Error occurred: ' . $e->getMessage() );
970 }
971
972 throw $e;
973 }
974 finally {
975 if ( $isStream && file_exists( $options['filename'] ) ) {
976 unlink( $options['filename'] );
977 }
978 }
979 }
980
981 private function get_audio( $url ) {
982 require_once( ABSPATH . 'wp-admin/includes/media.php' );
983
984 // Validate URL scheme to prevent SSRF attacks
985 $parts = wp_parse_url( $url );
986 if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
987 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
988 }
989
990 // Use wp_safe_remote_get to block localhost/private IPs (SSRF protection)
991 $response = wp_safe_remote_get( $url, [
992 'timeout' => 60,
993 'redirection' => 0 // Prevent redirect-based bypass
994 ] );
995 if ( is_wp_error( $response ) ) {
996 throw new Exception( 'Failed to download audio: ' . $response->get_error_message() );
997 }
998 $audio_data = wp_remote_retrieve_body( $response );
999 if ( empty( $audio_data ) ) {
1000 throw new Exception( 'Failed to download audio: empty response.' );
1001 }
1002
1003 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
1004 file_put_contents( $tmpFile, $audio_data );
1005 $length = null;
1006 $metadata = wp_read_audio_metadata( $tmpFile );
1007 if ( isset( $metadata['length'] ) ) {
1008 $length = $metadata['length'];
1009 }
1010 $data = file_get_contents( $tmpFile );
1011 unlink( $tmpFile );
1012 return [ 'data' => $data, 'length' => $length ];
1013 }
1014
1015 public function run_transcribe_query( $query ) {
1016 $audioData = null;
1017
1018 // Priority 1: Direct audio data
1019 if ( !empty( $query->audioData ) ) {
1020 $audioData = [
1021 'data' => $query->audioData,
1022 'length' => strlen( $query->audioData ) / 1024 // KB
1023 ];
1024 }
1025 // Priority 2: File path
1026 else if ( !empty( $query->path ) ) {
1027 if ( !file_exists( $query->path ) ) {
1028 throw new Exception( 'Audio file not found: ' . $query->path );
1029 }
1030 if ( !is_readable( $query->path ) ) {
1031 throw new Exception( 'Audio file is not readable: ' . $query->path );
1032 }
1033 $audioData = [
1034 'data' => file_get_contents( $query->path ),
1035 'length' => filesize( $query->path ) / 1024 // KB
1036 ];
1037 }
1038 // Priority 3: Attached file object
1039 else if ( method_exists( $query, 'getAttachments' ) ) {
1040 $attachments = $query->getAttachments();
1041 if ( !empty( $attachments ) ) {
1042 $file = $attachments[0];
1043 $audioData = [
1044 'data' => $file->get_data(),
1045 'length' => strlen( $file->get_data() ) / 1024 // KB
1046 ];
1047 }
1048 }
1049 // Priority 4: URL (backward compatibility)
1050 else if ( !empty( $query->url ) ) {
1051 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1052 throw new Exception( 'Invalid URL for transcription.' );
1053 }
1054 $audioData = $this->get_audio( $query->url );
1055 }
1056 else {
1057 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1058 }
1059
1060 $body = $this->build_body( $query, null, $audioData['data'] );
1061 $url = $this->build_url( $query );
1062 $headers = $this->build_headers( $query );
1063 $options = $this->build_options( $headers, null, $body );
1064
1065 // Perform the request
1066 try {
1067 $res = $this->run_query( $url, $options );
1068 $data = $res['data'];
1069 if ( empty( $data ) ) {
1070 throw new Exception( 'Invalid data for transcription.' );
1071 }
1072 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1073 $reply = new Meow_MWAI_Reply( $query );
1074 $reply->set_usage( $usage );
1075 $reply->set_choices( $data );
1076 return $reply;
1077 }
1078 catch ( Exception $e ) {
1079 $service = $this->get_service_name();
1080 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1081 throw new Exception( "$service: " . $e->getMessage() );
1082 }
1083 }
1084
1085 public function run_embedding_query( $query ) {
1086 // Pre-validate token count against the embedding model's limit.
1087 // OpenAI embedding models (text-embedding-3-small/large, ada-002) all cap
1088 // at 8192 input tokens; sending a longer chunk yields an opaque
1089 // "Invalid 'input': maximum context length is 8192 tokens" error. Count
1090 // locally via tiktoken (bundled) so we fail with a clear message up front.
1091 $embedding_max_tokens = apply_filters( 'mwai_embedding_max_tokens', 8192, $query->model );
1092 if ( !empty( $query->message ) && $embedding_max_tokens > 0 ) {
1093 $token_count = Meow_MWAI_Core::estimate_tokens( $query->message, $query->model );
1094 if ( $token_count > $embedding_max_tokens ) {
1095 throw new Exception( sprintf(
1096 'Embedding input is %d tokens, exceeds the %d-token limit for %s. Split this entry into smaller chunks and try again.',
1097 $token_count,
1098 $embedding_max_tokens,
1099 $query->model
1100 ) );
1101 }
1102 }
1103
1104 $body = $this->build_body( $query );
1105 $url = $this->build_url( $query );
1106 $headers = $this->build_headers( $query );
1107 $options = $this->build_options( $headers, $body );
1108
1109 try {
1110 $res = $this->run_query( $url, $options );
1111 $data = $res['data'];
1112 if ( empty( $data ) || !isset( $data['data'] ) ) {
1113 throw new Exception( 'Invalid data for embedding.' );
1114 }
1115 $usage = $data['usage'];
1116 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1117 $reply = new Meow_MWAI_Reply( $query );
1118 $reply->set_usage( $usage );
1119 $reply->set_choices( $data['data'] );
1120 return $reply;
1121 }
1122 catch ( Exception $e ) {
1123 $message = $e->getMessage();
1124 $error = $this->try_decode_error( $message );
1125 if ( !is_null( $error ) ) {
1126 $message = $error;
1127 }
1128 $service = $this->get_service_name();
1129 Meow_MWAI_Logging::error( "$service: " . $message );
1130 throw new Exception( "$service: " . $message );
1131 }
1132 }
1133
1134 public function try_decode_error( $data ) {
1135 $json = json_decode( $data, true );
1136 if ( isset( $json['error']['message'] ) ) {
1137 return $json['error']['message'];
1138 }
1139 return null;
1140 }
1141
1142 protected function finalize_choices( $choices, $responseData, $query ) {
1143 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1144 foreach ( $choices as &$choice ) {
1145 if ( isset( $choice['message'] ) ) {
1146 // If we have both tool_calls and function_call, remove function_call
1147 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1148 isset( $choice['message']['function_call'] ) ) {
1149 unset( $choice['message']['function_call'] );
1150 }
1151 }
1152 }
1153 return $choices;
1154 }
1155
1156 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1157 // Check if this is a GPT-5 model - they don't support Chat Completions API
1158 if ( $this->is_gpt5_model( $query->model ) ) {
1159 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1160 }
1161
1162 $isStreaming = !is_null( $streamCallback );
1163
1164 // Initialize debug mode
1165 $this->init_debug_mode( $query );
1166
1167 // Store the callback for event emission (both streaming and non-streaming debug mode)
1168 if ( !is_null( $streamCallback ) ) {
1169 $this->streamCallback = $streamCallback;
1170 }
1171
1172 if ( $isStreaming ) {
1173 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1174 }
1175
1176 $this->reset_stream();
1177 $body = $this->build_body( $query, $streamCallback );
1178 $url = $this->build_url( $query );
1179 $headers = $this->build_headers( $query );
1180 $options = $this->build_options( $headers, $body );
1181
1182 // Emit "Request sent" event for feedback queries
1183 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1184 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1185 $event = Meow_MWAI_Event::request_sent()
1186 ->set_metadata( 'is_feedback', true )
1187 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1188 call_user_func( $streamCallback, $event );
1189 }
1190
1191 try {
1192 $res = $this->run_query( $url, $options, $streamCallback );
1193 $reply = new Meow_MWAI_Reply( $query );
1194
1195 $returned_id = null;
1196 $returned_model = $this->inModel;
1197 $returned_in_tokens = null;
1198 $returned_out_tokens = null;
1199 $returned_price = null;
1200 $returned_choices = [];
1201
1202 // Streaming Mode
1203 if ( $isStreaming ) {
1204 if ( empty( $this->streamContent ) ) {
1205 $error = $this->try_decode_error( $this->streamBuffer );
1206 if ( !is_null( $error ) ) {
1207 throw new Exception( $error );
1208 }
1209 }
1210 $returned_id = $this->inId;
1211 $returned_model = $this->inModel ? $this->inModel : $query->model;
1212
1213 // Use regular content if available, otherwise fall back to thinking/reasoning
1214 $finalContent = $this->streamContent;
1215 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1216 // Use thinking content as fallback when there's no regular content
1217 // This happens with Ollama when it returns only reasoning/thinking
1218 // Wrap in asterisks to show as italics in markdown
1219 $finalContent = '*' . $this->streamThinking . '*';
1220
1221 // Log this for debugging
1222 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1223 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1224 }
1225 }
1226
1227 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1228 // Prefer tool_calls; fall back to legacy only if necessary
1229 if ( !empty( $this->streamToolCalls ) ) {
1230 $message['tool_calls'] = $this->streamToolCalls;
1231 }
1232 elseif ( !empty( $this->streamFunctionCall ) ) {
1233 $message['function_call'] = $this->streamFunctionCall;
1234 }
1235
1236 // Optionally include thinking as metadata if both content and thinking exist
1237 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1238 $message['thinking'] = $this->streamThinking;
1239 }
1240 if ( !is_null( $this->streamInTokens ) ) {
1241 $returned_in_tokens = $this->streamInTokens;
1242 }
1243 if ( !is_null( $this->streamOutTokens ) ) {
1244 $returned_out_tokens = $this->streamOutTokens;
1245 }
1246 if ( !is_null( $this->streamCost ) ) {
1247 $returned_price = $this->streamCost;
1248 }
1249 $returned_choices = [ [ 'message' => $message ] ];
1250 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1251
1252 // Log streaming response data if queries debug is enabled
1253 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1254 if ( $queries_debug ) {
1255 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1256 $streaming_data = [
1257 'id' => $returned_id,
1258 'model' => $returned_model,
1259 'content_length' => strlen( $this->streamContent ),
1260 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1261 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1262 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1263 'usage' => [
1264 'input_tokens' => $returned_in_tokens,
1265 'output_tokens' => $returned_out_tokens,
1266 'cost' => $returned_price
1267 ]
1268 ];
1269
1270 // Log tool calls details if present
1271 if ( !empty( $this->streamToolCalls ) ) {
1272 $streaming_data['tool_calls_details'] = [];
1273 foreach ( $this->streamToolCalls as $tool_call ) {
1274 $streaming_data['tool_calls_details'][] = [
1275 'id' => $tool_call['id'] ?? 'unknown',
1276 'name' => $tool_call['function']['name'] ?? 'unknown',
1277 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1278 ];
1279 }
1280 }
1281
1282 // Log function call if present
1283 if ( !empty( $this->streamFunctionCall ) ) {
1284 $streaming_data['function_call'] = [
1285 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1286 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1287 ];
1288 }
1289
1290 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1291 }
1292 }
1293 // Standard Mode
1294 else {
1295 $data = $res['data'];
1296 if ( empty( $data ) ) {
1297 throw new Exception( 'No content received (res is null).' );
1298 }
1299
1300 // Comprehensive logging for non-streaming mode - capture FULL response
1301 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1302 if ( $queries_debug ) {
1303 error_log( '[AI Engine Queries] ========================================' );
1304 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1305 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1306 error_log( '[AI Engine Queries] ========================================' );
1307
1308 // Look specifically for container_id
1309 $this->search_for_container_id_recursive( $data, '' );
1310 }
1311
1312 if ( !$data['model'] ) {
1313 $service = $this->get_service_name();
1314 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1315 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1316 throw new Exception( 'Invalid response (no model information).' );
1317 }
1318 $returned_id = $data['id'];
1319 $returned_model = $data['model'];
1320 $usage = $data['usage'] ?? [];
1321 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1322 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1323 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1324 $returned_choices = $data['choices'];
1325 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1326 }
1327
1328 // Set the results.
1329 $reply->set_choices( $returned_choices );
1330 if ( !empty( $returned_id ) ) {
1331 $reply->set_id( $returned_id );
1332 }
1333 if ( !empty( $returned_id ) ) {
1334 $reply->set_id( $returned_id );
1335 }
1336
1337 // Handle tokens.
1338 $this->handle_tokens_usage(
1339 $reply,
1340 $query,
1341 $returned_model,
1342 $returned_in_tokens,
1343 $returned_out_tokens,
1344 $returned_price
1345 );
1346
1347 return $reply;
1348 }
1349 catch ( Exception $e ) {
1350 $service = $this->get_service_name();
1351 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1352 $message = "$service: " . $e->getMessage();
1353 throw new Exception( $message );
1354 }
1355 finally {
1356 if ( !is_null( $streamCallback ) ) {
1357 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1358 }
1359 }
1360 }
1361
1362 public function handle_tokens_usage(
1363 $reply,
1364 $query,
1365 $returned_model,
1366 $returned_in_tokens,
1367 $returned_out_tokens,
1368 $returned_price = null
1369 ) {
1370 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1371 $reply->get_in_tokens( $query );
1372 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1373 $reply->get_out_tokens();
1374 $returned_price = !is_null( $returned_price ) ? $returned_price :
1375 $reply->get_price();
1376 $usage = $this->core->record_tokens_usage(
1377 $returned_model,
1378 $returned_in_tokens,
1379 $returned_out_tokens,
1380 $returned_price
1381 );
1382 $reply->set_usage( $usage );
1383
1384 // Store the actual model returned by the API
1385 $reply->set_model( $returned_model );
1386
1387 // Set default accuracy to 'estimated' for engines that don't override
1388 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1389 $reply->set_usage_accuracy( 'estimated' );
1390 }
1391
1392 // Request to the OpenAI Images API (gpt-image models, token-based pricing)
1393 public function run_image_query( $query, $streamCallback = null ) {
1394 $body = $this->build_body( $query );
1395 $url = $this->build_url( $query );
1396 $headers = $this->build_headers( $query );
1397 $options = $this->build_options( $headers, $body );
1398
1399 try {
1400 $res = $this->run_query( $url, $options );
1401 $data = $res['data'];
1402 $choices = $data['data'];
1403 $reply = new Meow_MWAI_Reply( $query );
1404 $model = $query->model;
1405
1406 // gpt-image models always return usage with input/output tokens
1407 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1408 $usage = $data['usage'] ?? [];
1409 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1410 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1411 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1412 $usage['queries'] = 1;
1413 $usage['accuracy'] = 'tokens';
1414 $reply->set_usage( $usage );
1415 $reply->set_usage_accuracy( 'tokens' );
1416
1417 $reply->set_choices( $choices );
1418 $reply->set_type( 'images' );
1419
1420 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1421 foreach ( $reply->results as &$result ) {
1422 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1423 'query_envId' => $query->envId,
1424 'query_session' => $query->session,
1425 'query_model' => $query->model,
1426 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1427 $fileUrl = $this->core->files->get_url( $fileId );
1428 $result = $fileUrl;
1429 }
1430 }
1431 $reply->result = $reply->results[0];
1432 return $reply;
1433 }
1434 catch ( Exception $e ) {
1435 $service = $this->get_service_name();
1436 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1437 throw new Exception( "$service: " . $e->getMessage() );
1438 }
1439 }
1440
1441 public function run_editimage_query( $query ) {
1442 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1443 if ( empty( $attachments ) ) {
1444 throw new Exception( 'No image provided for editing.' );
1445 }
1446 // Ensure the model supports image editing
1447 $modelInfo = $this->retrieve_model_info( $query->model );
1448 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1449 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1450 }
1451 $file = $attachments[0];
1452 $imageData = $file->get_data();
1453 $body = $this->build_body( $query, null, $imageData );
1454 $url = $this->build_url( $query );
1455 $headers = $this->build_headers( $query );
1456 $options = $this->build_options( $headers, null, $body );
1457
1458 try {
1459 $res = $this->run_query( $url, $options );
1460 $data = $res['data'];
1461 $choices = $data['data'];
1462 $reply = new Meow_MWAI_Reply( $query );
1463 $model = $query->model;
1464
1465 // gpt-image models always return usage with input/output tokens
1466 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1467 $usage = $data['usage'] ?? [];
1468 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1469 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1470 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1471 $usage['queries'] = 1;
1472 $usage['accuracy'] = 'tokens';
1473 $reply->set_usage( $usage );
1474 $reply->set_usage_accuracy( 'tokens' );
1475
1476 $reply->set_choices( $choices );
1477 $reply->set_type( 'images' );
1478
1479 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1480 foreach ( $reply->results as &$result ) {
1481 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1482 'query_envId' => $query->envId,
1483 'query_session' => $query->session,
1484 'query_model' => $query->model,
1485 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1486 $fileUrl = $this->core->files->get_url( $fileId );
1487 $result = $fileUrl;
1488 }
1489 }
1490 $reply->result = $reply->results[0];
1491 return $reply;
1492 }
1493 catch ( Exception $e ) {
1494 $service = $this->get_service_name();
1495 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1496 throw new Exception( "$service: " . $e->getMessage() );
1497 }
1498 }
1499
1500 /*
1501 This is the rest of the OpenAI API support, not related to the models directly.
1502 */
1503
1504 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1505 // OpenAI uses { error: { message: ... } }; some compatible providers (xAI, etc.) use a
1506 // plain string in the error field — handle both shapes.
1507 protected function handle_response_errors( $data ) {
1508 if ( !isset( $data['error'] ) || empty( $data['error'] ) ) {
1509 return;
1510 }
1511 $err = $data['error'];
1512 if ( is_array( $err ) ) {
1513 $message = $err['message'] ?? json_encode( $err );
1514 }
1515 else {
1516 $message = (string) $err;
1517 }
1518 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1519 $message = str_replace( $matches[1], '', $message );
1520 }
1521 throw new Exception( $message );
1522 }
1523
1524 public function list_files( $purposeFilter = null ) {
1525 if ( empty( $purposeFilter ) ) {
1526 return $this->execute( 'GET', '/files' );
1527 }
1528 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1529 }
1530
1531 public static function get_suffix_for_model( $model ) {
1532 // Legacy fine-tuned models
1533 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1534 if ( count( $matches ) > 0 ) {
1535 return $matches[1];
1536 }
1537
1538 // New fine-tuned models
1539 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1540 if ( count( $matches ) > 0 ) {
1541 return $matches[1];
1542 }
1543
1544 return 'N/A';
1545 }
1546
1547 public static function get_model_without_release_date( $model ) {
1548 if ( empty( $model ) ) {
1549 return null;
1550 }
1551 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1552 }
1553
1554 // 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.
1555 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1556 $finetunes = $this->list_finetunes( $legacy );
1557 $deleted = [];
1558
1559 foreach ( $finetunes as $finetune ) {
1560 $name = $finetune['model'];
1561 $isSucceeded = $finetune['status'] === 'succeeded';
1562 if ( $isSucceeded ) {
1563 try {
1564 $finetune = $this->get_model( $name );
1565 }
1566 catch ( Exception $e ) {
1567 $deleted[] = $name;
1568 }
1569 }
1570 }
1571 if ( $legacy ) {
1572 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1573 }
1574 else {
1575 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1576 }
1577 return $deleted;
1578 }
1579
1580 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1581 // retrieve all the models since Summer 2023, let's see! WIP.
1582 public function list_finetunes( $legacy = false ) {
1583 if ( $legacy ) {
1584 $res = $this->execute( 'GET', '/fine-tunes' );
1585 }
1586 else {
1587 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1588 }
1589 $finetunes = $res['data'];
1590
1591 // Add suffix
1592 $finetunes = array_map( function ( $finetune ) {
1593 if ( isset( $finetune['user_provided_suffix'] ) ) {
1594 $finetune['suffix'] = $finetune['user_provided_suffix'];
1595 }
1596 else {
1597 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1598 }
1599 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1600 if ( isset( $finetune['estimated_finish'] ) ) {
1601 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1602 }
1603 else {
1604 $finetune['estimatedOn'] = null;
1605 }
1606 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1607 $finetune['base_model'] = $finetune['model'];
1608 $finetune['model'] = $finetune['fine_tuned_model'];
1609 unset( $finetune['object'] );
1610 unset( $finetune['hyperparams'] );
1611 unset( $finetune['result_files'] );
1612 unset( $finetune['training_files'] );
1613 unset( $finetune['validation_files'] );
1614 unset( $finetune['created_at'] );
1615 unset( $finetune['updated_at'] );
1616 unset( $finetune['fine_tuned_model'] );
1617 return $finetune;
1618 }, $finetunes );
1619
1620 usort( $finetunes, function ( $a, $b ) {
1621 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1622 } );
1623
1624 if ( $legacy ) {
1625 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1626 }
1627 else {
1628 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1629 }
1630
1631 return $finetunes;
1632 }
1633
1634 public function moderate( $input ) {
1635 $result = $this->execute( 'POST', '/moderations', [
1636 'input' => $input
1637 ] );
1638 return $result;
1639 }
1640
1641 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1642 global $wp_filter;
1643
1644 // Build multipart form data
1645 $boundary = wp_generate_password( 24, false );
1646 $body = '';
1647 $body .= "--$boundary\r\n";
1648 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1649 $body .= $purpose . "\r\n";
1650 $body .= "--$boundary\r\n";
1651 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1652 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1653 $body .= $data . "\r\n";
1654 $body .= "--$boundary--\r\n";
1655
1656 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1657 // Save current hooks
1658 $saved_hooks = null;
1659 if ( isset( $wp_filter['http_api_curl'] ) ) {
1660 $saved_hooks = $wp_filter['http_api_curl'];
1661 unset( $wp_filter['http_api_curl'] );
1662 }
1663
1664 // Upload using WordPress HTTP API
1665 $url = 'https://api.openai.com/v1/files';
1666 $response = wp_remote_post( $url, [
1667 'headers' => [
1668 'Authorization' => 'Bearer ' . $this->apiKey,
1669 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1670 ],
1671 'body' => $body,
1672 'timeout' => 60
1673 ] );
1674
1675 // Restore hooks
1676 if ( $saved_hooks !== null ) {
1677 $wp_filter['http_api_curl'] = $saved_hooks;
1678 }
1679
1680 if ( is_wp_error( $response ) ) {
1681 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1682 }
1683
1684 $response_body = wp_remote_retrieve_body( $response );
1685 return json_decode( $response_body, true );
1686 }
1687
1688 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1689 $body = [
1690 'name' => !empty( $name ) ? $name : 'default',
1691 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1692 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1693 ];
1694 if ( $expiry !== 'never' ) {
1695 if ( is_string( $expiry ) ) {
1696 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1697 $expiry = 7;
1698 }
1699 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1700 if ( $expiry && is_numeric( $expiry ) ) {
1701 $body['expires_after'] = [
1702 'anchor' => 'last_active_at',
1703 'days' => $expiryInDays
1704 ];
1705 }
1706 }
1707 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1708 return $result['id'];
1709 }
1710
1711 public function get_vector_store( $vectorStoreId ) {
1712 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1713 }
1714
1715 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1716 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1717 'file_id' => $fileId
1718 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1719 return $result['id'];
1720
1721 }
1722
1723 public function delete_file( $fileId ) {
1724 return $this->execute( 'DELETE', '/files/' . $fileId );
1725 }
1726
1727 public function get_model( $modelId ) {
1728 return $this->execute( 'GET', '/models/' . $modelId );
1729 }
1730
1731 public function cancel_finetune( $fineTuneId ) {
1732 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1733 }
1734
1735 public function delete_finetune( $modelId ) {
1736 return $this->execute( 'DELETE', '/models/' . $modelId );
1737 }
1738
1739 public function download_file( $fileId, $newFile = null ) {
1740 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1741 $fileInfo = json_decode( (string) $fileInfo, true );
1742 if ( empty( $fileInfo ) ) {
1743 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1744 }
1745 $filename = $fileInfo['filename'];
1746 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1747 if ( empty( $newFile ) ) {
1748 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1749 $tempFile = wp_tempnam( $filename );
1750 if ( !$tempFile ) {
1751 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1752 }
1753 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1754 $newFile = $tempFile . '.' . $extension;
1755 }
1756 else {
1757 $newFile = $tempFile;
1758 }
1759 }
1760 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1761 file_put_contents( $newFile, $data );
1762 return $newFile;
1763 }
1764
1765 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1766 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1767 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1768 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1769 (float) $hyperparams['learningRateMultiplier'] : null;
1770 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1771 (float) $hyperparams['promptLossWeight'] : null;
1772 $arguments = [
1773 'training_file' => $fileId,
1774 'model' => $model,
1775 'suffix' => $suffix
1776 ];
1777 if ( $legacy ) {
1778 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1779 }
1780 else {
1781 if ( $n_epochs ) {
1782 $arguments['hyperparams'] = [];
1783 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1784 }
1785 if ( $batch_size ) {
1786 if ( empty( $arguments['hyperparams'] ) ) {
1787 $arguments['hyperparams'] = [];
1788 }
1789 $arguments['hyperparams']['batch_size'] = $batch_size;
1790 }
1791 if ( $learning_rate_multiplier ) {
1792 if ( empty( $arguments['hyperparams'] ) ) {
1793 $arguments['hyperparams'] = [];
1794 }
1795 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1796 }
1797 if ( $prompt_loss_weight ) {
1798 if ( empty( $arguments['hyperparams'] ) ) {
1799 $arguments['hyperparams'] = [];
1800 }
1801 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1802 }
1803 if ( $model === 'turbo' ) {
1804 $arguments['model'] = 'gpt-3.5-turbo';
1805 }
1806 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1807 }
1808 return $result;
1809 }
1810
1811 /**
1812 * Build the body of a form request.
1813 * If the field name is 'file', then the field value is the filename of the file to upload.
1814 * The file contents are taken from the 'data' field.
1815 *
1816 * @param array $fields
1817 * @param string $boundary
1818 * @return string
1819 */
1820 public function build_form_body( $fields, $boundary ) {
1821 $body = '';
1822 foreach ( $fields as $name => $value ) {
1823 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1824 continue;
1825 }
1826 $body .= "--$boundary\r\n";
1827 $body .= "Content-Disposition: form-data; name=\"$name\"";
1828 if ( $name === 'image' || $name === 'file' ) {
1829 $body .= "; filename=\"{$value}\"\r\n";
1830 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1831 $body .= "Content-Type: {$mime}\r\n\r\n";
1832 $body .= $fields['data'] . "\r\n";
1833 }
1834 else if ( $name === 'mask' ) {
1835 $body .= "; filename=\"{$value}\"\r\n";
1836 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1837 $body .= "Content-Type: {$mime}\r\n\r\n";
1838 $body .= $fields['mask_data'] . "\r\n";
1839 }
1840 else {
1841 // JSON encode arrays and objects, keep scalars as-is
1842 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
1843 $body .= "\r\n\r\n$encodedValue\r\n";
1844 }
1845 }
1846 $body .= "--$boundary--\r\n";
1847 return $body;
1848 }
1849
1850 /**
1851 * Run a request to the OpenAI API.
1852 * Fore more information about the $formFields, refer to the build_form_body method.
1853 *
1854 * @param string $method POST, PUT, GET, DELETE...
1855 * @param string $url The API endpoint
1856 * @param array $query The query parameters (json)
1857 * @param array $formFields The form fields (multipart/form-data)
1858 * @param bool $json Whether to return the response as json or not
1859 * @return array
1860 */
1861 public function execute(
1862 $method,
1863 $url,
1864 $query = null,
1865 $formFields = null,
1866 $json = true,
1867 $extraHeaders = null,
1868 $streamCallback = null
1869 ) {
1870 $isAzure = $this->envType === 'azure';
1871 $isOpenAI = !$isAzure;
1872
1873 // Prepare the headers
1874 $headers = "Content-Type: application/json\r\n";
1875 if ( $isOpenAI ) {
1876 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
1877 if ( $this->organizationId ) {
1878 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
1879 }
1880 }
1881 else if ( $isAzure ) {
1882 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
1883 }
1884
1885 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
1886 if ( !empty( $query ) && !is_string( $query ) ) {
1887 $body = $this->safe_json_encode( $query, 'query body' );
1888 }
1889 else {
1890 $body = $query;
1891 }
1892
1893 // If we have form fields, we need to change the headers and the body.
1894 if ( !empty( $formFields ) ) {
1895 $boundary = wp_generate_password( 24, false );
1896 $headers = [
1897 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1898 ];
1899 if ( $isOpenAI ) {
1900 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
1901 if ( $this->organizationId ) {
1902 $headers['OpenAI-Organization'] = $this->organizationId;
1903 }
1904 }
1905 else if ( $isAzure ) {
1906 $headers['api-key'] = $this->apiKey;
1907 }
1908 $body = $this->build_form_body( $formFields, $boundary );
1909 }
1910
1911 // Maybe we should have headers always as an array... not sure why we have it as a string.
1912 if ( !empty( $extraHeaders ) ) {
1913 foreach ( $extraHeaders as $key => $value ) {
1914 if ( is_array( $headers ) ) {
1915 $headers[$key] = $value;
1916 }
1917 else {
1918 $headers .= "$key: $value\r\n";
1919 }
1920 }
1921 }
1922
1923 // Create the URL
1924 if ( $isOpenAI ) {
1925 $url = 'https://api.openai.com/v1' . $url;
1926 }
1927 else if ( $isAzure ) {
1928 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
1929 $hasQuery = strpos( $url, '?' ) !== false;
1930 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
1931 }
1932
1933 // If it's a GET, body should be null, and we should append the query to the URL.
1934 if ( $method === 'GET' ) {
1935 if ( !empty( $query ) ) {
1936 $hasQuery = strpos( $url, '?' ) !== false;
1937 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
1938 }
1939 $body = null;
1940 }
1941
1942 $options = [
1943 'headers' => $headers,
1944 'method' => $method,
1945 'timeout' => MWAI_TIMEOUT,
1946 'body' => $body,
1947 'sslverify' => MWAI_SSL_VERIFY
1948 ];
1949
1950 // Check if queries debug is enabled
1951 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1952
1953 // Log the request if queries debug is enabled
1954 if ( $queries_debug ) {
1955 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
1956
1957 if ( !empty( $body ) ) {
1958 error_log( '[AI Engine Queries] Request Body:' );
1959 error_log( $body );
1960 }
1961
1962 if ( !is_null( $streamCallback ) ) {
1963 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
1964 }
1965 }
1966
1967 try {
1968 if ( !is_null( $streamCallback ) ) {
1969 $options['stream'] = true;
1970 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
1971 // The stream handler calls the streamCallback every time there is content
1972 // TODO: For assistants, we should probably have a different stream handler to
1973 // handle the assistant's specific reply and perform the necessary actions.
1974 $this->streamCallback = $streamCallback;
1975 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1976 }
1977 $res = wp_remote_request( $url, $options );
1978 if ( is_wp_error( $res ) ) {
1979 throw new Exception( $res->get_error_message() );
1980 }
1981
1982 $res = wp_remote_retrieve_body( $res );
1983
1984 // Handle empty responses for container LIST API only (not for file content downloads)
1985 if ( strpos( $url, '/containers/' ) !== false &&
1986 strpos( $url, '/files' ) !== false &&
1987 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
1988 empty( $res ) ) {
1989 // Return empty array for empty container files LIST response
1990 $data = $json ? [] : '';
1991 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
1992 }
1993 else {
1994 $data = $json ? json_decode( $res, true ) : $res;
1995 }
1996
1997 // Debug logging for decoded data (skip for content downloads)
1998 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
1999 error_log( '[AI Engine] After json_decode:' );
2000 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
2001 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
2002 if ( $data !== null && is_array( $data ) ) {
2003 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
2004 error_log( '[AI Engine] - Data count: ' . count( $data ) );
2005 }
2006 if ( $json && $data === null && !empty( $res ) ) {
2007 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
2008 }
2009 }
2010
2011 $this->handle_response_errors( $data );
2012
2013 // Log the response if queries debug is enabled
2014 if ( $queries_debug && is_null( $streamCallback ) ) {
2015 error_log( '[AI Engine Queries] Response Body:' );
2016 error_log( $res );
2017 }
2018 return $data;
2019 }
2020 catch ( Exception $e ) {
2021 $service = $this->get_service_name();
2022 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
2023 throw new Exception( "$service: " . $e->getMessage() );
2024 }
2025 finally {
2026 if ( !is_null( $streamCallback ) ) {
2027 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2028 }
2029 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
2030 unlink( $options['filename'] );
2031 }
2032 }
2033 }
2034
2035 public function get_models() {
2036 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
2037 // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
2038 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
2039 foreach ( $finetunes as $finetune ) {
2040 if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
2041 continue;
2042 }
2043 if ( empty( $finetune['base_model'] ) || empty( $finetune['model'] ) || empty( $finetune['suffix'] ) ) {
2044 continue;
2045 }
2046 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
2047 if ( !empty( $baseModel ) ) {
2048 $model = null;
2049 foreach ( $models as $currentModel ) {
2050 if ( $currentModel['model'] === $baseModel ) {
2051 $model = $currentModel;
2052 break;
2053 }
2054 }
2055 if ( !empty( $model ) ) {
2056 $model['model'] = $finetune['model'];
2057 $model['name'] = $finetune['suffix'];
2058 $models[] = $model;
2059 }
2060 }
2061 }
2062 return $models;
2063 }
2064
2065 public static function get_models_static() {
2066 return MWAI_OPENAI_MODELS;
2067 }
2068
2069 /**
2070 * Recursively search for container_id in the response data
2071 */
2072 protected function search_for_container_id_recursive( $data, $path = '' ) {
2073 if ( is_array( $data ) || is_object( $data ) ) {
2074 foreach ( $data as $key => $value ) {
2075 $currentPath = $path ? $path . '.' . $key : $key;
2076
2077 // Check if this key is container_id
2078 if ( $key === 'container_id' ) {
2079 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2080 }
2081
2082 // Recursively search in nested structures
2083 if ( is_array( $value ) || is_object( $value ) ) {
2084 $this->search_for_container_id_recursive( $value, $currentPath );
2085 }
2086 }
2087 }
2088 }
2089
2090 // 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().
2091 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2092 $modelFamily = self::get_model_without_release_date( $modelFamily );
2093 $models = $this->get_models();
2094 foreach ( $models as $currentModel ) {
2095 if ( $currentModel['model'] === $modelFamily ) {
2096 if ( $finetune ) {
2097 if ( isset( $currentModel['finetune']['price'] ) ) {
2098 $currentModel['price'] = $currentModel['finetune']['price'];
2099 }
2100 else if ( isset( $currentModel['finetune']['in'] ) ) {
2101 $currentModel['price'] = [
2102 'in' => $currentModel['finetune']['in'],
2103 'out' => $currentModel['finetune']['out']
2104 ];
2105 }
2106 }
2107 $inPrice = $currentModel['price'];
2108 $outPrice = $currentModel['price'];
2109 if ( is_array( $currentModel['price'] ) ) {
2110 $inPrice = $currentModel['price']['in'];
2111 $outPrice = $currentModel['price']['out'];
2112 }
2113 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2114 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2115 return $inTotalPrice + $outTotalPrice;
2116 }
2117 }
2118 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2119 return null;
2120 }
2121
2122 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2123 $model = $query->model;
2124 $units = 0;
2125 $finetune = false;
2126 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2127 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2128 $finetune = true;
2129 }
2130 $inUnits = $reply->get_in_tokens( $query );
2131 $outUnits = $reply->get_out_tokens();
2132 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2133 }
2134 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2135 // gpt-image models are billed by input/output tokens like text models.
2136 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2137 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2138 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2139 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2140 }
2141 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2142 $model = 'whisper';
2143 $units = $reply->get_units();
2144 return $this->calculate_price( $model, 0, $units, null, $finetune );
2145 }
2146 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2147 $units = $reply->get_total_tokens();
2148 return $this->calculate_price( $model, 0, $units, null, $finetune );
2149 }
2150 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2151 return null;
2152 }
2153 }
2154