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