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