PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.2.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.2.0
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 8 months ago core.php 8 months ago factory.php 8 months ago google.php 8 months ago mistral.php 9 months ago open-router.php 11 months ago openai.php 8 months ago perplexity.php 10 months ago replicate.php 8 months ago
chatml.php
2127 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.' );
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 // Set default accuracy to 'estimated' for engines that don't override
1300 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1301 $reply->set_usage_accuracy( 'estimated' );
1302 }
1303
1304 // Request to DALL-E API
1305 public function run_image_query( $query, $streamCallback = null ) {
1306 $body = $this->build_body( $query );
1307 $url = $this->build_url( $query );
1308 $headers = $this->build_headers( $query );
1309 $options = $this->build_options( $headers, $body );
1310
1311 try {
1312 $res = $this->run_query( $url, $options );
1313 $data = $res['data'];
1314 $choices = [];
1315 $choices = $data['data'];
1316 $reply = new Meow_MWAI_Reply( $query );
1317 $model = $query->model;
1318 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1319
1320 // Image models use two different pricing models:
1321 // 1. Token-based (gpt-image-1, gpt-image-1-mini): API returns usage.input_tokens and usage.output_tokens
1322 // Price: (input_tokens × $10 + output_tokens × $40) / 1M
1323 // 2. Per-image (DALL-E): API returns no usage data, price based on resolution
1324 // Price: Fixed per image (e.g., $0.040 for 1024x1024)
1325
1326 if ( isset( $data['usage'] ) ) {
1327 // Token-based model (gpt-image-1, gpt-image-1-mini)
1328 $usage = $data['usage'];
1329
1330 // IMPORTANT: OpenAI Images API uses 'input_tokens' and 'output_tokens',
1331 // not 'prompt_tokens' and 'completion_tokens' like the Chat API
1332 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1333 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1334
1335 // Record token usage for statistics tracking
1336 // Note: Price calculation happens in get_price() method, not here
1337 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1338
1339 // Set usage data with token info and accuracy level
1340 $usage['queries'] = 1;
1341 $usage['accuracy'] = 'tokens';
1342
1343 $reply->set_usage( $usage );
1344 $reply->set_usage_accuracy( $usage['accuracy'] );
1345 }
1346 else {
1347 // Per-image model (DALL-E): No token data, use resolution-based pricing
1348 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1349 $reply->set_usage( $usage );
1350 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1351 }
1352
1353 $reply->set_choices( $choices );
1354 $reply->set_type( 'images' );
1355
1356 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1357 foreach ( $reply->results as &$result ) {
1358 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1359 'query_envId' => $query->envId,
1360 'query_session' => $query->session,
1361 'query_model' => $query->model,
1362 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1363 $fileUrl = $this->core->files->get_url( $fileId );
1364 $result = $fileUrl;
1365 }
1366 }
1367 $reply->result = $reply->results[0];
1368 return $reply;
1369 }
1370 catch ( Exception $e ) {
1371 $service = $this->get_service_name();
1372 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1373 throw new Exception( "$service: " . $e->getMessage() );
1374 }
1375 }
1376
1377 public function run_editimage_query( $query ) {
1378 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1379 if ( empty( $attachments ) ) {
1380 throw new Exception( 'No image provided for editing.' );
1381 }
1382 // Ensure the model supports image editing
1383 $modelInfo = $this->retrieve_model_info( $query->model );
1384 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1385 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1386 }
1387 $file = $attachments[0];
1388 $imageData = $file->get_data();
1389 $body = $this->build_body( $query, null, $imageData );
1390 $url = $this->build_url( $query );
1391 $headers = $this->build_headers( $query );
1392 $options = $this->build_options( $headers, null, $body );
1393
1394 try {
1395 $res = $this->run_query( $url, $options );
1396 $data = $res['data'];
1397 $choices = $data['data'];
1398 $reply = new Meow_MWAI_Reply( $query );
1399 $model = $query->model;
1400 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1401
1402 // Image models use two different pricing models:
1403 // 1. Token-based (gpt-image-1, gpt-image-1-mini): API returns usage.input_tokens and usage.output_tokens
1404 // Price: (input_tokens × $10 + output_tokens × $40) / 1M
1405 // 2. Per-image (DALL-E): API returns no usage data, price based on resolution
1406 // Price: Fixed per image (e.g., $0.040 for 1024x1024)
1407
1408 if ( isset( $data['usage'] ) ) {
1409 // Token-based model (gpt-image-1, gpt-image-1-mini)
1410 $usage = $data['usage'];
1411
1412 // IMPORTANT: OpenAI Images API uses 'input_tokens' and 'output_tokens',
1413 // not 'prompt_tokens' and 'completion_tokens' like the Chat API
1414 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1415 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1416
1417 // Record token usage for statistics tracking
1418 // Note: Price calculation happens in get_price() method, not here
1419 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1420
1421 // Set usage data with token info and accuracy level
1422 $usage['queries'] = 1;
1423 $usage['accuracy'] = 'tokens';
1424
1425 $reply->set_usage( $usage );
1426 $reply->set_usage_accuracy( $usage['accuracy'] );
1427 }
1428 else {
1429 // Per-image model (DALL-E): No token data, use resolution-based pricing
1430 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1431 $reply->set_usage( $usage );
1432 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1433 }
1434
1435 $reply->set_choices( $choices );
1436 $reply->set_type( 'images' );
1437
1438 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1439 foreach ( $reply->results as &$result ) {
1440 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1441 'query_envId' => $query->envId,
1442 'query_session' => $query->session,
1443 'query_model' => $query->model,
1444 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1445 $fileUrl = $this->core->files->get_url( $fileId );
1446 $result = $fileUrl;
1447 }
1448 }
1449 $reply->result = $reply->results[0];
1450 return $reply;
1451 }
1452 catch ( Exception $e ) {
1453 $service = $this->get_service_name();
1454 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1455 throw new Exception( "$service: " . $e->getMessage() );
1456 }
1457 }
1458
1459 /*
1460 This is the rest of the OpenAI API support, not related to the models directly.
1461 */
1462
1463 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1464 protected function handle_response_errors( $data ) {
1465 if ( isset( $data['error'] ) && !empty( $data['error'] ) ) {
1466 $message = $data['error']['message'];
1467 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1468 $message = str_replace( $matches[1], '', $message );
1469 }
1470 throw new Exception( $message );
1471 }
1472 }
1473
1474 public function list_files( $purposeFilter = null ) {
1475 if ( empty( $purposeFilter ) ) {
1476 return $this->execute( 'GET', '/files' );
1477 }
1478 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1479 }
1480
1481 public static function get_suffix_for_model( $model ) {
1482 // Legacy fine-tuned models
1483 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1484 if ( count( $matches ) > 0 ) {
1485 return $matches[1];
1486 }
1487
1488 // New fine-tuned models
1489 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1490 if ( count( $matches ) > 0 ) {
1491 return $matches[1];
1492 }
1493
1494 return 'N/A';
1495 }
1496
1497 public static function get_model_without_release_date( $model ) {
1498 if ( empty( $model ) ) {
1499 return null;
1500 }
1501 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1502 }
1503
1504 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1505 $finetunes = $this->list_finetunes( $legacy );
1506 $deleted = [];
1507
1508 foreach ( $finetunes as $finetune ) {
1509 $name = $finetune['model'];
1510 $isSucceeded = $finetune['status'] === 'succeeded';
1511 if ( $isSucceeded ) {
1512 try {
1513 $finetune = $this->get_model( $name );
1514 }
1515 catch ( Exception $e ) {
1516 $deleted[] = $name;
1517 }
1518 }
1519 }
1520 if ( $legacy ) {
1521 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1522 }
1523 else {
1524 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1525 }
1526 return $deleted;
1527 }
1528
1529 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1530 // retrieve all the models since Summer 2023, let's see! WIP.
1531 public function list_finetunes( $legacy = false ) {
1532 if ( $legacy ) {
1533 $res = $this->execute( 'GET', '/fine-tunes' );
1534 }
1535 else {
1536 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1537 }
1538 $finetunes = $res['data'];
1539
1540 // Add suffix
1541 $finetunes = array_map( function ( $finetune ) {
1542 if ( isset( $finetune['user_provided_suffix'] ) ) {
1543 $finetune['suffix'] = $finetune['user_provided_suffix'];
1544 }
1545 else {
1546 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1547 }
1548 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1549 if ( isset( $finetune['estimated_finish'] ) ) {
1550 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1551 }
1552 else {
1553 $finetune['estimatedOn'] = null;
1554 }
1555 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1556 $finetune['base_model'] = $finetune['model'];
1557 $finetune['model'] = $finetune['fine_tuned_model'];
1558 unset( $finetune['object'] );
1559 unset( $finetune['hyperparams'] );
1560 unset( $finetune['result_files'] );
1561 unset( $finetune['training_files'] );
1562 unset( $finetune['validation_files'] );
1563 unset( $finetune['created_at'] );
1564 unset( $finetune['updated_at'] );
1565 unset( $finetune['fine_tuned_model'] );
1566 return $finetune;
1567 }, $finetunes );
1568
1569 usort( $finetunes, function ( $a, $b ) {
1570 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1571 } );
1572
1573 if ( $legacy ) {
1574 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1575 }
1576 else {
1577 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1578 }
1579
1580 return $finetunes;
1581 }
1582
1583 public function moderate( $input ) {
1584 $result = $this->execute( 'POST', '/moderations', [
1585 'input' => $input
1586 ] );
1587 return $result;
1588 }
1589
1590 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1591 global $wp_filter;
1592
1593 // Build multipart form data
1594 $boundary = wp_generate_password( 24, false );
1595 $body = '';
1596 $body .= "--$boundary\r\n";
1597 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1598 $body .= $purpose . "\r\n";
1599 $body .= "--$boundary\r\n";
1600 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1601 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1602 $body .= $data . "\r\n";
1603 $body .= "--$boundary--\r\n";
1604
1605 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1606 // Save current hooks
1607 $saved_hooks = null;
1608 if ( isset( $wp_filter['http_api_curl'] ) ) {
1609 $saved_hooks = $wp_filter['http_api_curl'];
1610 unset( $wp_filter['http_api_curl'] );
1611 }
1612
1613 // Upload using WordPress HTTP API
1614 $url = 'https://api.openai.com/v1/files';
1615 $response = wp_remote_post( $url, [
1616 'headers' => [
1617 'Authorization' => 'Bearer ' . $this->apiKey,
1618 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1619 ],
1620 'body' => $body,
1621 'timeout' => 60
1622 ] );
1623
1624 // Restore hooks
1625 if ( $saved_hooks !== null ) {
1626 $wp_filter['http_api_curl'] = $saved_hooks;
1627 }
1628
1629 if ( is_wp_error( $response ) ) {
1630 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1631 }
1632
1633 $response_body = wp_remote_retrieve_body( $response );
1634 return json_decode( $response_body, true );
1635 }
1636
1637 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1638 $body = [
1639 'name' => !empty( $name ) ? $name : 'default',
1640 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1641 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1642 ];
1643 if ( $expiry !== 'never' ) {
1644 if ( is_string( $expiry ) ) {
1645 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1646 $expiry = 7;
1647 }
1648 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1649 if ( $expiry && is_numeric( $expiry ) ) {
1650 $body['expires_after'] = [
1651 'anchor' => 'last_active_at',
1652 'days' => $expiryInDays
1653 ];
1654 }
1655 }
1656 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1657 return $result['id'];
1658 }
1659
1660 public function get_vector_store( $vectorStoreId ) {
1661 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1662 }
1663
1664 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1665 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1666 'file_id' => $fileId
1667 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1668 return $result['id'];
1669
1670 }
1671
1672 public function delete_file( $fileId ) {
1673 return $this->execute( 'DELETE', '/files/' . $fileId );
1674 }
1675
1676 public function get_model( $modelId ) {
1677 return $this->execute( 'GET', '/models/' . $modelId );
1678 }
1679
1680 public function cancel_finetune( $fineTuneId ) {
1681 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1682 }
1683
1684 public function delete_finetune( $modelId ) {
1685 return $this->execute( 'DELETE', '/models/' . $modelId );
1686 }
1687
1688 public function download_file( $fileId, $newFile = null ) {
1689 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1690 $fileInfo = json_decode( (string) $fileInfo, true );
1691 if ( empty( $fileInfo ) ) {
1692 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1693 }
1694 $filename = $fileInfo['filename'];
1695 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1696 if ( empty( $newFile ) ) {
1697 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1698 $tempFile = wp_tempnam( $filename );
1699 if ( !$tempFile ) {
1700 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1701 }
1702 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1703 $newFile = $tempFile . '.' . $extension;
1704 }
1705 else {
1706 $newFile = $tempFile;
1707 }
1708 }
1709 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1710 file_put_contents( $newFile, $data );
1711 return $newFile;
1712 }
1713
1714 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1715 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1716 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1717 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1718 (float) $hyperparams['learningRateMultiplier'] : null;
1719 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1720 (float) $hyperparams['promptLossWeight'] : null;
1721 $arguments = [
1722 'training_file' => $fileId,
1723 'model' => $model,
1724 'suffix' => $suffix
1725 ];
1726 if ( $legacy ) {
1727 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1728 }
1729 else {
1730 if ( $n_epochs ) {
1731 $arguments['hyperparams'] = [];
1732 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1733 }
1734 if ( $batch_size ) {
1735 if ( empty( $arguments['hyperparams'] ) ) {
1736 $arguments['hyperparams'] = [];
1737 }
1738 $arguments['hyperparams']['batch_size'] = $batch_size;
1739 }
1740 if ( $learning_rate_multiplier ) {
1741 if ( empty( $arguments['hyperparams'] ) ) {
1742 $arguments['hyperparams'] = [];
1743 }
1744 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1745 }
1746 if ( $prompt_loss_weight ) {
1747 if ( empty( $arguments['hyperparams'] ) ) {
1748 $arguments['hyperparams'] = [];
1749 }
1750 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1751 }
1752 if ( $model === 'turbo' ) {
1753 $arguments['model'] = 'gpt-3.5-turbo';
1754 }
1755 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1756 }
1757 return $result;
1758 }
1759
1760 /**
1761 * Build the body of a form request.
1762 * If the field name is 'file', then the field value is the filename of the file to upload.
1763 * The file contents are taken from the 'data' field.
1764 *
1765 * @param array $fields
1766 * @param string $boundary
1767 * @return string
1768 */
1769 public function build_form_body( $fields, $boundary ) {
1770 $body = '';
1771 foreach ( $fields as $name => $value ) {
1772 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1773 continue;
1774 }
1775 $body .= "--$boundary\r\n";
1776 $body .= "Content-Disposition: form-data; name=\"$name\"";
1777 if ( $name === 'image' || $name === 'file' ) {
1778 $body .= "; filename=\"{$value}\"\r\n";
1779 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1780 $body .= "Content-Type: {$mime}\r\n\r\n";
1781 $body .= $fields['data'] . "\r\n";
1782 }
1783 else if ( $name === 'mask' ) {
1784 $body .= "; filename=\"{$value}\"\r\n";
1785 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1786 $body .= "Content-Type: {$mime}\r\n\r\n";
1787 $body .= $fields['mask_data'] . "\r\n";
1788 }
1789 else {
1790 // JSON encode arrays and objects, keep scalars as-is
1791 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
1792 $body .= "\r\n\r\n$encodedValue\r\n";
1793 }
1794 }
1795 $body .= "--$boundary--\r\n";
1796 return $body;
1797 }
1798
1799 /**
1800 * Run a request to the OpenAI API.
1801 * Fore more information about the $formFields, refer to the build_form_body method.
1802 *
1803 * @param string $method POST, PUT, GET, DELETE...
1804 * @param string $url The API endpoint
1805 * @param array $query The query parameters (json)
1806 * @param array $formFields The form fields (multipart/form-data)
1807 * @param bool $json Whether to return the response as json or not
1808 * @return array
1809 */
1810 public function execute(
1811 $method,
1812 $url,
1813 $query = null,
1814 $formFields = null,
1815 $json = true,
1816 $extraHeaders = null,
1817 $streamCallback = null
1818 ) {
1819 $isAzure = $this->envType === 'azure';
1820 $isOpenAI = !$isAzure;
1821
1822 // Prepare the headers
1823 $headers = "Content-Type: application/json\r\n";
1824 if ( $isOpenAI ) {
1825 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
1826 if ( $this->organizationId ) {
1827 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
1828 }
1829 }
1830 else if ( $isAzure ) {
1831 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
1832 }
1833
1834 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
1835 if ( !empty( $query ) && !is_string( $query ) ) {
1836 $body = $this->safe_json_encode( $query, 'query body' );
1837 }
1838 else {
1839 $body = $query;
1840 }
1841
1842 // If we have form fields, we need to change the headers and the body.
1843 if ( !empty( $formFields ) ) {
1844 $boundary = wp_generate_password( 24, false );
1845 $headers = [
1846 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1847 ];
1848 if ( $isOpenAI ) {
1849 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
1850 if ( $this->organizationId ) {
1851 $headers['OpenAI-Organization'] = $this->organizationId;
1852 }
1853 }
1854 else if ( $isAzure ) {
1855 $headers['api-key'] = $this->apiKey;
1856 }
1857 $body = $this->build_form_body( $formFields, $boundary );
1858 }
1859
1860 // Maybe we should have headers always as an array... not sure why we have it as a string.
1861 if ( !empty( $extraHeaders ) ) {
1862 foreach ( $extraHeaders as $key => $value ) {
1863 if ( is_array( $headers ) ) {
1864 $headers[$key] = $value;
1865 }
1866 else {
1867 $headers .= "$key: $value\r\n";
1868 }
1869 }
1870 }
1871
1872 // Create the URL
1873 if ( $isOpenAI ) {
1874 $url = 'https://api.openai.com/v1' . $url;
1875 }
1876 else if ( $isAzure ) {
1877 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
1878 $hasQuery = strpos( $url, '?' ) !== false;
1879 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
1880 }
1881
1882
1883 // If it's a GET, body should be null, and we should append the query to the URL.
1884 if ( $method === 'GET' ) {
1885 if ( !empty( $query ) ) {
1886 $hasQuery = strpos( $url, '?' ) !== false;
1887 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
1888 }
1889 $body = null;
1890 }
1891
1892 $options = [
1893 'headers' => $headers,
1894 'method' => $method,
1895 'timeout' => MWAI_TIMEOUT,
1896 'body' => $body,
1897 'sslverify' => false
1898 ];
1899
1900 // Check if queries debug is enabled
1901 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1902
1903 // Log the request if queries debug is enabled
1904 if ( $queries_debug ) {
1905 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
1906
1907 if ( !empty( $body ) ) {
1908 error_log( '[AI Engine Queries] Request Body:' );
1909 error_log( $body );
1910 }
1911
1912 if ( !is_null( $streamCallback ) ) {
1913 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
1914 }
1915 }
1916
1917 try {
1918 if ( !is_null( $streamCallback ) ) {
1919 $options['stream'] = true;
1920 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
1921 // The stream handler calls the streamCallback every time there is content
1922 // TODO: For assistants, we should probably have a different stream handler to
1923 // handle the assistant's specific reply and perform the necessary actions.
1924 $this->streamCallback = $streamCallback;
1925 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1926 }
1927 $res = wp_remote_request( $url, $options );
1928 if ( is_wp_error( $res ) ) {
1929 throw new Exception( $res->get_error_message() );
1930 }
1931
1932 $res = wp_remote_retrieve_body( $res );
1933
1934
1935 // Handle empty responses for container LIST API only (not for file content downloads)
1936 if ( strpos( $url, '/containers/' ) !== false &&
1937 strpos( $url, '/files' ) !== false &&
1938 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
1939 empty( $res ) ) {
1940 // Return empty array for empty container files LIST response
1941 $data = $json ? [] : '';
1942 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
1943 } else {
1944 $data = $json ? json_decode( $res, true ) : $res;
1945 }
1946
1947 // Debug logging for decoded data (skip for content downloads)
1948 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
1949 error_log( '[AI Engine] After json_decode:' );
1950 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
1951 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
1952 if ( $data !== null && is_array( $data ) ) {
1953 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
1954 error_log( '[AI Engine] - Data count: ' . count( $data ) );
1955 }
1956 if ( $json && $data === null && !empty( $res ) ) {
1957 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
1958 }
1959 }
1960
1961 $this->handle_response_errors( $data );
1962
1963 // Log the response if queries debug is enabled
1964 if ( $queries_debug && is_null( $streamCallback ) ) {
1965 error_log( '[AI Engine Queries] Response Body:' );
1966 error_log( $res );
1967 }
1968 return $data;
1969 }
1970 catch ( Exception $e ) {
1971 $service = $this->get_service_name();
1972 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1973 throw new Exception( "$service: " . $e->getMessage() );
1974 }
1975 finally {
1976 if ( !is_null( $streamCallback ) ) {
1977 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1978 }
1979 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
1980 unlink( $options['filename'] );
1981 }
1982 }
1983 }
1984
1985 public function get_models() {
1986 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
1987 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
1988 foreach ( $finetunes as $finetune ) {
1989 if ( $finetune['status'] !== 'succeeded' ) {
1990 continue;
1991 }
1992 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
1993 if ( !empty( $baseModel ) ) {
1994 $model = null;
1995 foreach ( $models as $currentModel ) {
1996 if ( $currentModel['model'] === $baseModel ) {
1997 $model = $currentModel;
1998 break;
1999 }
2000 }
2001 if ( !empty( $model ) ) {
2002 $model['model'] = $finetune['model'];
2003 $model['name'] = $finetune['suffix'];
2004 $models[] = $model;
2005 }
2006 }
2007 }
2008 return $models;
2009 }
2010
2011 public static function get_models_static() {
2012 return MWAI_OPENAI_MODELS;
2013 }
2014
2015 /**
2016 * Recursively search for container_id in the response data
2017 */
2018 protected function search_for_container_id_recursive( $data, $path = '' ) {
2019 if ( is_array( $data ) || is_object( $data ) ) {
2020 foreach ( $data as $key => $value ) {
2021 $currentPath = $path ? $path . '.' . $key : $key;
2022
2023 // Check if this key is container_id
2024 if ( $key === 'container_id' ) {
2025 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2026 }
2027
2028 // Recursively search in nested structures
2029 if ( is_array( $value ) || is_object( $value ) ) {
2030 $this->search_for_container_id_recursive( $value, $currentPath );
2031 }
2032 }
2033 }
2034 }
2035
2036 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2037 $modelFamily = self::get_model_without_release_date( $modelFamily );
2038 $models = $this->get_models();
2039 foreach ( $models as $currentModel ) {
2040 if ( $currentModel['model'] === $modelFamily ) {
2041 if ( $currentModel['type'] === 'image' ) {
2042 if ( !$resolution ) {
2043 Meow_MWAI_Logging::warn( '(OpenAI) Image models require a resolution.' );
2044 return null;
2045 }
2046 else {
2047 foreach ( $currentModel['resolutions'] as $r ) {
2048 if ( $r['name'] == $resolution ) {
2049 return $r['price'] * $outUnits;
2050 }
2051 }
2052 }
2053 }
2054 else {
2055 if ( $finetune ) {
2056 if ( isset( $currentModel['finetune']['price'] ) ) {
2057 $currentModel['price'] = $currentModel['finetune']['price'];
2058 }
2059 else if ( isset( $currentModel['finetune']['in'] ) ) {
2060 $currentModel['price'] = [
2061 'in' => $currentModel['finetune']['in'],
2062 'out' => $currentModel['finetune']['out']
2063 ];
2064 }
2065 }
2066 $inPrice = $currentModel['price'];
2067 $outPrice = $currentModel['price'];
2068 if ( is_array( $currentModel['price'] ) ) {
2069 $inPrice = $currentModel['price']['in'];
2070 $outPrice = $currentModel['price']['out'];
2071 }
2072 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2073 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2074 return $inTotalPrice + $outTotalPrice;
2075 }
2076 }
2077 }
2078 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2079 return null;
2080 }
2081
2082 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2083 $model = $query->model;
2084 $units = 0;
2085 $finetune = false;
2086 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2087 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2088 $finetune = true;
2089 }
2090 $inUnits = $reply->get_in_tokens( $query );
2091 $outUnits = $reply->get_out_tokens();
2092 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2093 }
2094 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2095 // Image models have two different pricing approaches:
2096 // - Token-based (gpt-image-1, gpt-image-1-mini): Billed by input/output tokens like text models
2097 // - Per-image (DALL-E): Billed by image count and resolution
2098
2099 // Check if this is a token-based model by looking for token data in the reply
2100 if ( isset( $reply->usage['total_tokens'] ) && $reply->usage['total_tokens'] > 0 ) {
2101 // Token-based pricing: Use actual token counts from API response
2102 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2103 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2104 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2105 // Pass null for resolution since token-based models don't use it
2106 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2107 }
2108
2109 // Per-image pricing: Use image count and resolution
2110 $units = $query->maxResults;
2111 $resolution = $query->resolution;
2112 return $this->calculate_price( $model, 0, $units, $resolution, $finetune );
2113 }
2114 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2115 $model = 'whisper';
2116 $units = $reply->get_units();
2117 return $this->calculate_price( $model, 0, $units, null, $finetune );
2118 }
2119 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2120 $units = $reply->get_total_tokens();
2121 return $this->calculate_price( $model, 0, $units, null, $finetune );
2122 }
2123 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2124 return null;
2125 }
2126 }
2127