PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 2.9.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v2.9.5
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
1787 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] --> 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] Response Headers: ' . json_encode( $headers ) );
784 error_log( '[AI Engine Queries] 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] <-- 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] 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
829 // Validate URL scheme to prevent SSRF attacks
830 $parts = wp_parse_url( $url );
831 if ( ! isset( $parts['scheme'] ) || ! in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
832 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
833 }
834
835 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
836 file_put_contents( $tmpFile, file_get_contents( $url ) );
837 $length = null;
838 $metadata = wp_read_audio_metadata( $tmpFile );
839 if ( isset( $metadata['length'] ) ) {
840 $length = $metadata['length'];
841 }
842 $data = file_get_contents( $tmpFile );
843 unlink( $tmpFile );
844 return [ 'data' => $data, 'length' => $length ];
845 }
846
847 public function run_transcribe_query( $query ) {
848 // TODO: This function currently only supports the URL method.
849 // But as with Vision, we should support the file upload method too.
850 $attachedFile = $query->attachedFile ?? null;
851 $audioUrl = $query->url ?? null;
852 if ( $attachedFile ) {
853 $audioUrl = $attachedFile->get_url();
854 $query->set_url( $audioUrl );
855 }
856 if ( !filter_var( $audioUrl, FILTER_VALIDATE_URL ) ) {
857 throw new Exception( 'Invalid URL for transcription.' );
858 }
859
860 $audioData = $this->get_audio( $audioUrl );
861 $body = $this->build_body( $query, null, $audioData['data'] );
862 $url = $this->build_url( $query );
863 $headers = $this->build_headers( $query );
864 $options = $this->build_options( $headers, null, $body );
865
866 // Perform the request
867 try {
868 $res = $this->run_query( $url, $options );
869 $data = $res['data'];
870 if ( empty( $data ) ) {
871 throw new Exception( 'Invalid data for transcription.' );
872 }
873 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
874 $reply = new Meow_MWAI_Reply( $query );
875 $reply->set_usage( $usage );
876 $reply->set_choices( $data );
877 return $reply;
878 }
879 catch ( Exception $e ) {
880 $service = $this->get_service_name();
881 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
882 throw new Exception( "$service: " . $e->getMessage() );
883 }
884 }
885
886 public function run_embedding_query( $query ) {
887 $body = $this->build_body( $query );
888 $url = $this->build_url( $query );
889 $headers = $this->build_headers( $query );
890 $options = $this->build_options( $headers, $body );
891
892 try {
893 $res = $this->run_query( $url, $options );
894 $data = $res['data'];
895 if ( empty( $data ) || !isset( $data['data'] ) ) {
896 throw new Exception( 'Invalid data for embedding.' );
897 }
898 $usage = $data['usage'];
899 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
900 $reply = new Meow_MWAI_Reply( $query );
901 $reply->set_usage( $usage );
902 $reply->set_choices( $data['data'] );
903 return $reply;
904 }
905 catch ( Exception $e ) {
906 $message = $e->getMessage();
907 $error = $this->try_decode_error( $message );
908 if ( !is_null( $error ) ) {
909 $message = $error;
910 }
911 $service = $this->get_service_name();
912 Meow_MWAI_Logging::error( "$service: " . $message );
913 throw new Exception( "$service: " . $message );
914 }
915 }
916
917 public function try_decode_error( $data ) {
918 $json = json_decode( $data, true );
919 if ( isset( $json['error']['message'] ) ) {
920 return $json['error']['message'];
921 }
922 return null;
923 }
924
925 protected function finalize_choices( $choices, $responseData, $query ) {
926 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
927 foreach ( $choices as &$choice ) {
928 if ( isset( $choice['message'] ) ) {
929 // If we have both tool_calls and function_call, remove function_call
930 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
931 isset( $choice['message']['function_call'] ) ) {
932 unset( $choice['message']['function_call'] );
933 }
934 }
935 }
936 return $choices;
937 }
938
939 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
940 $isStreaming = !is_null( $streamCallback );
941
942 // Initialize debug mode
943 $this->init_debug_mode( $query );
944
945 // Store the callback for event emission (both streaming and non-streaming debug mode)
946 if ( !is_null( $streamCallback ) ) {
947 $this->streamCallback = $streamCallback;
948 }
949
950 if ( $isStreaming ) {
951 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
952 }
953
954 $this->reset_stream();
955 $body = $this->build_body( $query, $streamCallback );
956 $url = $this->build_url( $query );
957 $headers = $this->build_headers( $query );
958 $options = $this->build_options( $headers, $body );
959
960 // Emit "Request sent" event for feedback queries
961 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
962 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
963 $event = Meow_MWAI_Event::request_sent()
964 ->set_metadata( 'is_feedback', true )
965 ->set_metadata( 'feedback_count', count( $query->blocks ) );
966 call_user_func( $streamCallback, $event );
967 }
968
969 try {
970 $res = $this->run_query( $url, $options, $streamCallback );
971 $reply = new Meow_MWAI_Reply( $query );
972
973 $returned_id = null;
974 $returned_model = $this->inModel;
975 $returned_in_tokens = null;
976 $returned_out_tokens = null;
977 $returned_price = null;
978 $returned_choices = [];
979
980 // Streaming Mode
981 if ( $isStreaming ) {
982 if ( empty( $this->streamContent ) ) {
983 $error = $this->try_decode_error( $this->streamBuffer );
984 if ( !is_null( $error ) ) {
985 throw new Exception( $error );
986 }
987 }
988 $returned_id = $this->inId;
989 $returned_model = $this->inModel ? $this->inModel : $query->model;
990 $message = [ 'role' => 'assistant', 'content' => $this->streamContent ];
991 // Prefer tool_calls; fall back to legacy only if necessary
992 if ( !empty( $this->streamToolCalls ) ) {
993 $message['tool_calls'] = $this->streamToolCalls;
994 }
995 elseif ( !empty( $this->streamFunctionCall ) ) {
996 $message['function_call'] = $this->streamFunctionCall;
997 }
998 if ( !is_null( $this->streamInTokens ) ) {
999 $returned_in_tokens = $this->streamInTokens;
1000 }
1001 if ( !is_null( $this->streamOutTokens ) ) {
1002 $returned_out_tokens = $this->streamOutTokens;
1003 }
1004 if ( !is_null( $this->streamCost ) ) {
1005 $returned_price = $this->streamCost;
1006 }
1007 $returned_choices = [ [ 'message' => $message ] ];
1008 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1009
1010 // Log streaming response data if queries debug is enabled
1011 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1012 if ( $queries_debug ) {
1013 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1014 $streaming_data = [
1015 'id' => $returned_id,
1016 'model' => $returned_model,
1017 'content_length' => strlen( $this->streamContent ),
1018 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1019 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1020 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1021 'usage' => [
1022 'input_tokens' => $returned_in_tokens,
1023 'output_tokens' => $returned_out_tokens,
1024 'cost' => $returned_price
1025 ]
1026 ];
1027
1028 // Log tool calls details if present
1029 if ( !empty( $this->streamToolCalls ) ) {
1030 $streaming_data['tool_calls_details'] = [];
1031 foreach ( $this->streamToolCalls as $tool_call ) {
1032 $streaming_data['tool_calls_details'][] = [
1033 'id' => $tool_call['id'] ?? 'unknown',
1034 'name' => $tool_call['function']['name'] ?? 'unknown',
1035 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1036 ];
1037 }
1038 }
1039
1040 // Log function call if present
1041 if ( !empty( $this->streamFunctionCall ) ) {
1042 $streaming_data['function_call'] = [
1043 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1044 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1045 ];
1046 }
1047
1048 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1049 }
1050 }
1051 // Standard Mode
1052 else {
1053 $data = $res['data'];
1054 if ( empty( $data ) ) {
1055 throw new Exception( 'No content received (res is null).' );
1056 }
1057 if ( !$data['model'] ) {
1058 $service = $this->get_service_name();
1059 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1060 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1061 throw new Exception( 'Invalid response (no model information).' );
1062 }
1063 $returned_id = $data['id'];
1064 $returned_model = $data['model'];
1065 $usage = $data['usage'] ?? [];
1066 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1067 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1068 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1069 $returned_choices = $data['choices'];
1070 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1071 }
1072
1073 // Set the results.
1074 $reply->set_choices( $returned_choices );
1075 if ( !empty( $returned_id ) ) {
1076 $reply->set_id( $returned_id );
1077 }
1078 if ( !empty( $returned_id ) ) {
1079 $reply->set_id( $returned_id );
1080 }
1081
1082 // Handle tokens.
1083 $this->handle_tokens_usage(
1084 $reply,
1085 $query,
1086 $returned_model,
1087 $returned_in_tokens,
1088 $returned_out_tokens,
1089 $returned_price
1090 );
1091
1092 return $reply;
1093 }
1094 catch ( Exception $e ) {
1095 $service = $this->get_service_name();
1096 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1097 $message = "$service: " . $e->getMessage();
1098 throw new Exception( $message );
1099 }
1100 finally {
1101 if ( !is_null( $streamCallback ) ) {
1102 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1103 }
1104 }
1105 }
1106
1107 public function handle_tokens_usage(
1108 $reply,
1109 $query,
1110 $returned_model,
1111 $returned_in_tokens,
1112 $returned_out_tokens,
1113 $returned_price = null
1114 ) {
1115 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1116 $reply->get_in_tokens( $query );
1117 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1118 $reply->get_out_tokens();
1119 $returned_price = !is_null( $returned_price ) ? $returned_price :
1120 $reply->get_price();
1121 $usage = $this->core->record_tokens_usage(
1122 $returned_model,
1123 $returned_in_tokens,
1124 $returned_out_tokens,
1125 $returned_price
1126 );
1127 $reply->set_usage( $usage );
1128
1129 // Set default accuracy to 'estimated' for engines that don't override
1130 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1131 $reply->set_usage_accuracy( 'estimated' );
1132 }
1133
1134 // Request to DALL-E API
1135 public function run_image_query( $query ) {
1136 $body = $this->build_body( $query );
1137 $url = $this->build_url( $query );
1138 $headers = $this->build_headers( $query );
1139 $options = $this->build_options( $headers, $body );
1140
1141 try {
1142 $res = $this->run_query( $url, $options );
1143 $data = $res['data'];
1144 $choices = [];
1145 $choices = $data['data'];
1146 $reply = new Meow_MWAI_Reply( $query );
1147 $model = $query->model;
1148 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1149 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1150 $reply->set_usage( $usage );
1151 $reply->set_usage_accuracy( 'estimated' ); // Image generation always uses estimated pricing
1152 $reply->set_choices( $choices );
1153 $reply->set_type( 'images' );
1154
1155 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1156 foreach ( $reply->results as &$result ) {
1157 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1158 'query_envId' => $query->envId,
1159 'query_session' => $query->session,
1160 'query_model' => $query->model,
1161 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1162 $fileUrl = $this->core->files->get_url( $fileId );
1163 $result = $fileUrl;
1164 }
1165 }
1166 $reply->result = $reply->results[0];
1167 return $reply;
1168 }
1169 catch ( Exception $e ) {
1170 $service = $this->get_service_name();
1171 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1172 throw new Exception( "$service: " . $e->getMessage() );
1173 }
1174 }
1175
1176 public function run_editimage_query( $query ) {
1177 if ( empty( $query->attachedFile ) ) {
1178 throw new Exception( 'No image provided for editing.' );
1179 }
1180 // Ensure the model supports image editing
1181 $modelInfo = $this->retrieve_model_info( $query->model );
1182 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1183 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1184 }
1185 $imageData = $query->attachedFile->get_data();
1186 $body = $this->build_body( $query, null, $imageData );
1187 $url = $this->build_url( $query );
1188 $headers = $this->build_headers( $query );
1189 $options = $this->build_options( $headers, null, $body );
1190
1191 try {
1192 $res = $this->run_query( $url, $options );
1193 $data = $res['data'];
1194 $choices = $data['data'];
1195 $reply = new Meow_MWAI_Reply( $query );
1196 $model = $query->model;
1197 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
1198 $usage = $this->core->record_images_usage( $model, $resolution, $query->maxResults );
1199 $reply->set_usage( $usage );
1200 $reply->set_usage_accuracy( 'estimated' ); // Image generation always uses estimated pricing
1201 $reply->set_choices( $choices );
1202 $reply->set_type( 'images' );
1203
1204 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1205 foreach ( $reply->results as &$result ) {
1206 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1207 'query_envId' => $query->envId,
1208 'query_session' => $query->session,
1209 'query_model' => $query->model,
1210 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1211 $fileUrl = $this->core->files->get_url( $fileId );
1212 $result = $fileUrl;
1213 }
1214 }
1215 $reply->result = $reply->results[0];
1216 return $reply;
1217 }
1218 catch ( Exception $e ) {
1219 $service = $this->get_service_name();
1220 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1221 throw new Exception( "$service: " . $e->getMessage() );
1222 }
1223 }
1224
1225 /*
1226 This is the rest of the OpenAI API support, not related to the models directly.
1227 */
1228
1229 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1230 protected function handle_response_errors( $data ) {
1231 if ( isset( $data['error'] ) && !empty( $data['error'] ) ) {
1232 $message = $data['error']['message'];
1233 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1234 $message = str_replace( $matches[1], '', $message );
1235 }
1236 throw new Exception( $message );
1237 }
1238 }
1239
1240 public function list_files( $purposeFilter = null ) {
1241 if ( empty( $purposeFilter ) ) {
1242 return $this->execute( 'GET', '/files' );
1243 }
1244 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1245 }
1246
1247 public static function get_suffix_for_model( $model ) {
1248 // Legacy fine-tuned models
1249 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1250 if ( count( $matches ) > 0 ) {
1251 return $matches[1];
1252 }
1253
1254 // New fine-tuned models
1255 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1256 if ( count( $matches ) > 0 ) {
1257 return $matches[1];
1258 }
1259
1260 return 'N/A';
1261 }
1262
1263 public static function get_model_without_release_date( $model ) {
1264 if ( empty( $model ) ) {
1265 return null;
1266 }
1267 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1268 }
1269
1270 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1271 $finetunes = $this->list_finetunes( $legacy );
1272 $deleted = [];
1273
1274 foreach ( $finetunes as $finetune ) {
1275 $name = $finetune['model'];
1276 $isSucceeded = $finetune['status'] === 'succeeded';
1277 if ( $isSucceeded ) {
1278 try {
1279 $finetune = $this->get_model( $name );
1280 }
1281 catch ( Exception $e ) {
1282 $deleted[] = $name;
1283 }
1284 }
1285 }
1286 if ( $legacy ) {
1287 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1288 }
1289 else {
1290 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1291 }
1292 return $deleted;
1293 }
1294
1295 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1296 // retrieve all the models since Summer 2023, let's see! WIP.
1297 public function list_finetunes( $legacy = false ) {
1298 if ( $legacy ) {
1299 $res = $this->execute( 'GET', '/fine-tunes' );
1300 }
1301 else {
1302 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1303 }
1304 $finetunes = $res['data'];
1305
1306 // Add suffix
1307 $finetunes = array_map( function ( $finetune ) {
1308 if ( isset( $finetune['user_provided_suffix'] ) ) {
1309 $finetune['suffix'] = $finetune['user_provided_suffix'];
1310 }
1311 else {
1312 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1313 }
1314 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1315 if ( isset( $finetune['estimated_finish'] ) ) {
1316 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1317 }
1318 else {
1319 $finetune['estimatedOn'] = null;
1320 }
1321 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1322 $finetune['base_model'] = $finetune['model'];
1323 $finetune['model'] = $finetune['fine_tuned_model'];
1324 unset( $finetune['object'] );
1325 unset( $finetune['hyperparams'] );
1326 unset( $finetune['result_files'] );
1327 unset( $finetune['training_files'] );
1328 unset( $finetune['validation_files'] );
1329 unset( $finetune['created_at'] );
1330 unset( $finetune['updated_at'] );
1331 unset( $finetune['fine_tuned_model'] );
1332 return $finetune;
1333 }, $finetunes );
1334
1335 usort( $finetunes, function ( $a, $b ) {
1336 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1337 } );
1338
1339 if ( $legacy ) {
1340 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1341 }
1342 else {
1343 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1344 }
1345
1346 return $finetunes;
1347 }
1348
1349 public function moderate( $input ) {
1350 $result = $this->execute( 'POST', '/moderations', [
1351 'input' => $input
1352 ] );
1353 return $result;
1354 }
1355
1356 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1357 $result = $this->execute( 'POST', '/files', null, [
1358 'purpose' => $purpose,
1359 'data' => $data,
1360 'file' => $filename
1361 ] );
1362 return $result;
1363 }
1364
1365 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1366 $body = [
1367 'name' => !empty( $name ) ? $name : 'default',
1368 'metadata' => $metadata
1369 ];
1370 if ( $expiry !== 'never' ) {
1371 if ( is_string( $expiry ) ) {
1372 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1373 $expiry = 7;
1374 }
1375 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1376 if ( $expiry && is_numeric( $expiry ) ) {
1377 $body['expires_after'] = [
1378 'anchor' => 'last_active_at',
1379 'days' => $expiryInDays
1380 ];
1381 }
1382 }
1383 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1384 return $result['id'];
1385 }
1386
1387 public function get_vector_store( $vectorStoreId ) {
1388 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1389 }
1390
1391 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1392 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1393 'file_id' => $fileId
1394 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1395 return $result['id'];
1396
1397 }
1398
1399 public function delete_file( $fileId ) {
1400 return $this->execute( 'DELETE', '/files/' . $fileId );
1401 }
1402
1403 public function get_model( $modelId ) {
1404 return $this->execute( 'GET', '/models/' . $modelId );
1405 }
1406
1407 public function cancel_finetune( $fineTuneId ) {
1408 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1409 }
1410
1411 public function delete_finetune( $modelId ) {
1412 return $this->execute( 'DELETE', '/models/' . $modelId );
1413 }
1414
1415 public function download_file( $fileId, $newFile = null ) {
1416 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1417 $fileInfo = json_decode( (string) $fileInfo, true );
1418 if ( empty( $fileInfo ) ) {
1419 throw new Exception( 'AI Engine: File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1420 }
1421 $filename = $fileInfo['filename'];
1422 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1423 if ( empty( $newFile ) ) {
1424 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1425 $tempFile = wp_tempnam( $filename );
1426 if ( !$tempFile ) {
1427 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1428 }
1429 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1430 $newFile = $tempFile . '.' . $extension;
1431 }
1432 else {
1433 $newFile = $tempFile;
1434 }
1435 }
1436 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1437 file_put_contents( $newFile, $data );
1438 return $newFile;
1439 }
1440
1441 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1442 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1443 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1444 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1445 (float) $hyperparams['learningRateMultiplier'] : null;
1446 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1447 (float) $hyperparams['promptLossWeight'] : null;
1448 $arguments = [
1449 'training_file' => $fileId,
1450 'model' => $model,
1451 'suffix' => $suffix
1452 ];
1453 if ( $legacy ) {
1454 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1455 }
1456 else {
1457 if ( $n_epochs ) {
1458 $arguments['hyperparams'] = [];
1459 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1460 }
1461 if ( $batch_size ) {
1462 if ( empty( $arguments['hyperparams'] ) ) {
1463 $arguments['hyperparams'] = [];
1464 }
1465 $arguments['hyperparams']['batch_size'] = $batch_size;
1466 }
1467 if ( $learning_rate_multiplier ) {
1468 if ( empty( $arguments['hyperparams'] ) ) {
1469 $arguments['hyperparams'] = [];
1470 }
1471 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1472 }
1473 if ( $prompt_loss_weight ) {
1474 if ( empty( $arguments['hyperparams'] ) ) {
1475 $arguments['hyperparams'] = [];
1476 }
1477 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1478 }
1479 if ( $model === 'turbo' ) {
1480 $arguments['model'] = 'gpt-3.5-turbo';
1481 }
1482 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1483 }
1484 return $result;
1485 }
1486
1487 /**
1488 * Build the body of a form request.
1489 * If the field name is 'file', then the field value is the filename of the file to upload.
1490 * The file contents are taken from the 'data' field.
1491 *
1492 * @param array $fields
1493 * @param string $boundary
1494 * @return string
1495 */
1496 public function build_form_body( $fields, $boundary ) {
1497 $body = '';
1498 foreach ( $fields as $name => $value ) {
1499 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1500 continue;
1501 }
1502 $body .= "--$boundary\r\n";
1503 $body .= "Content-Disposition: form-data; name=\"$name\"";
1504 if ( $name === 'image' || $name === 'file' ) {
1505 $body .= "; filename=\"{$value}\"\r\n";
1506 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1507 $body .= "Content-Type: {$mime}\r\n\r\n";
1508 $body .= $fields['data'] . "\r\n";
1509 }
1510 else if ( $name === 'mask' ) {
1511 $body .= "; filename=\"{$value}\"\r\n";
1512 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1513 $body .= "Content-Type: {$mime}\r\n\r\n";
1514 $body .= $fields['mask_data'] . "\r\n";
1515 }
1516 else {
1517 $body .= "\r\n\r\n$value\r\n";
1518 }
1519 }
1520 $body .= "--$boundary--\r\n";
1521 return $body;
1522 }
1523
1524 /**
1525 * Run a request to the OpenAI API.
1526 * Fore more information about the $formFields, refer to the build_form_body method.
1527 *
1528 * @param string $method POST, PUT, GET, DELETE...
1529 * @param string $url The API endpoint
1530 * @param array $query The query parameters (json)
1531 * @param array $formFields The form fields (multipart/form-data)
1532 * @param bool $json Whether to return the response as json or not
1533 * @return array
1534 */
1535 public function execute(
1536 $method,
1537 $url,
1538 $query = null,
1539 $formFields = null,
1540 $json = true,
1541 $extraHeaders = null,
1542 $streamCallback = null
1543 ) {
1544 $isAzure = $this->envType === 'azure';
1545 $isOpenAI = !$isAzure;
1546
1547 // Prepare the headers
1548 $headers = "Content-Type: application/json\r\n";
1549 if ( $isOpenAI ) {
1550 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
1551 if ( $this->organizationId ) {
1552 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
1553 }
1554 }
1555 else if ( $isAzure ) {
1556 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
1557 }
1558
1559 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
1560 if ( !empty( $query ) && !is_string( $query ) ) {
1561 $body = json_encode( $query );
1562 }
1563 else {
1564 $body = $query;
1565 }
1566
1567 // If we have form fields, we need to change the headers and the body.
1568 if ( !empty( $formFields ) ) {
1569 $boundary = wp_generate_password( 24, false );
1570 $headers = [
1571 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1572 ];
1573 if ( $isOpenAI ) {
1574 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
1575 if ( $this->organizationId ) {
1576 $headers['OpenAI-Organization'] = $this->organizationId;
1577 }
1578 }
1579 else if ( $isAzure ) {
1580 $headers['api-key'] = $this->apiKey;
1581 }
1582 $body = $this->build_form_body( $formFields, $boundary );
1583 }
1584
1585 // Maybe we should have headers always as an array... not sure why we have it as a string.
1586 if ( !empty( $extraHeaders ) ) {
1587 foreach ( $extraHeaders as $key => $value ) {
1588 if ( is_array( $headers ) ) {
1589 $headers[$key] = $value;
1590 }
1591 else {
1592 $headers .= "$key: $value\r\n";
1593 }
1594 }
1595 }
1596
1597 // Create the URL
1598 if ( $isOpenAI ) {
1599 $url = 'https://api.openai.com/v1' . $url;
1600 }
1601 else if ( $isAzure ) {
1602 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
1603 $hasQuery = strpos( $url, '?' ) !== false;
1604 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
1605 }
1606
1607 // If it's a GET, body should be null, and we should append the query to the URL.
1608 if ( $method === 'GET' ) {
1609 if ( !empty( $query ) ) {
1610 $hasQuery = strpos( $url, '?' ) !== false;
1611 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
1612 }
1613 $body = null;
1614 }
1615
1616 $options = [
1617 'headers' => $headers,
1618 'method' => $method,
1619 'timeout' => MWAI_TIMEOUT,
1620 'body' => $body,
1621 'sslverify' => false
1622 ];
1623
1624 // Check if queries debug is enabled
1625 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1626
1627 // Log the request if queries debug is enabled
1628 if ( $queries_debug ) {
1629 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
1630
1631 if ( !empty( $body ) ) {
1632 error_log( '[AI Engine Queries] Request Body:' );
1633 error_log( $body );
1634 }
1635
1636 if ( !is_null( $streamCallback ) ) {
1637 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
1638 }
1639 }
1640
1641 try {
1642 if ( !is_null( $streamCallback ) ) {
1643 $options['stream'] = true;
1644 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
1645 // The stream handler calls the streamCallback every time there is content
1646 // TODO: For assistants, we should probably have a different stream handler to
1647 // handle the assistant's specific reply and perform the necessary actions.
1648 $this->streamCallback = $streamCallback;
1649 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1650 }
1651 $res = wp_remote_request( $url, $options );
1652 if ( is_wp_error( $res ) ) {
1653 throw new Exception( $res->get_error_message() );
1654 }
1655 $res = wp_remote_retrieve_body( $res );
1656 $data = $json ? json_decode( $res, true ) : $res;
1657 $this->handle_response_errors( $data );
1658
1659 // Log the response if queries debug is enabled
1660 if ( $queries_debug && is_null( $streamCallback ) ) {
1661 error_log( '[AI Engine Queries] Response Body:' );
1662 error_log( $res );
1663 }
1664 return $data;
1665 }
1666 catch ( Exception $e ) {
1667 $service = $this->get_service_name();
1668 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1669 throw new Exception( "$service: " . $e->getMessage() );
1670 }
1671 finally {
1672 if ( !is_null( $streamCallback ) ) {
1673 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1674 }
1675 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
1676 unlink( $options['filename'] );
1677 }
1678 }
1679 }
1680
1681 public function get_models() {
1682 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
1683 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
1684 foreach ( $finetunes as $finetune ) {
1685 if ( $finetune['status'] !== 'succeeded' ) {
1686 continue;
1687 }
1688 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
1689 if ( !empty( $baseModel ) ) {
1690 $model = null;
1691 foreach ( $models as $currentModel ) {
1692 if ( $currentModel['model'] === $baseModel ) {
1693 $model = $currentModel;
1694 break;
1695 }
1696 }
1697 if ( !empty( $model ) ) {
1698 $model['model'] = $finetune['model'];
1699 $model['name'] = $finetune['suffix'];
1700 $models[] = $model;
1701 }
1702 }
1703 }
1704 return $models;
1705 }
1706
1707 public static function get_models_static() {
1708 return MWAI_OPENAI_MODELS;
1709 }
1710
1711 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
1712 $modelFamily = self::get_model_without_release_date( $modelFamily );
1713 $models = $this->get_models();
1714 foreach ( $models as $currentModel ) {
1715 if ( $currentModel['model'] === $modelFamily ) {
1716 if ( $currentModel['type'] === 'image' ) {
1717 if ( !$resolution ) {
1718 Meow_MWAI_Logging::warn( '(OpenAI) Image models require a resolution.' );
1719 return null;
1720 }
1721 else {
1722 foreach ( $currentModel['resolutions'] as $r ) {
1723 if ( $r['name'] == $resolution ) {
1724 return $r['price'] * $outUnits;
1725 }
1726 }
1727 }
1728 }
1729 else {
1730 if ( $finetune ) {
1731 if ( isset( $currentModel['finetune']['price'] ) ) {
1732 $currentModel['price'] = $currentModel['finetune']['price'];
1733 }
1734 else if ( isset( $currentModel['finetune']['in'] ) ) {
1735 $currentModel['price'] = [
1736 'in' => $currentModel['finetune']['in'],
1737 'out' => $currentModel['finetune']['out']
1738 ];
1739 }
1740 }
1741 $inPrice = $currentModel['price'];
1742 $outPrice = $currentModel['price'];
1743 if ( is_array( $currentModel['price'] ) ) {
1744 $inPrice = $currentModel['price']['in'];
1745 $outPrice = $currentModel['price']['out'];
1746 }
1747 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
1748 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
1749 return $inTotalPrice + $outTotalPrice;
1750 }
1751 }
1752 }
1753 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
1754 return null;
1755 }
1756
1757 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1758 $model = $query->model;
1759 $units = 0;
1760 $finetune = false;
1761 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
1762 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
1763 $finetune = true;
1764 }
1765 $inUnits = $reply->get_in_tokens( $query );
1766 $outUnits = $reply->get_out_tokens();
1767 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
1768 }
1769 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
1770 $units = $query->maxResults;
1771 $resolution = $query->resolution;
1772 return $this->calculate_price( $model, 0, $units, $resolution, $finetune );
1773 }
1774 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
1775 $model = 'whisper';
1776 $units = $reply->get_units();
1777 return $this->calculate_price( $model, 0, $units, null, $finetune );
1778 }
1779 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
1780 $units = $reply->get_total_tokens();
1781 return $this->calculate_price( $model, 0, $units, null, $finetune );
1782 }
1783 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
1784 return null;
1785 }
1786 }
1787