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