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