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