PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.2.9
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.2.9
3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / chatml.php
ai-engine / classes / engines Last commit date
anthropic.php 6 months ago chatml.php 6 months ago core.php 7 months ago factory.php 8 months ago google.php 6 months ago mistral.php 6 months ago open-router.php 7 months ago openai.php 6 months ago perplexity.php 6 months ago replicate.php 6 months ago
chatml.php
2151 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.
121 if ( !empty( $query->context ) ) {
122 $messages[] = [ 'role' => 'system', 'content' => $query->context ];
123 }
124
125 // Finally, we need to add the message, but if there is an image, we need to add it as a system message.
126 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
127 if ( !empty( $attachments ) ) {
128 $content = [
129 [
130 'type' => 'text',
131 'text' => $query->get_message()
132 ]
133 ];
134
135 // Handle all attachments (unified approach)
136 foreach ( $attachments as $file ) {
137 // Check file type BEFORE trying to access file data
138 // Images can be loaded via URL or base64, but PDFs might use OpenAI file_id references
139 $mimeType = $file->get_mimeType() ?? '';
140 $isImage = strpos( $mimeType, 'image/' ) === 0;
141
142 if ( $isImage ) {
143 $finalUrl = null;
144 if ( $query->image_remote_upload === 'url' ) {
145 $finalUrl = $file->get_url();
146 }
147 else {
148 $finalUrl = $file->get_inline_base64_url();
149 }
150
151 $content[] = [
152 'type' => 'image_url',
153 'image_url' => [
154 'url' => $finalUrl
155 ]
156 ];
157 }
158 // Skip non-images for Chat Completions API
159 }
160
161 $messages[] = [
162 'role' => 'user',
163 'content' => $content
164 ];
165 }
166 else {
167 $messages[] = [ 'role' => 'user', 'content' => $query->get_message() ];
168 }
169
170 // We need to convert all the 'system' role into 'developer' role.
171 if ( $this->requires_developer_roles( $query->model ) ) {
172 foreach ( $messages as &$message ) {
173 if ( $message['role'] === 'system' ) {
174 $message['role'] = 'developer';
175 }
176 }
177 }
178 // But otherwise, if it's o1, we need to remove the message which are 'system'
179 else if ( $this->is_o1_model( $query->model ) ) {
180 $hasChanges = false;
181 foreach ( $messages as $index => $message ) {
182 if ( $message['role'] === 'system' ) {
183 unset( $messages[$index] );
184 $hasChanges = true;
185 }
186 }
187 if ( $hasChanges ) {
188 $messages = array_values( $messages );
189 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support System nor Developer messages. They were removed.' );
190 }
191 }
192
193 return $messages;
194 }
195
196 protected function build_body( $query, $streamCallback = null, $extra = null ) {
197 if ( $query instanceof Meow_MWAI_Query_Text ) {
198 $body = [
199 'model' => $query->model,
200 'stream' => !is_null( $streamCallback ),
201 ];
202
203 if ( !empty( $query->maxTokens ) ) {
204 // max_tokens has been deprecated in favor of max_completion_tokens in 2025.
205 $body['max_completion_tokens'] = $query->maxTokens;
206 }
207
208 if ( !empty( $query->temperature ) ) {
209 // GPT-5 and o1 models don't support temperature parameter
210 if ( !$this->is_o1_model( $query->model ) && !$this->is_gpt5_model( $query->model ) ) {
211 $body['temperature'] = $query->temperature;
212 }
213 else if ( $this->is_o1_model( $query->model ) ) {
214 // o1 models require temperature to be 1 if specified
215 $body['temperature'] = 1;
216 }
217 // For GPT-5 models, we simply don't include the temperature parameter
218 }
219
220 if ( !empty( $query->maxResults ) ) {
221 $body['n'] = $query->maxResults;
222 }
223
224 if ( !empty( $query->stop ) ) {
225 $body['stop'] = $query->stop;
226 }
227
228 if ( !empty( $query->responseFormat ) ) {
229 if ( $query->responseFormat === 'json' ) {
230 $body['response_format'] = [ 'type' => 'json_object' ];
231 }
232 }
233
234 // Usage Data (only for OpenAI)
235 // https://cookbook.openai.com/examples/how_to_stream_completions#4-how-to-get-token-usage-data-for-streamed-chat-completion-response
236 if ( !empty( $streamCallback ) && $this->envType === 'openai' ) {
237 $body['stream_options'] = [
238 'include_usage' => true,
239 ];
240 }
241
242 if ( !empty( $query->functions ) ) {
243 $model = $this->retrieve_model_info( $query->model );
244 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
245 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support Function Calling.' );
246 }
247 else if ( strpos( $query->model, 'ft:' ) === 0 ) {
248 Meow_MWAI_Logging::warn( 'OpenAI doesn\'t support Function Calling with fine-tuned models yet.' );
249 }
250 else {
251 $body['tools'] = [];
252 // Dynamic function: they will interactively enhance the completion (tools).
253 foreach ( $query->functions as $function ) {
254 $body['tools'][] = [
255 'type' => 'function',
256 'function' => $function->serializeForOpenAI()
257 ];
258 }
259 // Static functions: they will be executed at the end of the completion.
260 //$body['function_call'] = $query->functionCall;
261 }
262 }
263 $body['messages'] = $this->build_messages( $query );
264
265 // Add the feedback if it's a feedback query.
266 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
267 if ( !empty( $query->blocks ) ) {
268 foreach ( $query->blocks as $feedback_block ) {
269 $body['messages'][] = $feedback_block['rawMessage'];
270 foreach ( $feedback_block['feedbacks'] as $feedback ) {
271 // Ensure content is a string for the API
272 $content = $feedback['reply']['value'];
273 if ( !is_string( $content ) ) {
274 $content = json_encode( $content );
275 }
276
277 $body['messages'][] = [
278 'tool_call_id' => $feedback['request']['toolId'],
279 'role' => 'tool',
280 'name' => $feedback['request']['name'],
281 'content' => $content
282 ];
283
284 // Note: Function result events are now emitted centrally in core.php
285 // when the function is actually executed
286 }
287 }
288 }
289 return $body;
290 }
291
292 return $body;
293 }
294 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
295 // Determine filename and MIME type
296 $filename = 'audio.mp3'; // default
297 $mimeType = 'audio/mpeg'; // default
298 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
299 if ( !empty( $attachments ) ) {
300 $firstAttachment = $attachments[0];
301 if ( method_exists( $firstAttachment, 'get_filename' ) ) {
302 $filename = $firstAttachment->get_filename();
303 }
304 if ( method_exists( $firstAttachment, 'get_mimeType' ) ) {
305 $detectedMime = $firstAttachment->get_mimeType();
306 if ( !empty( $detectedMime ) ) {
307 $mimeType = $detectedMime;
308 }
309 }
310 }
311 else if ( !empty( $query->url ) ) {
312 $filename = basename( $query->url );
313 }
314
315 // Guard against malformed filenames coming from form prompts/placeholders
316 if ( empty( $filename ) ) {
317 $filename = 'audio.mp3';
318 }
319 $filename = preg_replace( "/[\r\n]+/", '', $filename );
320 $mimeType = preg_replace( "/[\r\n]+/", '', $mimeType );
321 if ( empty( $mimeType ) ) {
322 $mimeType = 'audio/mpeg';
323 }
324
325 $body = [
326 'prompt' => $query->message,
327 'model' => $query->model,
328 'response_format' => 'text',
329 'file' => $filename,
330 'data' => $extra,
331 'mime' => $mimeType
332 ];
333 return $body;
334 }
335 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
336 $body = [ 'input' => $query->message, 'model' => $query->model ];
337 if ( $this->envType === 'azure' ) {
338 $body = [ 'input' => $query->message ];
339 }
340 // Dimensions are only supported by v3 models
341 if ( !empty( $query->dimensions ) && strpos( $query->model, 'ada-002' ) === false ) {
342 $body['dimensions'] = $query->dimensions;
343 }
344 return $body;
345 }
346 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
347 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
348 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
349 $firstFile = !empty( $attachments ) ? $attachments[0] : null;
350 $filename = $firstFile ? $firstFile->get_filename() : '';
351 $mimeType = $firstFile ? $firstFile->get_mimeType() : null;
352 $body = [
353 'prompt' => $query->message,
354 'n' => $query->maxResults,
355 'size' => $resolution,
356 'image' => $filename,
357 'data' => $extra
358 ];
359 if ( !empty( $mimeType ) ) {
360 $body['mime'] = $mimeType;
361 }
362
363 // Add mask if provided
364 if ( !empty( $query->mask ) ) {
365 $maskData = $query->mask->get_data();
366 $maskFilename = 'mask.png';
367 $maskMimeType = $query->mask->get_mimeType();
368
369 $body['mask'] = $maskFilename;
370 $body['mask_data'] = $maskData;
371 if ( !empty( $maskMimeType ) ) {
372 $body['mask_mime'] = $maskMimeType;
373 }
374 }
375 // 'response_format' => 'b64_json',
376 if ( !empty( $query->model ) ) {
377 $body['model'] = $query->model;
378 }
379 return $body;
380 }
381 else if ( $query instanceof Meow_MWAI_Query_Image ) {
382 $model = $query->model;
383 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
384 $body = [
385 'prompt' => $query->message,
386 'n' => $query->maxResults,
387 'size' => $resolution,
388 ];
389
390 // TODO: Let's clean this up; with a better Query Image class.
391 // https://platform.openai.com/docs/api-reference/images/create#images-create-quality
392
393 if ( strpos( $model, 'gpt-image' ) === 0 ) {
394 // GPT Image models (token-based pricing)
395 $body['model'] = $model; // Use the actual model name
396 $body['quality'] = 'high';
397 $body['moderation'] = 'low';
398 }
399 else {
400 // DALL-E models (per-image pricing)
401 $body['response_format'] = 'b64_json';
402 if ( $model === 'dall-e-3' ) {
403 $body['model'] = 'dall-e-3';
404 }
405 if ( $model === 'dall-e-3-hd' ) {
406 $body['model'] = 'dall-e-3';
407 $body['quality'] = 'hd';
408 }
409 if ( !empty( $query->style ) && strpos( $model, 'dall-e-3' ) === 0 ) {
410 $body['style'] = $query->style;
411 }
412 }
413 return $body;
414 }
415 }
416
417 protected function build_url( $query, $endpoint = null ) {
418 $url = '';
419 $env = $this->env;
420 // This endpoint is basically OpenAI or Azure, but in the case this class
421 // is overriden, we can pass the endpoint directly (for OpenRouter or HuggingFace, for example).
422 if ( empty( $endpoint ) ) {
423 if ( $this->envType === 'openai' ) {
424 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
425 $this->organizationId = isset( $env['organizationId'] ) ? $env['organizationId'] : null;
426 }
427 else if ( $this->envType === 'azure' ) {
428 $endpoint = isset( $env['endpoint'] ) ? $env['endpoint'] : null;
429 // Ensure the endpoint has the proper protocol if it's just a domain
430 if ( $endpoint && strpos( $endpoint, 'http' ) !== 0 ) {
431 $endpoint = 'https://' . $endpoint;
432 }
433 }
434 else {
435 if ( empty( $this->envType ) ) {
436 throw new Exception( 'Endpoint is not defined, and this envType is not known.' );
437 }
438 throw new Exception( 'Endpoint is not defined, and this envType is not known: ' . $this->envType );
439 }
440 }
441 // Add the base API to the URL
442 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
443 if ( $this->envType === 'azure' ) {
444 $deployment_name = $this->get_azure_deployment_name( $query->model );
445 $url = trailingslashit( $endpoint ) . 'openai/deployments/' . $deployment_name;
446 $url .= '/chat/completions?' . $this->azureApiVersion;
447 }
448 else {
449 $url .= trailingslashit( $endpoint ) . 'chat/completions';
450 }
451 return $url;
452 }
453 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
454 $modeEndpoint = $query->feature === 'translation' ? 'translations' : 'transcriptions';
455 $url .= trailingslashit( $endpoint ) . 'audio/' . $modeEndpoint;
456 return $url;
457 }
458 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
459 $url .= trailingslashit( $endpoint ) . 'embeddings';
460 if ( $this->envType === 'azure' ) {
461 $deployment_name = $this->get_azure_deployment_name( $query->model );
462 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
463 $deployment_name . '/embeddings?' . $this->azureApiVersion;
464 }
465 return $url;
466 }
467 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
468 $url .= trailingslashit( $endpoint ) . 'images/edits';
469 if ( $this->envType === 'azure' ) {
470 $deployment_name = $this->get_azure_deployment_name( $query->model );
471 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
472 $deployment_name . '/images/edits?' . $this->azureApiVersion;
473 }
474 return $url;
475 }
476 else if ( $query instanceof Meow_MWAI_Query_Image ) {
477 $url .= trailingslashit( $endpoint ) . 'images/generations';
478 if ( $this->envType === 'azure' ) {
479 $deployment_name = $this->get_azure_deployment_name( $query->model );
480 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
481 $deployment_name . '/images/generations?' . $this->azureApiVersion;
482 }
483 return $url;
484 }
485 throw new Exception( 'The query is not supported by build_url().' );
486 }
487
488 protected function build_headers( $query ) {
489 if ( $query->apiKey ) {
490 $this->apiKey = $query->apiKey;
491 }
492 if ( empty( $this->apiKey ) ) {
493 throw new Exception( 'No API Key provided. Please visit the Settings. (ChatML Engine)' );
494 }
495 $headers = [
496 'Content-Type' => 'application/json',
497 'Authorization' => 'Bearer ' . $this->apiKey,
498 ];
499 if ( $this->organizationId ) {
500 $headers['OpenAI-Organization'] = $this->organizationId;
501 }
502 if ( $this->envType === 'azure' ) {
503 $headers = [ 'Content-Type' => 'application/json', 'api-key' => $this->apiKey ];
504 }
505 return $headers;
506 }
507
508 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
509 $body = null;
510 if ( !empty( $forms ) ) {
511 $boundary = wp_generate_password( 24, false );
512 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
513 $body = $this->build_form_body( $forms, $boundary );
514 }
515 else if ( !empty( $json ) ) {
516 $body = $this->safe_json_encode( $json, 'request body' );
517 }
518 $options = [
519 'headers' => $headers,
520 'method' => $method,
521 'timeout' => MWAI_TIMEOUT,
522 'body' => $body,
523 'sslverify' => MWAI_SSL_VERIFY
524 ];
525 return $options;
526 }
527 // object: "thread.message.delta"
528 protected function stream_data_handler( $json ) {
529 $content = null;
530 $handledCondition = false; // Track if we entered any condition
531
532 // Get additional data from the JSON
533 if ( isset( $json['model'] ) ) {
534 $this->inModel = $json['model'];
535 }
536 if ( isset( $json['id'] ) ) {
537 $this->inId = $json['id'];
538
539 // Send start event if debug mode is enabled and not already sent
540 if ( $this->currentDebugMode && $this->streamCallback && !$this->streamStartEmitted ) {
541 $this->streamStartEmitted = true;
542 $event = Meow_MWAI_Event::status( 'Starting stream...' )
543 ->set_metadata( 'model', $this->inModel )
544 ->set_metadata( 'id', $this->inId );
545 call_user_func( $this->streamCallback, $event );
546 }
547 }
548
549 $object = $json['object'] ?? null;
550
551 if ( $object === 'thread.run' ) {
552 $handledCondition = true;
553 $this->inThreadId = $json['thread_id'];
554 if ( $json['status'] === 'failed' ) {
555 $error = $json['last_error']['message'] ?? 'The run failed.';
556 throw new Exception( $error );
557 }
558 }
559 else if ( $object === 'thread.run.step.delta' ) {
560 $handledCondition = true;
561 if ( $json['delta']['step_details']['type'] === 'tool_calls' ) {
562 foreach ( $json['delta']['step_details']['tool_calls'] as $tool_call ) {
563 $index = $tool_call['index'] ?? null;
564 $currentStreamToolCall = null;
565 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
566 $currentStreamToolCall = &$this->streamToolCalls[$index];
567 }
568 else {
569 $this->streamToolCalls[] = [
570 'id' => null,
571 'type' => null,
572 'function' => [ 'name' => '', 'arguments' => '' ],
573 'code_interpreter' => [ 'input' => '', 'outputs' => [] ],
574 ];
575 end( $this->streamToolCalls );
576 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
577
578 // Send tool call initiated event
579 if ( $this->currentDebugMode && $this->streamCallback ) {
580 $event = Meow_MWAI_Event::status( 'Initiating tool call...' );
581 call_user_func( $this->streamCallback, $event );
582 }
583 }
584 if ( !empty( $tool_call['id'] ) ) {
585 $currentStreamToolCall['id'] = $tool_call['id'];
586 }
587 if ( !empty( $tool_call['type'] ) ) {
588 $currentStreamToolCall['type'] = $tool_call['type'];
589 }
590 if ( isset( $tool_call['function'] ) ) {
591 $function = $tool_call['function'];
592 if ( isset( $function['name'] ) ) {
593 $currentStreamToolCall['function']['name'] .= $function['name'];
594 }
595 if ( isset( $function['arguments'] ) ) {
596 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
597 }
598 }
599 if ( isset( $tool_call['code_interpreter'] ) ) {
600 $code_interpreter = $tool_call['code_interpreter'];
601 if ( isset( $code_interpreter['input'] ) ) {
602 $currentStreamToolCall['code_interpreter']['input'] .= $code_interpreter['input'];
603 }
604 if ( isset( $code_interpreter['outputs'] ) ) {
605 $currentStreamToolCall['code_interpreter']['outputs'] = $code_interpreter['outputs'];
606 }
607 }
608 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
609 }
610 }
611 }
612 else if ( $object === 'thread.message.delta' ) {
613 $handledCondition = true;
614 $delta = $json['delta']['content'][0] ?? null;
615 if ( $delta ) {
616 switch ( $delta['type'] ?? null ) {
617 case 'text':
618 if ( !empty( $delta['value'] ) && is_string( $delta['value'] ) ) {
619 $content = $delta['value'];
620 }
621 else if ( !empty( $delta['text'] ) && is_string( $delta['text'] ) ) {
622 $content = $delta['text'];
623 }
624 else if ( !empty( $delta['text'] ) && is_array( $delta['text'] ) ) {
625 $text = $delta['text'];
626 if ( !empty( $text['annotations'] ) ) {
627 $this->streamAnnotations = array_merge( $this->streamAnnotations, $text['annotations'] );
628 }
629 if ( isset( $text['value'] ) ) {
630 $content = $text['value'];
631 }
632 }
633 else {
634 error_log( 'AI Engine: Unknown text format: ' . json_encode( $delta ) );
635 }
636 break;
637 case 'image':
638 $content = $delta['url'];
639 break;
640 case 'image_file':
641 $fileId = $delta['image_file']['file_id'];
642 $content = '<!-- IMG #' . $fileId . ' -->';
643 $this->streamImageIds[] = $fileId;
644 break;
645 case 'function_call':
646 if ( empty( $this->streamFunctionCall ) ) {
647 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
648 }
649 $this->streamFunctionCall['name'] = $delta['function_call']['name'] ?? $this->streamFunctionCall['name'];
650 if ( isset( $delta['function_call']['arguments'] ) ) {
651 $args = json_decode( $delta['function_call']['arguments'], true );
652 $this->streamFunctionCall['arguments'] = $args ?? [];
653 }
654 break;
655 case 'tool_call':
656 $tool_call = $delta['tool_call'];
657 $index = $tool_call['index'] ?? null;
658 $currentStreamToolCall = null;
659 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
660 $currentStreamToolCall = &$this->streamToolCalls[$index];
661 }
662 else {
663 $this->streamToolCalls[] = [
664 'id' => null,
665 'type' => null,
666 'function' => [ 'name' => '', 'arguments' => '' ]
667 ];
668 end( $this->streamToolCalls );
669 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
670 }
671 break;
672 }
673 }
674 }
675 else if ( $object === 'thread.run.step' ) {
676 $handledCondition = true;
677 //$type = $json['step'];
678 // Could be tool_calls, means an OpenAI Assistant is doing something.
679 }
680 else {
681 if ( isset( $json['choices'][0]['text'] ) ) {
682 $handledCondition = true;
683 $content = $json['choices'][0]['text'];
684 }
685 else if ( isset( $json['choices'][0]['delta']['content'] ) ) {
686 $handledCondition = true;
687 $content = $json['choices'][0]['delta']['content'];
688 }
689 else if ( isset( $json['choices'][0]['delta']['function_call'] ) ) {
690 $handledCondition = true;
691 if ( empty( $this->streamFunctionCall ) ) {
692 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
693 }
694 $this->streamFunctionCall['name'] = $json['choices'][0]['delta']['function_call']['name'] ?? $this->streamFunctionCall['name'];
695 if ( isset( $json['choices'][0]['delta']['function_call']['arguments'] ) ) {
696 $args = json_decode( $json['choices'][0]['delta']['function_call']['arguments'], true );
697 $this->streamFunctionCall['arguments'] = $args ?? [];
698 }
699 }
700 else if ( isset( $json['choices'][0]['delta']['tool_calls'] ) ) {
701 $handledCondition = true;
702 // New schema detected – drop any half-built legacy call to prevent duplicates
703 $this->streamFunctionCall = null;
704
705 foreach ( $json['choices'][0]['delta']['tool_calls'] as $tool_call ) {
706 $index = $tool_call['index'] ?? null;
707 $currentStreamToolCall = null;
708 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
709 $currentStreamToolCall = &$this->streamToolCalls[$index];
710 }
711 else {
712 $this->streamToolCalls[] = [
713 'id' => null,
714 'type' => null,
715 'function' => [ 'name' => '', 'arguments' => '' ]
716 ];
717 end( $this->streamToolCalls );
718 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
719 }
720 if ( !empty( $tool_call['id'] ) ) {
721 $currentStreamToolCall['id'] = $tool_call['id'];
722 }
723 if ( !empty( $tool_call['type'] ) ) {
724 $currentStreamToolCall['type'] = $tool_call['type'];
725 }
726 if ( isset( $tool_call['function'] ) ) {
727 $function = $tool_call['function'];
728 if ( isset( $function['name'] ) ) {
729 $currentStreamToolCall['function']['name'] .= $function['name'];
730 }
731 if ( isset( $function['arguments'] ) ) {
732 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
733 }
734 }
735 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
736 }
737 }
738 else if ( isset( $json['choices'][0]['delta']['role'] ) ) {
739 $handledCondition = true;
740 $this->streamLastMessage = [
741 'role' => $json['choices'][0]['delta']['role'],
742 'content' => null
743 ];
744 }
745
746 // Handle thinking/reasoning content (from Ollama and potentially other models)
747 // This can appear alongside or instead of regular content
748 if ( isset( $json['choices'][0]['delta']['reasoning'] ) ) {
749 $handledCondition = true;
750 $thinking = $json['choices'][0]['delta']['reasoning'];
751 if ( !empty( $thinking ) ) {
752 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
753 }
754 }
755 // Also check for 'thinking' field (OpenAI's o1 models use this)
756 else if ( isset( $json['choices'][0]['delta']['thinking'] ) ) {
757 $handledCondition = true;
758 $thinking = $json['choices'][0]['delta']['thinking'];
759 if ( !empty( $thinking ) ) {
760 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
761 }
762 }
763 }
764
765 $usage = $json['usage'] ?? [];
766 if ( isset( $usage['prompt_tokens'], $usage['completion_tokens'] ) ) {
767 $this->streamInTokens = (int) $usage['prompt_tokens'];
768 $this->streamOutTokens = (int) $usage['completion_tokens'];
769
770 if ( isset( $usage['cost'] ) ) {
771 $this->streamCost = (float) $usage['cost'];
772 }
773 }
774
775 // If content is an array, let's try to convert it into a string. Normally, there would be a 'value' key.
776 if ( is_array( $content ) ) {
777 if ( isset( $content['value'] ) ) {
778 $content = $content['value'];
779 }
780 else {
781 throw new Exception( 'Could not read this: ' . json_encode( $content ) );
782 }
783 }
784
785 // Log unhandled JSON in dev mode
786 if ( !$handledCondition && $this->core->get_option( 'dev_mode' ) ) {
787 error_log( '[AI Engine] Unhandled streaming JSON structure: ' . json_encode( $json ) );
788 }
789
790 // Avoid some endings
791 $endings = [ '', '</s>' ];
792 if ( in_array( $content, $endings ) ) {
793 $content = null;
794 }
795
796 return ( $content === '0' || !empty( $content ) ) ? $content : null;
797 }
798
799 public function run( $query, $streamCallback = null, $maxDepth = 5 ) {
800 // Check if this is a realtime model being used with chat completions
801 if ( $this->is_realtime_model( $query->model ) ) {
802 throw new Exception(
803 'Realtime models (like ' . $query->model . ') are designed for voice/audio interactions and cannot be used with this API.'
804 );
805 }
806
807 if ( $streamCallback ) {
808 // Disable streaming only for "o1" (as December 2024, it works for preview and mini)
809 if ( $query->model === 'o1' ) {
810 $streamCallback = null;
811 }
812 }
813 return parent::run( $query, $streamCallback, $maxDepth );
814 }
815
816 public function run_query( $url, $options, $isStream = false ) {
817 try {
818 $options['stream'] = $isStream;
819 if ( $isStream ) {
820 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
821 }
822
823 // Check if queries debug is enabled
824 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
825
826 // Log the request if queries debug is enabled
827 if ( $queries_debug ) {
828 error_log( '[AI Engine Queries] --> Request to: ' . $url );
829
830 if ( isset( $options['body'] ) ) {
831 // This is the actual body being sent to the AI service
832 $body_log = is_string( $options['body'] ) ? $options['body'] : json_encode( $options['body'] );
833
834 // Pretty print JSON if possible
835 $decoded = json_decode( $body_log, true );
836 if ( json_last_error() === JSON_ERROR_NONE ) {
837 error_log( json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
838 }
839 else {
840 error_log( $body_log );
841 }
842 }
843 }
844
845 $res = wp_remote_get( $url, $options );
846
847 if ( is_wp_error( $res ) ) {
848 throw new Exception( $res->get_error_message() );
849 }
850
851 $responseCode = wp_remote_retrieve_response_code( $res );
852 if ( $responseCode === 404 ) {
853 throw new Exception( 'The model\'s API URL was not found: ' . $url );
854 }
855 else if ( $responseCode === 400 ) {
856 $message = wp_remote_retrieve_body( $res );
857 // Log the full response body for debugging
858 error_log( '[AI Engine] 400 Bad Request - Full response body: ' . $message );
859 if ( empty( $message ) ) {
860 $message = wp_remote_retrieve_response_message( $res );
861 }
862 if ( empty( $message ) ) {
863 $message = 'Bad Request';
864 }
865 throw new Exception( $message );
866 }
867 else if ( $responseCode === 422 ) {
868 $message = wp_remote_retrieve_body( $res );
869 if ( empty( $message ) ) {
870 $message = wp_remote_retrieve_response_message( $res );
871 }
872 if ( empty( $message ) ) {
873 $message = 'Unprocessable Entity';
874 }
875 throw new Exception( $message );
876 }
877 else if ( $responseCode === 500 ) {
878 $message = wp_remote_retrieve_body( $res );
879 if ( empty( $message ) ) {
880 $message = wp_remote_retrieve_response_message( $res );
881 }
882 if ( empty( $message ) ) {
883 $message = 'Internal Server Error';
884 }
885 throw new Exception( $message );
886 }
887
888 if ( $isStream ) {
889 return [ 'stream' => true ];
890 }
891
892 $response = wp_remote_retrieve_body( $res );
893 $headersRes = wp_remote_retrieve_headers( $res );
894 $headers = $headersRes->getAll();
895
896 // Check if Content-Type is 'multipart/form-data' or 'text/plain'
897 // If so, we don't need to decode the response
898 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
899 $resContentType = $normalizedHeaders['content-type'] ?? '';
900 if ( strpos( $resContentType, 'multipart/form-data' ) !== false || strpos( $resContentType, 'text/plain' ) !== false ) {
901 // Log the response if queries debug is enabled
902 if ( $queries_debug && !$isStream ) {
903 error_log( '[AI Engine Queries] Response Headers: ' . json_encode( $headers ) );
904 error_log( '[AI Engine Queries] Response Body (raw): ' . substr( $response, 0, 1000 ) . '...' );
905 }
906 return [ 'stream' => false, 'headers' => $headers, 'data' => $response ];
907 }
908
909 $data = json_decode( $response, true );
910 $this->handle_response_errors( $data );
911
912 // Log the response if queries debug is enabled
913 if ( $queries_debug && !$isStream ) {
914 // Log the raw response as received from the AI service
915 error_log( '[AI Engine Queries] <-- Response:' );
916
917 // Pretty print JSON if possible
918 if ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) {
919 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
920 }
921 else {
922 error_log( $response );
923 }
924 }
925
926 return [ 'headers' => $headers, 'data' => $data ];
927 }
928 catch ( Exception $e ) {
929 $service = $this->get_service_name();
930 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
931
932 // Log error response if queries debug is enabled
933 if ( $queries_debug ) {
934 error_log( '[AI Engine Queries] Error occurred: ' . $e->getMessage() );
935 }
936
937 throw $e;
938 }
939 finally {
940 if ( $isStream && file_exists( $options['filename'] ) ) {
941 unlink( $options['filename'] );
942 }
943 }
944 }
945
946 private function get_audio( $url ) {
947 require_once( ABSPATH . 'wp-admin/includes/media.php' );
948
949 // Validate URL scheme to prevent SSRF attacks
950 $parts = wp_parse_url( $url );
951 if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
952 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
953 }
954
955 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
956 file_put_contents( $tmpFile, file_get_contents( $url ) );
957 $length = null;
958 $metadata = wp_read_audio_metadata( $tmpFile );
959 if ( isset( $metadata['length'] ) ) {
960 $length = $metadata['length'];
961 }
962 $data = file_get_contents( $tmpFile );
963 unlink( $tmpFile );
964 return [ 'data' => $data, 'length' => $length ];
965 }
966
967 public function run_transcribe_query( $query ) {
968 $audioData = null;
969
970 // Priority 1: Direct audio data
971 if ( !empty( $query->audioData ) ) {
972 $audioData = [
973 'data' => $query->audioData,
974 'length' => strlen( $query->audioData ) / 1024 // KB
975 ];
976 }
977 // Priority 2: File path
978 else if ( !empty( $query->path ) ) {
979 if ( !file_exists( $query->path ) ) {
980 throw new Exception( 'Audio file not found: ' . $query->path );
981 }
982 if ( !is_readable( $query->path ) ) {
983 throw new Exception( 'Audio file is not readable: ' . $query->path );
984 }
985 $audioData = [
986 'data' => file_get_contents( $query->path ),
987 'length' => filesize( $query->path ) / 1024 // KB
988 ];
989 }
990 // Priority 3: Attached file object
991 else if ( method_exists( $query, 'getAttachments' ) ) {
992 $attachments = $query->getAttachments();
993 if ( !empty( $attachments ) ) {
994 $file = $attachments[0];
995 $audioData = [
996 'data' => $file->get_data(),
997 'length' => strlen( $file->get_data() ) / 1024 // KB
998 ];
999 }
1000 }
1001 // Priority 4: URL (backward compatibility)
1002 else if ( !empty( $query->url ) ) {
1003 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1004 throw new Exception( 'Invalid URL for transcription.' );
1005 }
1006 $audioData = $this->get_audio( $query->url );
1007 }
1008 else {
1009 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1010 }
1011
1012 $body = $this->build_body( $query, null, $audioData['data'] );
1013 $url = $this->build_url( $query );
1014 $headers = $this->build_headers( $query );
1015 $options = $this->build_options( $headers, null, $body );
1016
1017 // Perform the request
1018 try {
1019 $res = $this->run_query( $url, $options );
1020 $data = $res['data'];
1021 if ( empty( $data ) ) {
1022 throw new Exception( 'Invalid data for transcription.' );
1023 }
1024 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1025 $reply = new Meow_MWAI_Reply( $query );
1026 $reply->set_usage( $usage );
1027 $reply->set_choices( $data );
1028 return $reply;
1029 }
1030 catch ( Exception $e ) {
1031 $service = $this->get_service_name();
1032 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1033 throw new Exception( "$service: " . $e->getMessage() );
1034 }
1035 }
1036
1037 public function run_embedding_query( $query ) {
1038 $body = $this->build_body( $query );
1039 $url = $this->build_url( $query );
1040 $headers = $this->build_headers( $query );
1041 $options = $this->build_options( $headers, $body );
1042
1043 try {
1044 $res = $this->run_query( $url, $options );
1045 $data = $res['data'];
1046 if ( empty( $data ) || !isset( $data['data'] ) ) {
1047 throw new Exception( 'Invalid data for embedding.' );
1048 }
1049 $usage = $data['usage'];
1050 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1051 $reply = new Meow_MWAI_Reply( $query );
1052 $reply->set_usage( $usage );
1053 $reply->set_choices( $data['data'] );
1054 return $reply;
1055 }
1056 catch ( Exception $e ) {
1057 $message = $e->getMessage();
1058 $error = $this->try_decode_error( $message );
1059 if ( !is_null( $error ) ) {
1060 $message = $error;
1061 }
1062 $service = $this->get_service_name();
1063 Meow_MWAI_Logging::error( "$service: " . $message );
1064 throw new Exception( "$service: " . $message );
1065 }
1066 }
1067
1068 public function try_decode_error( $data ) {
1069 $json = json_decode( $data, true );
1070 if ( isset( $json['error']['message'] ) ) {
1071 return $json['error']['message'];
1072 }
1073 return null;
1074 }
1075
1076 protected function finalize_choices( $choices, $responseData, $query ) {
1077 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1078 foreach ( $choices as &$choice ) {
1079 if ( isset( $choice['message'] ) ) {
1080 // If we have both tool_calls and function_call, remove function_call
1081 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1082 isset( $choice['message']['function_call'] ) ) {
1083 unset( $choice['message']['function_call'] );
1084 }
1085 }
1086 }
1087 return $choices;
1088 }
1089
1090 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1091 // Check if this is a GPT-5 model - they don't support Chat Completions API
1092 if ( $this->is_gpt5_model( $query->model ) ) {
1093 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1094 }
1095
1096 $isStreaming = !is_null( $streamCallback );
1097
1098 // Initialize debug mode
1099 $this->init_debug_mode( $query );
1100
1101 // Store the callback for event emission (both streaming and non-streaming debug mode)
1102 if ( !is_null( $streamCallback ) ) {
1103 $this->streamCallback = $streamCallback;
1104 }
1105
1106 if ( $isStreaming ) {
1107 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1108 }
1109
1110 $this->reset_stream();
1111 $body = $this->build_body( $query, $streamCallback );
1112 $url = $this->build_url( $query );
1113 $headers = $this->build_headers( $query );
1114 $options = $this->build_options( $headers, $body );
1115
1116 // Emit "Request sent" event for feedback queries
1117 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1118 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1119 $event = Meow_MWAI_Event::request_sent()
1120 ->set_metadata( 'is_feedback', true )
1121 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1122 call_user_func( $streamCallback, $event );
1123 }
1124
1125 try {
1126 $res = $this->run_query( $url, $options, $streamCallback );
1127 $reply = new Meow_MWAI_Reply( $query );
1128
1129 $returned_id = null;
1130 $returned_model = $this->inModel;
1131 $returned_in_tokens = null;
1132 $returned_out_tokens = null;
1133 $returned_price = null;
1134 $returned_choices = [];
1135
1136 // Streaming Mode
1137 if ( $isStreaming ) {
1138 if ( empty( $this->streamContent ) ) {
1139 $error = $this->try_decode_error( $this->streamBuffer );
1140 if ( !is_null( $error ) ) {
1141 throw new Exception( $error );
1142 }
1143 }
1144 $returned_id = $this->inId;
1145 $returned_model = $this->inModel ? $this->inModel : $query->model;
1146
1147 // Use regular content if available, otherwise fall back to thinking/reasoning
1148 $finalContent = $this->streamContent;
1149 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1150 // Use thinking content as fallback when there's no regular content
1151 // This happens with Ollama when it returns only reasoning/thinking
1152 // Wrap in asterisks to show as italics in markdown
1153 $finalContent = '*' . $this->streamThinking . '*';
1154
1155 // Log this for debugging
1156 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1157 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1158 }
1159 }
1160
1161 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1162 // Prefer tool_calls; fall back to legacy only if necessary
1163 if ( !empty( $this->streamToolCalls ) ) {
1164 $message['tool_calls'] = $this->streamToolCalls;
1165 }
1166 elseif ( !empty( $this->streamFunctionCall ) ) {
1167 $message['function_call'] = $this->streamFunctionCall;
1168 }
1169
1170 // Optionally include thinking as metadata if both content and thinking exist
1171 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1172 $message['thinking'] = $this->streamThinking;
1173 }
1174 if ( !is_null( $this->streamInTokens ) ) {
1175 $returned_in_tokens = $this->streamInTokens;
1176 }
1177 if ( !is_null( $this->streamOutTokens ) ) {
1178 $returned_out_tokens = $this->streamOutTokens;
1179 }
1180 if ( !is_null( $this->streamCost ) ) {
1181 $returned_price = $this->streamCost;
1182 }
1183 $returned_choices = [ [ 'message' => $message ] ];
1184 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1185
1186 // Log streaming response data if queries debug is enabled
1187 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1188 if ( $queries_debug ) {
1189 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1190 $streaming_data = [
1191 'id' => $returned_id,
1192 'model' => $returned_model,
1193 'content_length' => strlen( $this->streamContent ),
1194 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1195 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1196 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1197 'usage' => [
1198 'input_tokens' => $returned_in_tokens,
1199 'output_tokens' => $returned_out_tokens,
1200 'cost' => $returned_price
1201 ]
1202 ];
1203
1204 // Log tool calls details if present
1205 if ( !empty( $this->streamToolCalls ) ) {
1206 $streaming_data['tool_calls_details'] = [];
1207 foreach ( $this->streamToolCalls as $tool_call ) {
1208 $streaming_data['tool_calls_details'][] = [
1209 'id' => $tool_call['id'] ?? 'unknown',
1210 'name' => $tool_call['function']['name'] ?? 'unknown',
1211 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1212 ];
1213 }
1214 }
1215
1216 // Log function call if present
1217 if ( !empty( $this->streamFunctionCall ) ) {
1218 $streaming_data['function_call'] = [
1219 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1220 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1221 ];
1222 }
1223
1224 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1225 }
1226 }
1227 // Standard Mode
1228 else {
1229 $data = $res['data'];
1230 if ( empty( $data ) ) {
1231 throw new Exception( 'No content received (res is null).' );
1232 }
1233
1234 // Comprehensive logging for non-streaming mode - capture FULL response
1235 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1236 if ( $queries_debug ) {
1237 error_log( '[AI Engine Queries] ========================================' );
1238 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1239 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1240 error_log( '[AI Engine Queries] ========================================' );
1241
1242 // Look specifically for container_id
1243 $this->search_for_container_id_recursive( $data, '' );
1244 }
1245
1246 if ( !$data['model'] ) {
1247 $service = $this->get_service_name();
1248 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1249 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1250 throw new Exception( 'Invalid response (no model information).' );
1251 }
1252 $returned_id = $data['id'];
1253 $returned_model = $data['model'];
1254 $usage = $data['usage'] ?? [];
1255 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1256 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1257 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1258 $returned_choices = $data['choices'];
1259 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1260 }
1261
1262 // Set the results.
1263 $reply->set_choices( $returned_choices );
1264 if ( !empty( $returned_id ) ) {
1265 $reply->set_id( $returned_id );
1266 }
1267 if ( !empty( $returned_id ) ) {
1268 $reply->set_id( $returned_id );
1269 }
1270
1271 // Handle tokens.
1272 $this->handle_tokens_usage(
1273 $reply,
1274 $query,
1275 $returned_model,
1276 $returned_in_tokens,
1277 $returned_out_tokens,
1278 $returned_price
1279 );
1280
1281 return $reply;
1282 }
1283 catch ( Exception $e ) {
1284 $service = $this->get_service_name();
1285 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1286 $message = "$service: " . $e->getMessage();
1287 throw new Exception( $message );
1288 }
1289 finally {
1290 if ( !is_null( $streamCallback ) ) {
1291 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1292 }
1293 }
1294 }
1295
1296 public function handle_tokens_usage(
1297 $reply,
1298 $query,
1299 $returned_model,
1300 $returned_in_tokens,
1301 $returned_out_tokens,
1302 $returned_price = null
1303 ) {
1304 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1305 $reply->get_in_tokens( $query );
1306 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1307 $reply->get_out_tokens();
1308 $returned_price = !is_null( $returned_price ) ? $returned_price :
1309 $reply->get_price();
1310 $usage = $this->core->record_tokens_usage(
1311 $returned_model,
1312 $returned_in_tokens,
1313 $returned_out_tokens,
1314 $returned_price
1315 );
1316 $reply->set_usage( $usage );
1317
1318 // Store the actual model returned by the API
1319 $reply->set_model( $returned_model );
1320
1321 // Set default accuracy to 'estimated' for engines that don't override
1322 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1323 $reply->set_usage_accuracy( 'estimated' );
1324 }
1325
1326 // Request to DALL-E API
1327 public function run_image_query( $query, $streamCallback = null ) {
1328 $body = $this->build_body( $query );
1329 $url = $this->build_url( $query );
1330 $headers = $this->build_headers( $query );
1331 $options = $this->build_options( $headers, $body );
1332
1333 try {
1334 $res = $this->run_query( $url, $options );
1335 $data = $res['data'];
1336 $choices = [];
1337 $choices = $data['data'];
1338 $reply = new Meow_MWAI_Reply( $query );
1339 $model = $query->model;
1340 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1341
1342 // Image models use two different pricing models:
1343 // 1. Token-based (gpt-image-1, gpt-image-1-mini): API returns usage.input_tokens and usage.output_tokens
1344 // Price: (input_tokens × $10 + output_tokens × $40) / 1M
1345 // 2. Per-image (DALL-E): API returns no usage data, price based on resolution
1346 // Price: Fixed per image (e.g., $0.040 for 1024x1024)
1347
1348 if ( isset( $data['usage'] ) ) {
1349 // Token-based model (gpt-image-1, gpt-image-1-mini)
1350 $usage = $data['usage'];
1351
1352 // IMPORTANT: OpenAI Images API uses 'input_tokens' and 'output_tokens',
1353 // not 'prompt_tokens' and 'completion_tokens' like the Chat API
1354 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1355 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1356
1357 // Record token usage for statistics tracking
1358 // Note: Price calculation happens in get_price() method, not here
1359 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1360
1361 // Set usage data with token info and accuracy level
1362 $usage['queries'] = 1;
1363 $usage['accuracy'] = 'tokens';
1364
1365 $reply->set_usage( $usage );
1366 $reply->set_usage_accuracy( $usage['accuracy'] );
1367 }
1368 else {
1369 // Per-image model (DALL-E): No token data, use resolution-based pricing
1370 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1371 $reply->set_usage( $usage );
1372 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1373 }
1374
1375 $reply->set_choices( $choices );
1376 $reply->set_type( 'images' );
1377
1378 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1379 foreach ( $reply->results as &$result ) {
1380 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1381 'query_envId' => $query->envId,
1382 'query_session' => $query->session,
1383 'query_model' => $query->model,
1384 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1385 $fileUrl = $this->core->files->get_url( $fileId );
1386 $result = $fileUrl;
1387 }
1388 }
1389 $reply->result = $reply->results[0];
1390 return $reply;
1391 }
1392 catch ( Exception $e ) {
1393 $service = $this->get_service_name();
1394 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1395 throw new Exception( "$service: " . $e->getMessage() );
1396 }
1397 }
1398
1399 public function run_editimage_query( $query ) {
1400 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1401 if ( empty( $attachments ) ) {
1402 throw new Exception( 'No image provided for editing.' );
1403 }
1404 // Ensure the model supports image editing
1405 $modelInfo = $this->retrieve_model_info( $query->model );
1406 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1407 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1408 }
1409 $file = $attachments[0];
1410 $imageData = $file->get_data();
1411 $body = $this->build_body( $query, null, $imageData );
1412 $url = $this->build_url( $query );
1413 $headers = $this->build_headers( $query );
1414 $options = $this->build_options( $headers, null, $body );
1415
1416 try {
1417 $res = $this->run_query( $url, $options );
1418 $data = $res['data'];
1419 $choices = $data['data'];
1420 $reply = new Meow_MWAI_Reply( $query );
1421 $model = $query->model;
1422 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1423
1424 // Image models use two different pricing models:
1425 // 1. Token-based (gpt-image-1, gpt-image-1-mini): API returns usage.input_tokens and usage.output_tokens
1426 // Price: (input_tokens × $10 + output_tokens × $40) / 1M
1427 // 2. Per-image (DALL-E): API returns no usage data, price based on resolution
1428 // Price: Fixed per image (e.g., $0.040 for 1024x1024)
1429
1430 if ( isset( $data['usage'] ) ) {
1431 // Token-based model (gpt-image-1, gpt-image-1-mini)
1432 $usage = $data['usage'];
1433
1434 // IMPORTANT: OpenAI Images API uses 'input_tokens' and 'output_tokens',
1435 // not 'prompt_tokens' and 'completion_tokens' like the Chat API
1436 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1437 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1438
1439 // Record token usage for statistics tracking
1440 // Note: Price calculation happens in get_price() method, not here
1441 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1442
1443 // Set usage data with token info and accuracy level
1444 $usage['queries'] = 1;
1445 $usage['accuracy'] = 'tokens';
1446
1447 $reply->set_usage( $usage );
1448 $reply->set_usage_accuracy( $usage['accuracy'] );
1449 }
1450 else {
1451 // Per-image model (DALL-E): No token data, use resolution-based pricing
1452 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1453 $reply->set_usage( $usage );
1454 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1455 }
1456
1457 $reply->set_choices( $choices );
1458 $reply->set_type( 'images' );
1459
1460 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1461 foreach ( $reply->results as &$result ) {
1462 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1463 'query_envId' => $query->envId,
1464 'query_session' => $query->session,
1465 'query_model' => $query->model,
1466 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1467 $fileUrl = $this->core->files->get_url( $fileId );
1468 $result = $fileUrl;
1469 }
1470 }
1471 $reply->result = $reply->results[0];
1472 return $reply;
1473 }
1474 catch ( Exception $e ) {
1475 $service = $this->get_service_name();
1476 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1477 throw new Exception( "$service: " . $e->getMessage() );
1478 }
1479 }
1480
1481 /*
1482 This is the rest of the OpenAI API support, not related to the models directly.
1483 */
1484
1485 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1486 protected function handle_response_errors( $data ) {
1487 if ( isset( $data['error'] ) && !empty( $data['error'] ) ) {
1488 $message = $data['error']['message'];
1489 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1490 $message = str_replace( $matches[1], '', $message );
1491 }
1492 throw new Exception( $message );
1493 }
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 ( $currentModel['type'] === 'image' ) {
2066 if ( !$resolution ) {
2067 Meow_MWAI_Logging::warn( '(OpenAI) Image models require a resolution.' );
2068 return null;
2069 }
2070 else {
2071 foreach ( $currentModel['resolutions'] as $r ) {
2072 if ( $r['name'] == $resolution ) {
2073 return $r['price'] * $outUnits;
2074 }
2075 }
2076 }
2077 }
2078 else {
2079 if ( $finetune ) {
2080 if ( isset( $currentModel['finetune']['price'] ) ) {
2081 $currentModel['price'] = $currentModel['finetune']['price'];
2082 }
2083 else if ( isset( $currentModel['finetune']['in'] ) ) {
2084 $currentModel['price'] = [
2085 'in' => $currentModel['finetune']['in'],
2086 'out' => $currentModel['finetune']['out']
2087 ];
2088 }
2089 }
2090 $inPrice = $currentModel['price'];
2091 $outPrice = $currentModel['price'];
2092 if ( is_array( $currentModel['price'] ) ) {
2093 $inPrice = $currentModel['price']['in'];
2094 $outPrice = $currentModel['price']['out'];
2095 }
2096 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2097 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2098 return $inTotalPrice + $outTotalPrice;
2099 }
2100 }
2101 }
2102 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2103 return null;
2104 }
2105
2106 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2107 $model = $query->model;
2108 $units = 0;
2109 $finetune = false;
2110 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2111 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2112 $finetune = true;
2113 }
2114 $inUnits = $reply->get_in_tokens( $query );
2115 $outUnits = $reply->get_out_tokens();
2116 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2117 }
2118 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2119 // Image models have two different pricing approaches:
2120 // - Token-based (gpt-image-1, gpt-image-1-mini): Billed by input/output tokens like text models
2121 // - Per-image (DALL-E): Billed by image count and resolution
2122
2123 // Check if this is a token-based model by looking for token data in the reply
2124 if ( isset( $reply->usage['total_tokens'] ) && $reply->usage['total_tokens'] > 0 ) {
2125 // Token-based pricing: Use actual token counts from API response
2126 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2127 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2128 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2129 // Pass null for resolution since token-based models don't use it
2130 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2131 }
2132
2133 // Per-image pricing: Use image count and resolution
2134 $units = $query->maxResults;
2135 $resolution = $query->resolution;
2136 return $this->calculate_price( $model, 0, $units, $resolution, $finetune );
2137 }
2138 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2139 $model = 'whisper';
2140 $units = $reply->get_units();
2141 return $this->calculate_price( $model, 0, $units, null, $finetune );
2142 }
2143 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2144 $units = $reply->get_total_tokens();
2145 return $this->calculate_price( $model, 0, $units, null, $finetune );
2146 }
2147 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2148 return null;
2149 }
2150 }
2151