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