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