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