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