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