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