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