PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.6
3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.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
anthropic.php 1 week ago chatml.php 1 week ago core.php 1 week ago custom.php 1 week ago factory.php 1 month ago google-interactions.php 1 week ago google.php 1 week ago mistral.php 1 month ago open-router.php 1 month ago openai.php 1 week ago ovh.php 1 week ago perplexity.php 7 months ago replicate.php 6 months ago xai.php 2 months ago
chatml.php
2264 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 protected $streamThinkTag = 0; // Inside an inline <think> block (Qwen-style models)
32
33 protected $streamInTokens = null;
34 protected $streamOutTokens = null;
35 protected $streamCost = null;
36 protected $streamStartEmitted = false;
37
38 public function __construct( $core, $env ) {
39 parent::__construct( $core, $env );
40 $this->set_environment();
41 }
42
43 public function reset_stream() {
44 $this->streamContent = null;
45 $this->streamBuffer = null;
46 $this->streamFunctionCall = null;
47 $this->streamToolCalls = [];
48 $this->streamLastMessage = null;
49 $this->streamThinking = null;
50 $this->streamThinkTag = 0;
51 $this->streamInTokens = null;
52 $this->streamOutTokens = null;
53 $this->inModel = null;
54 $this->inId = null;
55 $this->emittedFunctionResults = [];
56 $this->streamStartEmitted = false;
57 }
58
59 protected function set_environment() {
60 $env = $this->env;
61 $this->apiKey = $env['apikey'];
62
63 if ( isset( $env['organizationId'] ) ) {
64 $this->organizationId = $env['organizationId'];
65 }
66 if ( $this->envType === 'azure' ) {
67 $this->azureDeployments = isset( $env['deployments'] ) ? $env['deployments'] : [];
68 }
69 }
70
71 protected function get_azure_deployment_name( $model ) {
72 foreach ( $this->azureDeployments as $deployment ) {
73 if ( $deployment['model'] === $model && !empty( $deployment['name'] ) ) {
74 return $deployment['name'];
75 }
76 }
77 throw new Exception( 'Unknown deployment for model: ' . $model );
78 }
79
80 protected function get_service_name() {
81 return $this->envType === 'azure' ? 'Azure' : 'OpenAI';
82 }
83
84 private function is_o1_model( $model ) {
85 $modelDef = $this->retrieve_model_info( $model );
86 return !empty( $modelDef['tags'] ) && in_array( 'o1-model', $modelDef['tags'] );
87 }
88
89 private function is_gpt5_model( $model ) {
90 // Check if the model is a GPT-5 variant
91 return strpos( $model, 'gpt-5' ) === 0;
92 }
93
94 private function requires_developer_roles( $model ) {
95 return false;
96 }
97
98 private function is_realtime_model( $model ) {
99 $modelDef = $this->retrieve_model_info( $model );
100 return !empty( $modelDef['family'] ) && $modelDef['family'] === 'realtime';
101 }
102
103 protected function build_messages( $query ) {
104 $messages = [];
105
106 // First, we need to add the first message (the instructions).
107 if ( !empty( $query->instructions ) ) {
108 //if ( !$this->is_o1_model( $query->model ) ) {
109 $messages[] = [ 'role' => 'system', 'content' => $query->instructions ];
110 //}
111 }
112
113 // Then, if any, we need to add the 'messages', they are already formatted.
114 foreach ( $query->messages as $message ) {
115 $messages[] = $message;
116 }
117
118 // If there is a context, we need to add it with proper framing.
119 if ( !empty( $query->context ) ) {
120 $framedContext = $this->core->frame_context( $query->context );
121 $messages[] = [ 'role' => 'system', 'content' => $framedContext ];
122 }
123
124 // Finally, we need to add the message, but if there is an image, we need to add it as a system message.
125 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
126 if ( !empty( $attachments ) ) {
127 // The message can be empty when the user only sends files; in that case the
128 // text part is skipped entirely, as some providers reject empty text parts.
129 $content = [];
130 $message = $query->get_message();
131 if ( $message !== null && $message !== '' ) {
132 $content[] = [
133 'type' => 'text',
134 'text' => $message
135 ];
136 }
137
138 // Handle all attachments (unified approach)
139 foreach ( $attachments as $file ) {
140 // Check file type BEFORE trying to access file data
141 // Images can be loaded via URL or base64, but PDFs might use OpenAI file_id references
142 $mimeType = $file->get_mimeType() ?? '';
143 $isImage = strpos( $mimeType, 'image/' ) === 0;
144
145 if ( $isImage ) {
146 $finalUrl = null;
147 if ( $query->image_remote_upload === 'url' ) {
148 $finalUrl = $file->get_url();
149 }
150 else {
151 $finalUrl = $file->get_inline_base64_url();
152 }
153
154 $content[] = [
155 'type' => 'image_url',
156 'image_url' => [
157 'url' => $finalUrl
158 ]
159 ];
160 }
161 // Skip non-images for Chat Completions API
162 }
163
164 // If every attachment was skipped and there was no message, fall back to an
165 // empty text part rather than sending an invalid empty content array.
166 if ( empty( $content ) ) {
167 $content[] = [ 'type' => 'text', 'text' => '' ];
168 }
169
170 $messages[] = [
171 'role' => 'user',
172 'content' => $content
173 ];
174 }
175 else {
176 $messages[] = [ 'role' => 'user', 'content' => $query->get_message() ];
177 }
178
179 // We need to convert all the 'system' role into 'developer' role.
180 if ( $this->requires_developer_roles( $query->model ) ) {
181 foreach ( $messages as &$message ) {
182 if ( $message['role'] === 'system' ) {
183 $message['role'] = 'developer';
184 }
185 }
186 }
187 // But otherwise, if it's o1, we need to remove the message which are 'system'
188 else if ( $this->is_o1_model( $query->model ) ) {
189 $hasChanges = false;
190 foreach ( $messages as $index => $message ) {
191 if ( $message['role'] === 'system' ) {
192 unset( $messages[$index] );
193 $hasChanges = true;
194 }
195 }
196 if ( $hasChanges ) {
197 $messages = array_values( $messages );
198 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support System nor Developer messages. They were removed.' );
199 }
200 }
201
202 return $messages;
203 }
204
205 /**
206 * Convert functions to Responses API tools format.
207 * Shared by OpenAI and OpenRouter engines.
208 */
209 protected function build_responses_tools( $functions ) {
210 $tools = [];
211 foreach ( $functions as $function ) {
212 $functionData = $function->serializeForOpenAI();
213 if ( !isset( $functionData['name'] ) || empty( $functionData['name'] ) ) {
214 Meow_MWAI_Logging::warn( 'Function missing required name field' );
215 continue;
216 }
217 $parameters = $functionData['parameters'] ?? null;
218 if ( !$parameters ) {
219 $parameters = [
220 'type' => 'object',
221 'properties' => new stdClass(),
222 'required' => []
223 ];
224 }
225 else {
226 if ( isset( $parameters['properties'] ) &&
227 is_array( $parameters['properties'] ) &&
228 empty( $parameters['properties'] ) ) {
229 $parameters['properties'] = new stdClass();
230 }
231 }
232 $tool = [
233 'type' => 'function',
234 'name' => $functionData['name'],
235 'description' => $functionData['description'] ?? '',
236 'parameters' => $parameters,
237 'strict' => false
238 ];
239 $tools[] = $tool;
240 }
241 return $tools;
242 }
243
244 protected function build_body( $query, $streamCallback = null, $extra = null ) {
245 if ( $query instanceof Meow_MWAI_Query_Text ) {
246 $body = [
247 'model' => $query->model,
248 'stream' => !is_null( $streamCallback ),
249 ];
250
251 if ( !empty( $query->maxTokens ) ) {
252 // max_tokens has been deprecated in favor of max_completion_tokens in 2025.
253 $body['max_completion_tokens'] = $query->maxTokens;
254 }
255
256 if ( !empty( $query->temperature ) ) {
257 // GPT-5 and o1 models don't support temperature parameter
258 if ( !$this->is_o1_model( $query->model ) && !$this->is_gpt5_model( $query->model ) ) {
259 $body['temperature'] = $query->temperature;
260 }
261 else if ( $this->is_o1_model( $query->model ) ) {
262 // o1 models require temperature to be 1 if specified
263 $body['temperature'] = 1;
264 }
265 // For GPT-5 models, we simply don't include the temperature parameter
266 }
267
268 if ( !empty( $query->maxResults ) ) {
269 $body['n'] = $query->maxResults;
270 }
271
272 if ( !empty( $query->stop ) ) {
273 $body['stop'] = $query->stop;
274 }
275
276 if ( !empty( $query->responseFormat ) ) {
277 if ( $query->responseFormat === 'json' ) {
278 $body['response_format'] = [ 'type' => 'json_object' ];
279 }
280 }
281
282 // Usage Data (only for OpenAI)
283 // https://cookbook.openai.com/examples/how_to_stream_completions#4-how-to-get-token-usage-data-for-streamed-chat-completion-response
284 if ( !empty( $streamCallback ) && $this->envType === 'openai' ) {
285 $body['stream_options'] = [
286 'include_usage' => true,
287 ];
288 }
289
290 if ( !empty( $query->functions ) ) {
291 $model = $this->retrieve_model_info( $query->model );
292 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
293 Meow_MWAI_Logging::warn( 'The model ' . $query->model . ' doesn\'t support Function Calling.' );
294 }
295 else if ( strpos( $query->model, 'ft:' ) === 0 ) {
296 Meow_MWAI_Logging::warn( 'OpenAI doesn\'t support Function Calling with fine-tuned models yet.' );
297 }
298 else {
299 $body['tools'] = [];
300 // Dynamic function: they will interactively enhance the completion (tools).
301 foreach ( $query->functions as $function ) {
302 $body['tools'][] = [
303 'type' => 'function',
304 'function' => $function->serializeForOpenAI()
305 ];
306 }
307 // Static functions: they will be executed at the end of the completion.
308 //$body['function_call'] = $query->functionCall;
309 }
310 }
311 $body['messages'] = $this->build_messages( $query );
312
313 // Add the feedback if it's a feedback query.
314 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
315 if ( !empty( $query->blocks ) ) {
316 foreach ( $query->blocks as $feedback_block ) {
317 $body['messages'][] = $feedback_block['rawMessage'];
318 foreach ( $feedback_block['feedbacks'] as $feedback ) {
319 // Ensure content is a string for the API
320 $content = $feedback['reply']['value'];
321 if ( !is_string( $content ) ) {
322 $content = json_encode( $content );
323 }
324
325 $body['messages'][] = [
326 'tool_call_id' => $feedback['request']['toolId'],
327 'role' => 'tool',
328 'name' => $feedback['request']['name'],
329 'content' => $content
330 ];
331
332 // Note: Function result events are now emitted centrally in core.php
333 // when the function is actually executed
334 }
335 }
336 }
337 return $body;
338 }
339
340 return $body;
341 }
342 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
343 // Determine filename and MIME type
344 $filename = 'audio.mp3'; // default
345 $mimeType = 'audio/mpeg'; // default
346 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
347 if ( !empty( $attachments ) ) {
348 $firstAttachment = $attachments[0];
349 if ( method_exists( $firstAttachment, 'get_filename' ) ) {
350 $filename = $firstAttachment->get_filename();
351 }
352 if ( method_exists( $firstAttachment, 'get_mimeType' ) ) {
353 $detectedMime = $firstAttachment->get_mimeType();
354 if ( !empty( $detectedMime ) ) {
355 $mimeType = $detectedMime;
356 }
357 }
358 }
359 else if ( !empty( $query->url ) ) {
360 $filename = basename( $query->url );
361 }
362
363 // Guard against malformed filenames coming from form prompts/placeholders
364 if ( empty( $filename ) ) {
365 $filename = 'audio.mp3';
366 }
367 $filename = preg_replace( "/[\r\n]+/", '', $filename );
368 $mimeType = preg_replace( "/[\r\n]+/", '', $mimeType );
369 if ( empty( $mimeType ) ) {
370 $mimeType = 'audio/mpeg';
371 }
372
373 $body = [
374 'prompt' => $query->message,
375 'model' => $query->model,
376 'response_format' => 'text',
377 'file' => $filename,
378 'data' => $extra,
379 'mime' => $mimeType
380 ];
381 return $body;
382 }
383 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
384 $body = [ 'input' => $query->message, 'model' => $query->model ];
385 if ( $this->envType === 'azure' ) {
386 $body = [ 'input' => $query->message ];
387 }
388 // Dimensions are only supported by v3 models
389 if ( !empty( $query->dimensions ) && strpos( $query->model, 'ada-002' ) === false ) {
390 $body['dimensions'] = $query->dimensions;
391 }
392 return $body;
393 }
394 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
395 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
396 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
397 $firstFile = !empty( $attachments ) ? $attachments[0] : null;
398 $filename = $firstFile ? $firstFile->get_filename() : '';
399 $mimeType = $firstFile ? $firstFile->get_mimeType() : null;
400 $body = [
401 'prompt' => $query->message,
402 'n' => $query->maxResults,
403 'size' => $resolution,
404 'image' => $filename,
405 'data' => $extra
406 ];
407 if ( !empty( $mimeType ) ) {
408 $body['mime'] = $mimeType;
409 }
410
411 // Add mask if provided
412 if ( !empty( $query->mask ) ) {
413 $maskData = $query->mask->get_data();
414 $maskFilename = 'mask.png';
415 $maskMimeType = $query->mask->get_mimeType();
416
417 $body['mask'] = $maskFilename;
418 $body['mask_data'] = $maskData;
419 if ( !empty( $maskMimeType ) ) {
420 $body['mask_mime'] = $maskMimeType;
421 }
422 }
423 // 'response_format' => 'b64_json',
424 if ( !empty( $query->model ) ) {
425 $body['model'] = $query->model;
426 }
427 return $body;
428 }
429 else if ( $query instanceof Meow_MWAI_Query_Image ) {
430 $model = $query->model;
431 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
432 $body = [
433 'model' => $model,
434 'prompt' => $query->message,
435 'n' => $query->maxResults,
436 'size' => $resolution,
437 'moderation' => 'low',
438 ];
439 // Only forward 'quality' when the caller (or final_checks) resolved one.
440 // Letting the API default decide ('auto' for gpt-image-*) keeps costs predictable
441 // and avoids forcing 'high' on every request.
442 if ( !empty( $query->quality ) ) {
443 $body['quality'] = $query->quality;
444 }
445 return $body;
446 }
447 }
448
449 protected function build_url( $query, $endpoint = null ) {
450 $url = '';
451 $env = $this->env;
452 // This endpoint is basically OpenAI or Azure, but in the case this class
453 // is overriden, we can pass the endpoint directly (for OpenRouter or HuggingFace, for example).
454 if ( empty( $endpoint ) ) {
455 if ( $this->envType === 'openai' ) {
456 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
457 $this->organizationId = isset( $env['organizationId'] ) ? $env['organizationId'] : null;
458 }
459 else if ( $this->envType === 'azure' ) {
460 $endpoint = isset( $env['endpoint'] ) ? $env['endpoint'] : null;
461 // Ensure the endpoint has the proper protocol if it's just a domain
462 if ( $endpoint && strpos( $endpoint, 'http' ) !== 0 ) {
463 $endpoint = 'https://' . $endpoint;
464 }
465 }
466 else {
467 if ( empty( $this->envType ) ) {
468 throw new Exception( 'Endpoint is not defined, and this envType is not known.' );
469 }
470 throw new Exception( 'Endpoint is not defined, and this envType is not known: ' . $this->envType );
471 }
472 }
473 // Add the base API to the URL
474 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
475 if ( $this->envType === 'azure' ) {
476 $deployment_name = $this->get_azure_deployment_name( $query->model );
477 $url = trailingslashit( $endpoint ) . 'openai/deployments/' . $deployment_name;
478 $url .= '/chat/completions?' . $this->azureApiVersion;
479 }
480 else {
481 $url .= trailingslashit( $endpoint ) . 'chat/completions';
482 }
483 return $url;
484 }
485 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
486 $modeEndpoint = $query->feature === 'translation' ? 'translations' : 'transcriptions';
487 $url .= trailingslashit( $endpoint ) . 'audio/' . $modeEndpoint;
488 return $url;
489 }
490 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
491 $url .= trailingslashit( $endpoint ) . 'embeddings';
492 if ( $this->envType === 'azure' ) {
493 $deployment_name = $this->get_azure_deployment_name( $query->model );
494 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
495 $deployment_name . '/embeddings?' . $this->azureApiVersion;
496 }
497 return $url;
498 }
499 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
500 $url .= trailingslashit( $endpoint ) . 'images/edits';
501 if ( $this->envType === 'azure' ) {
502 $deployment_name = $this->get_azure_deployment_name( $query->model );
503 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
504 $deployment_name . '/images/edits?' . $this->azureApiVersion;
505 }
506 return $url;
507 }
508 else if ( $query instanceof Meow_MWAI_Query_Image ) {
509 $url .= trailingslashit( $endpoint ) . 'images/generations';
510 if ( $this->envType === 'azure' ) {
511 $deployment_name = $this->get_azure_deployment_name( $query->model );
512 $url = trailingslashit( $endpoint ) . 'openai/deployments/' .
513 $deployment_name . '/images/generations?' . $this->azureApiVersion;
514 }
515 return $url;
516 }
517 throw new Exception( 'The query is not supported by build_url().' );
518 }
519
520 protected function build_headers( $query ) {
521 if ( $query->apiKey ) {
522 $this->apiKey = $query->apiKey;
523 }
524 if ( empty( $this->apiKey ) ) {
525 throw new Exception( 'No API Key provided. Please visit the Settings. (ChatML Engine)' );
526 }
527 $headers = [
528 'Content-Type' => 'application/json',
529 'Authorization' => 'Bearer ' . $this->apiKey,
530 ];
531 if ( $this->organizationId ) {
532 $headers['OpenAI-Organization'] = $this->organizationId;
533 }
534 if ( $this->envType === 'azure' ) {
535 $headers = [ 'Content-Type' => 'application/json', 'api-key' => $this->apiKey ];
536 }
537 return $headers;
538 }
539
540 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
541 $body = null;
542 if ( !empty( $forms ) ) {
543 $boundary = wp_generate_password( 24, false );
544 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
545 $body = $this->build_form_body( $forms, $boundary );
546 }
547 else if ( !empty( $json ) ) {
548 $body = $this->safe_json_encode( $json, 'request body' );
549 }
550 $options = [
551 'headers' => $headers,
552 'method' => $method,
553 'timeout' => MWAI_TIMEOUT,
554 'body' => $body,
555 'sslverify' => MWAI_SSL_VERIFY
556 ];
557 return $options;
558 }
559 // object: "thread.message.delta"
560 protected function stream_data_handler( $json ) {
561 $content = null;
562 $handledCondition = false; // Track if we entered any condition
563
564 // Get additional data from the JSON
565 if ( isset( $json['model'] ) ) {
566 $this->inModel = $json['model'];
567 }
568 if ( isset( $json['id'] ) ) {
569 $this->inId = $json['id'];
570
571 // Send start event if debug mode is enabled and not already sent
572 if ( $this->currentDebugMode && $this->streamCallback && !$this->streamStartEmitted ) {
573 $this->streamStartEmitted = true;
574 $event = Meow_MWAI_Event::status( 'Starting stream...' )
575 ->set_metadata( 'model', $this->inModel )
576 ->set_metadata( 'id', $this->inId );
577 call_user_func( $this->streamCallback, $event );
578 }
579 }
580
581 // Providers send no-op frames (keepalive, ping) purely to hold the SSE connection
582 // open through proxies. They carry no content and there is nothing to handle, so
583 // return before the "unhandled streaming JSON structure" log at the end of this
584 // method reports them as a parsing problem. They are expected traffic, not errors.
585 $eventType = $json['type'] ?? null;
586 if ( in_array( $eventType, [ 'keepalive', 'ping' ], true ) ) {
587 return null;
588 }
589
590 $object = $json['object'] ?? null;
591
592 if ( $object === 'thread.run' ) {
593 $handledCondition = true;
594 $this->inThreadId = $json['thread_id'];
595 if ( $json['status'] === 'failed' ) {
596 $error = $json['last_error']['message'] ?? 'The run failed.';
597 throw new Exception( $error );
598 }
599 }
600 else if ( $object === 'thread.run.step.delta' ) {
601 $handledCondition = true;
602 if ( $json['delta']['step_details']['type'] === 'tool_calls' ) {
603 foreach ( $json['delta']['step_details']['tool_calls'] as $tool_call ) {
604 $index = $tool_call['index'] ?? null;
605 // unset (not "= null") to break the reference from the previous loop
606 // iteration. Assigning null would null out the tool call we just stored
607 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
608 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
609 unset( $currentStreamToolCall );
610 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
611 $currentStreamToolCall = &$this->streamToolCalls[$index];
612 }
613 else {
614 $this->streamToolCalls[] = [
615 'id' => null,
616 'type' => null,
617 'function' => [ 'name' => '', 'arguments' => '' ],
618 'code_interpreter' => [ 'input' => '', 'outputs' => [] ],
619 ];
620 end( $this->streamToolCalls );
621 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
622
623 // Send tool call initiated event
624 if ( $this->currentDebugMode && $this->streamCallback ) {
625 $event = Meow_MWAI_Event::status( 'Initiating tool call...' );
626 call_user_func( $this->streamCallback, $event );
627 }
628 }
629 if ( !empty( $tool_call['id'] ) ) {
630 $currentStreamToolCall['id'] = $tool_call['id'];
631 }
632 if ( !empty( $tool_call['type'] ) ) {
633 $currentStreamToolCall['type'] = $tool_call['type'];
634 }
635 if ( isset( $tool_call['function'] ) ) {
636 $function = $tool_call['function'];
637 if ( isset( $function['name'] ) ) {
638 $currentStreamToolCall['function']['name'] .= $function['name'];
639 }
640 if ( isset( $function['arguments'] ) ) {
641 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
642 }
643 }
644 if ( isset( $tool_call['code_interpreter'] ) ) {
645 $code_interpreter = $tool_call['code_interpreter'];
646 if ( isset( $code_interpreter['input'] ) ) {
647 $currentStreamToolCall['code_interpreter']['input'] .= $code_interpreter['input'];
648 }
649 if ( isset( $code_interpreter['outputs'] ) ) {
650 $currentStreamToolCall['code_interpreter']['outputs'] = $code_interpreter['outputs'];
651 }
652 }
653 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
654 }
655 }
656 }
657 else if ( $object === 'thread.message.delta' ) {
658 $handledCondition = true;
659 $delta = $json['delta']['content'][0] ?? null;
660 if ( $delta ) {
661 switch ( $delta['type'] ?? null ) {
662 case 'text':
663 if ( !empty( $delta['value'] ) && is_string( $delta['value'] ) ) {
664 $content = $delta['value'];
665 }
666 else if ( !empty( $delta['text'] ) && is_string( $delta['text'] ) ) {
667 $content = $delta['text'];
668 }
669 else if ( !empty( $delta['text'] ) && is_array( $delta['text'] ) ) {
670 $text = $delta['text'];
671 if ( !empty( $text['annotations'] ) ) {
672 $this->streamAnnotations = array_merge( $this->streamAnnotations, $text['annotations'] );
673 }
674 if ( isset( $text['value'] ) ) {
675 $content = $text['value'];
676 }
677 }
678 else {
679 error_log( 'AI Engine: Unknown text format: ' . json_encode( $delta ) );
680 }
681 break;
682 case 'image':
683 $content = $delta['url'];
684 break;
685 case 'image_file':
686 $fileId = $delta['image_file']['file_id'];
687 $content = '<!-- IMG #' . $fileId . ' -->';
688 $this->streamImageIds[] = $fileId;
689 break;
690 case 'function_call':
691 // TODO: Re-evaluate after 2027-01-26. This is the deprecated OpenAI
692 // "function_call" streaming format, superseded by "tool_calls". It decodes
693 // each delta and replaces (instead of concatenating like the tool_calls
694 // path), so a provider that streams fragmented args here would lose them.
695 // Confirm no supported provider still uses this format, then remove it.
696 if ( empty( $this->streamFunctionCall ) ) {
697 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
698 }
699 $this->streamFunctionCall['name'] = $delta['function_call']['name'] ?? $this->streamFunctionCall['name'];
700 if ( isset( $delta['function_call']['arguments'] ) ) {
701 $args = json_decode( $delta['function_call']['arguments'], true );
702 $this->streamFunctionCall['arguments'] = $args ?? [];
703 }
704 break;
705 case 'tool_call':
706 $tool_call = $delta['tool_call'];
707 $index = $tool_call['index'] ?? null;
708 // unset (not "= null") to break the reference from the previous loop
709 // iteration. Assigning null would null out the tool call we just stored
710 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
711 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
712 unset( $currentStreamToolCall );
713 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
714 $currentStreamToolCall = &$this->streamToolCalls[$index];
715 }
716 else {
717 $this->streamToolCalls[] = [
718 'id' => null,
719 'type' => null,
720 'function' => [ 'name' => '', 'arguments' => '' ]
721 ];
722 end( $this->streamToolCalls );
723 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
724 }
725 break;
726 }
727 }
728 }
729 else if ( $object === 'thread.run.step' ) {
730 $handledCondition = true;
731 //$type = $json['step'];
732 // Could be tool_calls, means an OpenAI Assistant is doing something.
733 }
734 else {
735 if ( isset( $json['choices'][0]['text'] ) ) {
736 $handledCondition = true;
737 $content = $json['choices'][0]['text'];
738 }
739 else if ( isset( $json['choices'][0]['delta']['content'] ) ) {
740 $handledCondition = true;
741 $content = $json['choices'][0]['delta']['content'];
742 }
743 else if ( isset( $json['choices'][0]['delta']['function_call'] ) ) {
744 // TODO: Re-evaluate after 2027-01-26. Deprecated "function_call" stream path
745 // (same deprecation as the object-based case above); remove once no provider uses it.
746 $handledCondition = true;
747 if ( empty( $this->streamFunctionCall ) ) {
748 $this->streamFunctionCall = [ 'name' => '', 'arguments' => [] ];
749 }
750 $this->streamFunctionCall['name'] = $json['choices'][0]['delta']['function_call']['name'] ?? $this->streamFunctionCall['name'];
751 if ( isset( $json['choices'][0]['delta']['function_call']['arguments'] ) ) {
752 $args = json_decode( $json['choices'][0]['delta']['function_call']['arguments'], true );
753 $this->streamFunctionCall['arguments'] = $args ?? [];
754 }
755 }
756 else if ( isset( $json['choices'][0]['delta']['tool_calls'] ) ) {
757 $handledCondition = true;
758 // New schema detected – drop any half-built legacy call to prevent duplicates
759 $this->streamFunctionCall = null;
760
761 foreach ( $json['choices'][0]['delta']['tool_calls'] as $tool_call ) {
762 $index = $tool_call['index'] ?? null;
763 // unset (not "= null") to break the reference from the previous loop
764 // iteration. Assigning null would null out the tool call we just stored
765 // in $this->streamToolCalls, leaving a null hole for parallel tool calls
766 // (Mistral rejects that with a 422; OpenAI silently tolerates it).
767 unset( $currentStreamToolCall );
768 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
769 $currentStreamToolCall = &$this->streamToolCalls[$index];
770 }
771 else {
772 $this->streamToolCalls[] = [
773 'id' => null,
774 'type' => null,
775 'function' => [ 'name' => '', 'arguments' => '' ]
776 ];
777 end( $this->streamToolCalls );
778 $currentStreamToolCall = &$this->streamToolCalls[ key( $this->streamToolCalls ) ];
779 }
780 if ( !empty( $tool_call['id'] ) ) {
781 $currentStreamToolCall['id'] = $tool_call['id'];
782 }
783 if ( !empty( $tool_call['type'] ) ) {
784 $currentStreamToolCall['type'] = $tool_call['type'];
785 }
786 if ( isset( $tool_call['function'] ) ) {
787 $function = $tool_call['function'];
788 if ( isset( $function['name'] ) ) {
789 $currentStreamToolCall['function']['name'] .= $function['name'];
790 }
791 if ( isset( $function['arguments'] ) ) {
792 $currentStreamToolCall['function']['arguments'] .= $function['arguments'];
793 }
794 }
795 $this->streamLastMessage['tool_calls'] = $this->streamToolCalls;
796 }
797 }
798 else if ( isset( $json['choices'][0]['delta']['role'] ) ) {
799 $handledCondition = true;
800 $this->streamLastMessage = [
801 'role' => $json['choices'][0]['delta']['role'],
802 'content' => null
803 ];
804 }
805
806 // Handle thinking/reasoning content (from Ollama and potentially other models)
807 // This can appear alongside or instead of regular content
808 if ( isset( $json['choices'][0]['delta']['reasoning'] ) ) {
809 $handledCondition = true;
810 $thinking = $json['choices'][0]['delta']['reasoning'];
811 if ( !empty( $thinking ) ) {
812 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
813 }
814 }
815 // Also check for 'thinking' field (OpenAI's o1 models use this)
816 else if ( isset( $json['choices'][0]['delta']['thinking'] ) ) {
817 $handledCondition = true;
818 $thinking = $json['choices'][0]['delta']['thinking'];
819 if ( !empty( $thinking ) ) {
820 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thinking;
821 }
822 }
823 }
824
825 $usage = $json['usage'] ?? [];
826 if ( isset( $usage['prompt_tokens'], $usage['completion_tokens'] ) ) {
827 $this->streamInTokens = (int) $usage['prompt_tokens'];
828 $this->streamOutTokens = (int) $usage['completion_tokens'];
829
830 if ( isset( $usage['cost'] ) ) {
831 $this->streamCost = (float) $usage['cost'];
832 }
833 }
834
835 // If content is an array, let's try to convert it into a string. Normally, there would be a 'value' key.
836 if ( is_array( $content ) ) {
837 if ( isset( $content['value'] ) ) {
838 $content = $content['value'];
839 }
840 else {
841 throw new Exception( 'Could not read this: ' . json_encode( $content ) );
842 }
843 }
844
845 // Log unhandled JSON in dev mode
846 if ( !$handledCondition && $this->core->get_option( 'dev_mode' ) ) {
847 error_log( '[AI Engine] Unhandled streaming JSON structure: ' . json_encode( $json ) );
848 }
849
850 // Avoid some endings
851 $endings = [ '', '</s>' ];
852 if ( in_array( $content, $endings ) ) {
853 $content = null;
854 }
855
856 if ( $content !== '0' && empty( $content ) ) {
857 return null;
858 }
859 if ( is_string( $content ) ) {
860 $content = $this->filter_inline_thinking( $content );
861 if ( $content === '' ) {
862 return null;
863 }
864 }
865 return $content;
866 }
867
868 /**
869 * Qwen-style open models emit their reasoning INLINE in the content as
870 * <think>...</think>. Route it to streamThinking (with a single thinking
871 * event per block) so the visible reply only contains the actual answer.
872 */
873 protected function filter_inline_thinking( $content ) {
874 if ( $this->streamThinkTag === 0 &&
875 strpos( $content, '<think>' ) === false && strpos( $content, '</think>' ) === false ) {
876 return $content;
877 }
878 $out = '';
879 $thought = '';
880 $remaining = $content;
881 while ( $remaining !== '' ) {
882 if ( $this->streamThinkTag === 0 ) {
883 $pos = strpos( $remaining, '<think>' );
884 if ( $pos === false ) {
885 $out .= $remaining;
886 break;
887 }
888 $out .= substr( $remaining, 0, $pos );
889 $remaining = substr( $remaining, $pos + 7 );
890 $this->streamThinkTag = 1;
891 if ( $this->currentDebugMode && !empty( $this->streamCallback ) ) {
892 call_user_func( $this->streamCallback, Meow_MWAI_Event::thinking( 'Thinking...' ) );
893 }
894 }
895 else {
896 $pos = strpos( $remaining, '</think>' );
897 if ( $pos === false ) {
898 $thought .= $remaining;
899 break;
900 }
901 $thought .= substr( $remaining, 0, $pos );
902 // The answer usually follows the block after blank lines.
903 $remaining = ltrim( substr( $remaining, $pos + 8 ), "\n" );
904 $this->streamThinkTag = 0;
905 }
906 }
907 if ( $thought !== '' ) {
908 $this->streamThinking = ( $this->streamThinking ?? '' ) . $thought;
909 }
910 return $out;
911 }
912
913 /**
914 * Same cleanup for non-streaming replies: closed blocks are removed, and a
915 * trailing unclosed block (the model spent its whole budget thinking) too.
916 */
917 public static function strip_inline_thinking( $text ) {
918 if ( !is_string( $text ) || strpos( $text, '<think>' ) === false ) {
919 return $text;
920 }
921 $text = preg_replace( '/<think>.*?<\/think>/s', '', $text );
922 $text = preg_replace( '/<think>.*$/s', '', $text );
923 return ltrim( $text );
924 }
925
926 public function run( $query, $streamCallback = null, $maxDepth = 5 ) {
927 // Check if this is a realtime model being used with chat completions
928 if ( $this->is_realtime_model( $query->model ) ) {
929 throw new Exception(
930 'Realtime models (like ' . $query->model . ') are designed for voice/audio interactions and cannot be used with this API.'
931 );
932 }
933
934 return parent::run( $query, $streamCallback, $maxDepth );
935 }
936
937 public function run_query( $url, $options, $isStream = false ) {
938 try {
939 $options['stream'] = $isStream;
940 if ( $isStream ) {
941 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
942 }
943
944 // Check if queries debug is enabled
945 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
946
947 // Log the request if queries debug is enabled
948 if ( $queries_debug ) {
949 error_log( '[AI Engine Queries] --> Request to: ' . $url );
950
951 if ( isset( $options['body'] ) ) {
952 // This is the actual body being sent to the AI service
953 $body_log = is_string( $options['body'] ) ? $options['body'] : json_encode( $options['body'] );
954
955 // Pretty print JSON if possible
956 $decoded = json_decode( $body_log, true );
957 if ( json_last_error() === JSON_ERROR_NONE ) {
958 error_log( json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
959 }
960 else {
961 error_log( $body_log );
962 }
963 }
964 }
965
966 $res = wp_remote_get( $url, $options );
967
968 if ( is_wp_error( $res ) ) {
969 throw new Exception( $res->get_error_message() );
970 }
971
972 $responseCode = wp_remote_retrieve_response_code( $res );
973
974 // For streaming requests the response body is written to a temp file, so
975 // wp_remote_retrieve_body() comes back empty. Read it back here so provider
976 // error details (e.g. a 422 from Mistral) aren't lost on streaming requests.
977 $errorBody = wp_remote_retrieve_body( $res );
978 if ( empty( $errorBody ) && $isStream && !empty( $options['filename'] ) && file_exists( $options['filename'] ) ) {
979 $errorBody = file_get_contents( $options['filename'] );
980 }
981
982 if ( $responseCode === 404 ) {
983 // A 404 here is almost always an invalid model name for this provider
984 // (the endpoint URL is correct), so surface the provider's own message.
985 $detail = !empty( $errorBody ) ? ' Response: ' . $errorBody : '';
986 throw new Exception( 'The API returned 404 for ' . $url . '. This usually means the model is not valid for this provider.' . $detail );
987 }
988 else if ( $responseCode === 400 ) {
989 error_log( '[AI Engine] 400 Bad Request - Full response body: ' . $errorBody );
990 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
991 if ( empty( $message ) ) {
992 $message = 'Bad Request';
993 }
994 throw new Exception( $message );
995 }
996 else if ( $responseCode === 422 ) {
997 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
998 if ( empty( $message ) ) {
999 $message = 'Unprocessable Entity';
1000 }
1001 throw new Exception( $message );
1002 }
1003 else if ( $responseCode === 500 ) {
1004 $message = !empty( $errorBody ) ? $errorBody : wp_remote_retrieve_response_message( $res );
1005 if ( empty( $message ) ) {
1006 $message = 'Internal Server Error';
1007 }
1008 throw new Exception( $message );
1009 }
1010
1011 if ( $isStream ) {
1012 return [ 'stream' => true ];
1013 }
1014
1015 $response = wp_remote_retrieve_body( $res );
1016 $headersRes = wp_remote_retrieve_headers( $res );
1017 $headers = $headersRes->getAll();
1018
1019 // Check if Content-Type is 'multipart/form-data' or 'text/plain'
1020 // If so, we don't need to decode the response
1021 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
1022 $resContentType = $normalizedHeaders['content-type'] ?? '';
1023 if ( strpos( $resContentType, 'multipart/form-data' ) !== false || strpos( $resContentType, 'text/plain' ) !== false ) {
1024 // Log the response if queries debug is enabled
1025 if ( $queries_debug && !$isStream ) {
1026 error_log( '[AI Engine Queries] Response Headers: ' . json_encode( $headers ) );
1027 error_log( '[AI Engine Queries] Response Body (raw): ' . substr( $response, 0, 1000 ) . '...' );
1028 }
1029 return [ 'stream' => false, 'headers' => $headers, 'data' => $response ];
1030 }
1031
1032 $data = json_decode( $response, true );
1033 $this->handle_response_errors( $data );
1034
1035 // Log the response if queries debug is enabled
1036 if ( $queries_debug && !$isStream ) {
1037 // Log the raw response as received from the AI service
1038 error_log( '[AI Engine Queries] <-- Response:' );
1039
1040 // Pretty print JSON if possible
1041 if ( json_last_error() === JSON_ERROR_NONE && is_array( $data ) ) {
1042 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1043 }
1044 else {
1045 error_log( $response );
1046 }
1047 }
1048
1049 return [ 'headers' => $headers, 'data' => $data ];
1050 }
1051 catch ( Exception $e ) {
1052 $service = $this->get_service_name();
1053 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1054
1055 // Log error response if queries debug is enabled
1056 if ( $queries_debug ) {
1057 error_log( '[AI Engine Queries] Error occurred: ' . $e->getMessage() );
1058 }
1059
1060 throw $e;
1061 }
1062 finally {
1063 if ( $isStream && file_exists( $options['filename'] ) ) {
1064 unlink( $options['filename'] );
1065 }
1066 }
1067 }
1068
1069 private function get_audio( $url ) {
1070 require_once( ABSPATH . 'wp-admin/includes/media.php' );
1071
1072 // Validate URL scheme to prevent SSRF attacks
1073 $parts = wp_parse_url( $url );
1074 if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
1075 throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
1076 }
1077
1078 // Use wp_safe_remote_get to block localhost/private IPs (SSRF protection)
1079 $response = wp_safe_remote_get( $url, [
1080 'timeout' => 60,
1081 'redirection' => 0 // Prevent redirect-based bypass
1082 ] );
1083 if ( is_wp_error( $response ) ) {
1084 throw new Exception( 'Failed to download audio: ' . $response->get_error_message() );
1085 }
1086 $audio_data = wp_remote_retrieve_body( $response );
1087 if ( empty( $audio_data ) ) {
1088 throw new Exception( 'Failed to download audio: empty response.' );
1089 }
1090
1091 $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
1092 file_put_contents( $tmpFile, $audio_data );
1093 $length = null;
1094 $metadata = wp_read_audio_metadata( $tmpFile );
1095 if ( isset( $metadata['length'] ) ) {
1096 $length = $metadata['length'];
1097 }
1098 $data = file_get_contents( $tmpFile );
1099 unlink( $tmpFile );
1100 return [ 'data' => $data, 'length' => $length ];
1101 }
1102
1103 public function run_transcribe_query( $query ) {
1104 $audioData = null;
1105
1106 // Priority 1: Direct audio data
1107 if ( !empty( $query->audioData ) ) {
1108 $audioData = [
1109 'data' => $query->audioData,
1110 'length' => strlen( $query->audioData ) / 1024 // KB
1111 ];
1112 }
1113 // Priority 2: File path
1114 else if ( !empty( $query->path ) ) {
1115 // Last line of defense: REST callers never get to set this (the routes strip the
1116 // key), but simpleTranscribeAudio() is public PHP API, so a path can legitimately
1117 // arrive from site code. Confinement is not applied here on purpose: real callers
1118 // do transcribe files from outside the uploads directory. Stream wrappers are the
1119 // part that is never legitimate.
1120 $path = Meow_MWAI_Core::sanitize_file_path( $query->path );
1121 if ( !file_exists( $path ) ) {
1122 throw new Exception( 'Audio file not found: ' . $path );
1123 }
1124 if ( !is_readable( $path ) ) {
1125 throw new Exception( 'Audio file is not readable: ' . $path );
1126 }
1127 $audioData = [
1128 'data' => file_get_contents( $path ),
1129 'length' => filesize( $path ) / 1024 // KB
1130 ];
1131 }
1132 // Priority 3: Attached file object
1133 else if ( method_exists( $query, 'getAttachments' ) ) {
1134 $attachments = $query->getAttachments();
1135 if ( !empty( $attachments ) ) {
1136 $file = $attachments[0];
1137 $audioData = [
1138 'data' => $file->get_data(),
1139 'length' => strlen( $file->get_data() ) / 1024 // KB
1140 ];
1141 }
1142 }
1143 // Priority 4: URL (backward compatibility)
1144 else if ( !empty( $query->url ) ) {
1145 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1146 throw new Exception( 'Invalid URL for transcription.' );
1147 }
1148 $audioData = $this->get_audio( $query->url );
1149 }
1150 else {
1151 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1152 }
1153
1154 $body = $this->build_body( $query, null, $audioData['data'] );
1155 $url = $this->build_url( $query );
1156 $headers = $this->build_headers( $query );
1157 $options = $this->build_options( $headers, null, $body );
1158
1159 // Perform the request
1160 try {
1161 $res = $this->run_query( $url, $options );
1162 $data = $res['data'];
1163 if ( empty( $data ) ) {
1164 throw new Exception( 'Invalid data for transcription.' );
1165 }
1166 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1167 $reply = new Meow_MWAI_Reply( $query );
1168 $reply->set_usage( $usage );
1169 $reply->set_choices( $data );
1170 return $reply;
1171 }
1172 catch ( Exception $e ) {
1173 $service = $this->get_service_name();
1174 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1175 throw new Exception( "$service: " . $e->getMessage() );
1176 }
1177 }
1178
1179 public function run_embedding_query( $query ) {
1180 // Pre-validate token count against the embedding model's limit.
1181 // OpenAI embedding models (text-embedding-3-small/large, ada-002) all cap
1182 // at 8192 input tokens; sending a longer chunk yields an opaque
1183 // "Invalid 'input': maximum context length is 8192 tokens" error. Count
1184 // locally via tiktoken (bundled) so we fail with a clear message up front.
1185 $embedding_max_tokens = apply_filters( 'mwai_embedding_max_tokens', 8192, $query->model );
1186 if ( !empty( $query->message ) && $embedding_max_tokens > 0 ) {
1187 $token_count = Meow_MWAI_Core::estimate_tokens( $query->message, $query->model );
1188 if ( $token_count > $embedding_max_tokens ) {
1189 throw new Exception( sprintf(
1190 'Embedding input is %d tokens, exceeds the %d-token limit for %s. Split this entry into smaller chunks and try again.',
1191 $token_count,
1192 $embedding_max_tokens,
1193 $query->model
1194 ) );
1195 }
1196 }
1197
1198 $body = $this->build_body( $query );
1199 $url = $this->build_url( $query );
1200 $headers = $this->build_headers( $query );
1201 $options = $this->build_options( $headers, $body );
1202
1203 try {
1204 $res = $this->run_query( $url, $options );
1205 $data = $res['data'];
1206 if ( empty( $data ) || !isset( $data['data'] ) ) {
1207 throw new Exception( 'Invalid data for embedding.' );
1208 }
1209 $usage = $data['usage'];
1210 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1211 $reply = new Meow_MWAI_Reply( $query );
1212 $reply->set_usage( $usage );
1213 $reply->set_choices( $data['data'] );
1214 return $reply;
1215 }
1216 catch ( Exception $e ) {
1217 $message = $e->getMessage();
1218 $error = $this->try_decode_error( $message );
1219 if ( !is_null( $error ) ) {
1220 $message = $error;
1221 }
1222 $service = $this->get_service_name();
1223 Meow_MWAI_Logging::error( "$service: " . $message );
1224 throw new Exception( "$service: " . $message );
1225 }
1226 }
1227
1228 public function try_decode_error( $data ) {
1229 $json = json_decode( $data, true );
1230 if ( isset( $json['error']['message'] ) ) {
1231 return $json['error']['message'];
1232 }
1233 return null;
1234 }
1235
1236 protected function finalize_choices( $choices, $responseData, $query ) {
1237 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1238 foreach ( $choices as &$choice ) {
1239 if ( isset( $choice['message'] ) ) {
1240 // If we have both tool_calls and function_call, remove function_call
1241 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1242 isset( $choice['message']['function_call'] ) ) {
1243 unset( $choice['message']['function_call'] );
1244 }
1245 }
1246 }
1247 return $choices;
1248 }
1249
1250 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1251 // Check if this is a GPT-5 model - they don't support Chat Completions API
1252 if ( $this->is_gpt5_model( $query->model ) ) {
1253 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1254 }
1255
1256 $isStreaming = !is_null( $streamCallback );
1257
1258 // Initialize debug mode
1259 $this->init_debug_mode( $query );
1260
1261 // Store the callback for event emission (both streaming and non-streaming debug mode)
1262 if ( !is_null( $streamCallback ) ) {
1263 $this->streamCallback = $streamCallback;
1264 }
1265
1266 if ( $isStreaming ) {
1267 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1268 }
1269
1270 $this->reset_stream();
1271 $body = $this->build_body( $query, $streamCallback );
1272 $url = $this->build_url( $query );
1273 $headers = $this->build_headers( $query );
1274 $options = $this->build_options( $headers, $body );
1275
1276 // Emit "Request sent" event for feedback queries
1277 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1278 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1279 $event = Meow_MWAI_Event::request_sent()
1280 ->set_metadata( 'is_feedback', true )
1281 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1282 call_user_func( $streamCallback, $event );
1283 }
1284
1285 try {
1286 $res = $this->run_query( $url, $options, $streamCallback );
1287 $reply = new Meow_MWAI_Reply( $query );
1288
1289 $returned_id = null;
1290 $returned_model = $this->inModel;
1291 $returned_in_tokens = null;
1292 $returned_out_tokens = null;
1293 $returned_price = null;
1294 $returned_choices = [];
1295
1296 // Streaming Mode
1297 if ( $isStreaming ) {
1298 if ( empty( $this->streamContent ) ) {
1299 $error = $this->try_decode_error( $this->streamBuffer );
1300 if ( !is_null( $error ) ) {
1301 throw new Exception( $error );
1302 }
1303 // A stream that ends without a single chunk (no id, no content, no
1304 // tool call, no thinking) is a provider failure, not a reply. OVH
1305 // answers 200 with a lone [DONE] when it rejects a parameter, which
1306 // used to be saved as a silent empty message.
1307 if ( empty( $this->streamToolCalls ) && empty( $this->streamFunctionCall ) &&
1308 empty( $this->streamThinking ) && is_null( $this->inId ) ) {
1309 throw new Exception( 'The AI service closed the stream without sending a reply. A parameter (often Max Tokens) may exceed what this model supports.' );
1310 }
1311 }
1312 $returned_id = $this->inId;
1313 $returned_model = $this->inModel ? $this->inModel : $query->model;
1314
1315 // Use regular content if available, otherwise fall back to thinking/reasoning
1316 $finalContent = $this->streamContent;
1317 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1318 // Use thinking content as fallback when there's no regular content
1319 // This happens with Ollama when it returns only reasoning/thinking
1320 // Wrap in asterisks to show as italics in markdown
1321 $finalContent = '*' . $this->streamThinking . '*';
1322
1323 // Log this for debugging
1324 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1325 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1326 }
1327 }
1328
1329 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1330 // Prefer tool_calls; fall back to legacy only if necessary
1331 if ( !empty( $this->streamToolCalls ) ) {
1332 $message['tool_calls'] = $this->streamToolCalls;
1333 }
1334 elseif ( !empty( $this->streamFunctionCall ) ) {
1335 $message['function_call'] = $this->streamFunctionCall;
1336 }
1337
1338 // Optionally include thinking as metadata if both content and thinking exist
1339 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1340 $message['thinking'] = $this->streamThinking;
1341 }
1342 if ( !is_null( $this->streamInTokens ) ) {
1343 $returned_in_tokens = $this->streamInTokens;
1344 }
1345 if ( !is_null( $this->streamOutTokens ) ) {
1346 $returned_out_tokens = $this->streamOutTokens;
1347 }
1348 if ( !is_null( $this->streamCost ) ) {
1349 $returned_price = $this->streamCost;
1350 }
1351 $returned_choices = [ [ 'message' => $message ] ];
1352 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1353
1354 // Log streaming response data if queries debug is enabled
1355 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1356 if ( $queries_debug ) {
1357 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1358 $streaming_data = [
1359 'id' => $returned_id,
1360 'model' => $returned_model,
1361 'content_length' => strlen( $this->streamContent ),
1362 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1363 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1364 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1365 'usage' => [
1366 'input_tokens' => $returned_in_tokens,
1367 'output_tokens' => $returned_out_tokens,
1368 'cost' => $returned_price
1369 ]
1370 ];
1371
1372 // Log tool calls details if present
1373 if ( !empty( $this->streamToolCalls ) ) {
1374 $streaming_data['tool_calls_details'] = [];
1375 foreach ( $this->streamToolCalls as $tool_call ) {
1376 $streaming_data['tool_calls_details'][] = [
1377 'id' => $tool_call['id'] ?? 'unknown',
1378 'name' => $tool_call['function']['name'] ?? 'unknown',
1379 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1380 ];
1381 }
1382 }
1383
1384 // Log function call if present
1385 if ( !empty( $this->streamFunctionCall ) ) {
1386 $streaming_data['function_call'] = [
1387 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1388 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1389 ];
1390 }
1391
1392 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1393 }
1394 }
1395 // Standard Mode
1396 else {
1397 $data = $res['data'];
1398 if ( empty( $data ) ) {
1399 throw new Exception( 'No content received (res is null).' );
1400 }
1401
1402 // Comprehensive logging for non-streaming mode - capture FULL response
1403 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1404 if ( $queries_debug ) {
1405 error_log( '[AI Engine Queries] ========================================' );
1406 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1407 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1408 error_log( '[AI Engine Queries] ========================================' );
1409
1410 // Look specifically for container_id
1411 $this->search_for_container_id_recursive( $data, '' );
1412 }
1413
1414 if ( !$data['model'] ) {
1415 $service = $this->get_service_name();
1416 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1417 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1418 throw new Exception( 'Invalid response (no model information).' );
1419 }
1420 $returned_id = $data['id'];
1421 $returned_model = $data['model'];
1422 $usage = $data['usage'] ?? [];
1423 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1424 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1425 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1426 $returned_choices = $data['choices'];
1427 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1428
1429 // Strip inline <think> reasoning (Qwen-style models) from the reply.
1430 foreach ( $returned_choices as &$choice ) {
1431 if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) ) {
1432 $choice['message']['content'] = self::strip_inline_thinking( $choice['message']['content'] );
1433 }
1434 }
1435 unset( $choice );
1436 }
1437
1438 // Set the results.
1439 $reply->set_choices( $returned_choices );
1440 if ( !empty( $returned_id ) ) {
1441 $reply->set_id( $returned_id );
1442 }
1443 if ( !empty( $returned_id ) ) {
1444 $reply->set_id( $returned_id );
1445 }
1446
1447 // Handle tokens.
1448 $this->handle_tokens_usage(
1449 $reply,
1450 $query,
1451 $returned_model,
1452 $returned_in_tokens,
1453 $returned_out_tokens,
1454 $returned_price
1455 );
1456
1457 return $reply;
1458 }
1459 catch ( Exception $e ) {
1460 $service = $this->get_service_name();
1461 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1462 $message = "$service: " . $e->getMessage();
1463 throw new Exception( $message );
1464 }
1465 finally {
1466 if ( !is_null( $streamCallback ) ) {
1467 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1468 }
1469 }
1470 }
1471
1472 public function handle_tokens_usage(
1473 $reply,
1474 $query,
1475 $returned_model,
1476 $returned_in_tokens,
1477 $returned_out_tokens,
1478 $returned_price = null
1479 ) {
1480 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1481 $reply->get_in_tokens( $query );
1482 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1483 $reply->get_out_tokens();
1484 $returned_price = !is_null( $returned_price ) ? $returned_price :
1485 $reply->get_price();
1486 $usage = $this->core->record_tokens_usage(
1487 $returned_model,
1488 $returned_in_tokens,
1489 $returned_out_tokens,
1490 $returned_price
1491 );
1492 $reply->set_usage( $usage );
1493
1494 // Store the actual model returned by the API
1495 $reply->set_model( $returned_model );
1496
1497 // Set default accuracy to 'estimated' for engines that don't override
1498 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1499 $reply->set_usage_accuracy( 'estimated' );
1500 }
1501
1502 // Request to the OpenAI Images API (gpt-image models, token-based pricing)
1503 public function run_image_query( $query, $streamCallback = null ) {
1504 $body = $this->build_body( $query );
1505 $url = $this->build_url( $query );
1506 $headers = $this->build_headers( $query );
1507 $options = $this->build_options( $headers, $body );
1508
1509 try {
1510 $res = $this->run_query( $url, $options );
1511 $data = $res['data'];
1512 $choices = $data['data'];
1513 $reply = new Meow_MWAI_Reply( $query );
1514 $model = $query->model;
1515
1516 // gpt-image models always return usage with input/output tokens
1517 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1518 $usage = $data['usage'] ?? [];
1519 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1520 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1521 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1522 $usage['queries'] = 1;
1523 $usage['accuracy'] = 'tokens';
1524 $reply->set_usage( $usage );
1525 $reply->set_usage_accuracy( 'tokens' );
1526
1527 $reply->set_choices( $choices );
1528 $reply->set_type( 'images' );
1529
1530 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1531 foreach ( $reply->results as &$result ) {
1532 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1533 'query_envId' => $query->envId,
1534 'query_session' => $query->session,
1535 'query_model' => $query->model,
1536 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1537 $fileUrl = $this->core->files->get_url( $fileId );
1538 $result = $fileUrl;
1539 }
1540 }
1541 $reply->result = $reply->results[0];
1542 return $reply;
1543 }
1544 catch ( Exception $e ) {
1545 $service = $this->get_service_name();
1546 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1547 throw new Exception( "$service: " . $e->getMessage() );
1548 }
1549 }
1550
1551 public function run_editimage_query( $query ) {
1552 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1553 if ( empty( $attachments ) ) {
1554 throw new Exception( 'No image provided for editing.' );
1555 }
1556 // Ensure the model supports image editing
1557 $modelInfo = $this->retrieve_model_info( $query->model );
1558 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1559 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1560 }
1561 $file = $attachments[0];
1562 $imageData = $file->get_data();
1563 $body = $this->build_body( $query, null, $imageData );
1564 $url = $this->build_url( $query );
1565 $headers = $this->build_headers( $query );
1566 $options = $this->build_options( $headers, null, $body );
1567
1568 try {
1569 $res = $this->run_query( $url, $options );
1570 $data = $res['data'];
1571 $choices = $data['data'];
1572 $reply = new Meow_MWAI_Reply( $query );
1573 $model = $query->model;
1574
1575 // gpt-image models always return usage with input/output tokens
1576 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1577 $usage = $data['usage'] ?? [];
1578 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1579 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1580 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1581 $usage['queries'] = 1;
1582 $usage['accuracy'] = 'tokens';
1583 $reply->set_usage( $usage );
1584 $reply->set_usage_accuracy( 'tokens' );
1585
1586 $reply->set_choices( $choices );
1587 $reply->set_type( 'images' );
1588
1589 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1590 foreach ( $reply->results as &$result ) {
1591 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1592 'query_envId' => $query->envId,
1593 'query_session' => $query->session,
1594 'query_model' => $query->model,
1595 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1596 $fileUrl = $this->core->files->get_url( $fileId );
1597 $result = $fileUrl;
1598 }
1599 }
1600 $reply->result = $reply->results[0];
1601 return $reply;
1602 }
1603 catch ( Exception $e ) {
1604 $service = $this->get_service_name();
1605 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1606 throw new Exception( "$service: " . $e->getMessage() );
1607 }
1608 }
1609
1610 /*
1611 This is the rest of the OpenAI API support, not related to the models directly.
1612 */
1613
1614 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1615 // OpenAI uses { error: { message: ... } }; some compatible providers (xAI, etc.) use a
1616 // plain string in the error field — handle both shapes.
1617 protected function handle_response_errors( $data ) {
1618 if ( !isset( $data['error'] ) || empty( $data['error'] ) ) {
1619 return;
1620 }
1621 $err = $data['error'];
1622 if ( is_array( $err ) ) {
1623 $message = $err['message'] ?? json_encode( $err );
1624 }
1625 else {
1626 $message = (string) $err;
1627 }
1628 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1629 $message = str_replace( $matches[1], '', $message );
1630 }
1631 throw new Exception( $message );
1632 }
1633
1634 public function list_files( $purposeFilter = null ) {
1635 if ( empty( $purposeFilter ) ) {
1636 return $this->execute( 'GET', '/files' );
1637 }
1638 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1639 }
1640
1641 public static function get_suffix_for_model( $model ) {
1642 // Legacy fine-tuned models
1643 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1644 if ( count( $matches ) > 0 ) {
1645 return $matches[1];
1646 }
1647
1648 // New fine-tuned models
1649 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1650 if ( count( $matches ) > 0 ) {
1651 return $matches[1];
1652 }
1653
1654 return 'N/A';
1655 }
1656
1657 public static function get_model_without_release_date( $model ) {
1658 if ( empty( $model ) ) {
1659 return null;
1660 }
1661 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1662 }
1663
1664 // TODO: Remove every list_finetunes / cancel_finetune / delete_finetune / run_finetune method below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06). The `finetunes` env keys in init.php and the `finetune` key on every entry in constants/models.php go with them.
1665 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1666 $finetunes = $this->list_finetunes( $legacy );
1667 $deleted = [];
1668
1669 foreach ( $finetunes as $finetune ) {
1670 $name = $finetune['model'];
1671 $isSucceeded = $finetune['status'] === 'succeeded';
1672 if ( $isSucceeded ) {
1673 try {
1674 $finetune = $this->get_model( $name );
1675 }
1676 catch ( Exception $e ) {
1677 $deleted[] = $name;
1678 }
1679 }
1680 }
1681 if ( $legacy ) {
1682 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1683 }
1684 else {
1685 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1686 }
1687 return $deleted;
1688 }
1689
1690 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1691 // retrieve all the models since Summer 2023, let's see! WIP.
1692 public function list_finetunes( $legacy = false ) {
1693 if ( $legacy ) {
1694 $res = $this->execute( 'GET', '/fine-tunes' );
1695 }
1696 else {
1697 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1698 }
1699 $finetunes = $res['data'];
1700
1701 // Add suffix
1702 $finetunes = array_map( function ( $finetune ) {
1703 if ( isset( $finetune['user_provided_suffix'] ) ) {
1704 $finetune['suffix'] = $finetune['user_provided_suffix'];
1705 }
1706 else {
1707 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1708 }
1709 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1710 if ( isset( $finetune['estimated_finish'] ) ) {
1711 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1712 }
1713 else {
1714 $finetune['estimatedOn'] = null;
1715 }
1716 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1717 $finetune['base_model'] = $finetune['model'];
1718 $finetune['model'] = $finetune['fine_tuned_model'];
1719 unset( $finetune['object'] );
1720 unset( $finetune['hyperparams'] );
1721 unset( $finetune['result_files'] );
1722 unset( $finetune['training_files'] );
1723 unset( $finetune['validation_files'] );
1724 unset( $finetune['created_at'] );
1725 unset( $finetune['updated_at'] );
1726 unset( $finetune['fine_tuned_model'] );
1727 return $finetune;
1728 }, $finetunes );
1729
1730 usort( $finetunes, function ( $a, $b ) {
1731 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1732 } );
1733
1734 if ( $legacy ) {
1735 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1736 }
1737 else {
1738 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1739 }
1740
1741 return $finetunes;
1742 }
1743
1744 public function moderate( $input ) {
1745 $result = $this->execute( 'POST', '/moderations', [
1746 'input' => $input
1747 ] );
1748 return $result;
1749 }
1750
1751 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1752 global $wp_filter;
1753
1754 // Build multipart form data
1755 $boundary = wp_generate_password( 24, false );
1756 $body = '';
1757 $body .= "--$boundary\r\n";
1758 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1759 $body .= $purpose . "\r\n";
1760 $body .= "--$boundary\r\n";
1761 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1762 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1763 $body .= $data . "\r\n";
1764 $body .= "--$boundary--\r\n";
1765
1766 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1767 // Save current hooks
1768 $saved_hooks = null;
1769 if ( isset( $wp_filter['http_api_curl'] ) ) {
1770 $saved_hooks = $wp_filter['http_api_curl'];
1771 unset( $wp_filter['http_api_curl'] );
1772 }
1773
1774 // Upload using WordPress HTTP API
1775 $url = 'https://api.openai.com/v1/files';
1776 $response = wp_remote_post( $url, [
1777 'headers' => [
1778 'Authorization' => 'Bearer ' . $this->apiKey,
1779 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1780 ],
1781 'body' => $body,
1782 'timeout' => 60
1783 ] );
1784
1785 // Restore hooks
1786 if ( $saved_hooks !== null ) {
1787 $wp_filter['http_api_curl'] = $saved_hooks;
1788 }
1789
1790 if ( is_wp_error( $response ) ) {
1791 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1792 }
1793
1794 $response_body = wp_remote_retrieve_body( $response );
1795 return json_decode( $response_body, true );
1796 }
1797
1798 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1799 $body = [
1800 'name' => !empty( $name ) ? $name : 'default',
1801 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1802 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1803 ];
1804 if ( $expiry !== 'never' ) {
1805 if ( is_string( $expiry ) ) {
1806 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1807 $expiry = 7;
1808 }
1809 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1810 if ( $expiry && is_numeric( $expiry ) ) {
1811 $body['expires_after'] = [
1812 'anchor' => 'last_active_at',
1813 'days' => $expiryInDays
1814 ];
1815 }
1816 }
1817 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1818 return $result['id'];
1819 }
1820
1821 public function get_vector_store( $vectorStoreId ) {
1822 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1823 }
1824
1825 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1826 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1827 'file_id' => $fileId
1828 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1829 return $result['id'];
1830
1831 }
1832
1833 public function delete_file( $fileId ) {
1834 return $this->execute( 'DELETE', '/files/' . $fileId );
1835 }
1836
1837 public function get_model( $modelId ) {
1838 return $this->execute( 'GET', '/models/' . $modelId );
1839 }
1840
1841 public function cancel_finetune( $fineTuneId ) {
1842 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1843 }
1844
1845 public function delete_finetune( $modelId ) {
1846 return $this->execute( 'DELETE', '/models/' . $modelId );
1847 }
1848
1849 public function download_file( $fileId, $newFile = null ) {
1850 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1851 $fileInfo = json_decode( (string) $fileInfo, true );
1852 if ( empty( $fileInfo ) ) {
1853 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1854 }
1855 $filename = $fileInfo['filename'];
1856 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1857 if ( empty( $newFile ) ) {
1858 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1859 $tempFile = wp_tempnam( $filename );
1860 if ( !$tempFile ) {
1861 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1862 }
1863 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1864 $newFile = $tempFile . '.' . $extension;
1865 }
1866 else {
1867 $newFile = $tempFile;
1868 }
1869 }
1870 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1871 file_put_contents( $newFile, $data );
1872 return $newFile;
1873 }
1874
1875 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1876 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1877 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1878 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1879 (float) $hyperparams['learningRateMultiplier'] : null;
1880 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1881 (float) $hyperparams['promptLossWeight'] : null;
1882 $arguments = [
1883 'training_file' => $fileId,
1884 'model' => $model,
1885 'suffix' => $suffix
1886 ];
1887 if ( $legacy ) {
1888 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1889 }
1890 else {
1891 if ( $n_epochs ) {
1892 $arguments['hyperparams'] = [];
1893 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1894 }
1895 if ( $batch_size ) {
1896 if ( empty( $arguments['hyperparams'] ) ) {
1897 $arguments['hyperparams'] = [];
1898 }
1899 $arguments['hyperparams']['batch_size'] = $batch_size;
1900 }
1901 if ( $learning_rate_multiplier ) {
1902 if ( empty( $arguments['hyperparams'] ) ) {
1903 $arguments['hyperparams'] = [];
1904 }
1905 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1906 }
1907 if ( $prompt_loss_weight ) {
1908 if ( empty( $arguments['hyperparams'] ) ) {
1909 $arguments['hyperparams'] = [];
1910 }
1911 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1912 }
1913 if ( $model === 'turbo' ) {
1914 $arguments['model'] = 'gpt-3.5-turbo';
1915 }
1916 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1917 }
1918 return $result;
1919 }
1920
1921 /**
1922 * Build the body of a form request.
1923 * If the field name is 'file', then the field value is the filename of the file to upload.
1924 * The file contents are taken from the 'data' field.
1925 *
1926 * @param array $fields
1927 * @param string $boundary
1928 * @return string
1929 */
1930 public function build_form_body( $fields, $boundary ) {
1931 $body = '';
1932 foreach ( $fields as $name => $value ) {
1933 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1934 continue;
1935 }
1936 $body .= "--$boundary\r\n";
1937 $body .= "Content-Disposition: form-data; name=\"$name\"";
1938 if ( $name === 'image' || $name === 'file' ) {
1939 $body .= "; filename=\"{$value}\"\r\n";
1940 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1941 $body .= "Content-Type: {$mime}\r\n\r\n";
1942 $body .= $fields['data'] . "\r\n";
1943 }
1944 else if ( $name === 'mask' ) {
1945 $body .= "; filename=\"{$value}\"\r\n";
1946 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1947 $body .= "Content-Type: {$mime}\r\n\r\n";
1948 $body .= $fields['mask_data'] . "\r\n";
1949 }
1950 else {
1951 // JSON encode arrays and objects, keep scalars as-is
1952 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
1953 $body .= "\r\n\r\n$encodedValue\r\n";
1954 }
1955 }
1956 $body .= "--$boundary--\r\n";
1957 return $body;
1958 }
1959
1960 /**
1961 * Run a request to the OpenAI API.
1962 * Fore more information about the $formFields, refer to the build_form_body method.
1963 *
1964 * @param string $method POST, PUT, GET, DELETE...
1965 * @param string $url The API endpoint
1966 * @param array $query The query parameters (json)
1967 * @param array $formFields The form fields (multipart/form-data)
1968 * @param bool $json Whether to return the response as json or not
1969 * @return array
1970 */
1971 public function execute(
1972 $method,
1973 $url,
1974 $query = null,
1975 $formFields = null,
1976 $json = true,
1977 $extraHeaders = null,
1978 $streamCallback = null
1979 ) {
1980 $isAzure = $this->envType === 'azure';
1981 $isOpenAI = !$isAzure;
1982
1983 // Prepare the headers
1984 $headers = "Content-Type: application/json\r\n";
1985 if ( $isOpenAI ) {
1986 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
1987 if ( $this->organizationId ) {
1988 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
1989 }
1990 }
1991 else if ( $isAzure ) {
1992 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
1993 }
1994
1995 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
1996 if ( !empty( $query ) && !is_string( $query ) ) {
1997 $body = $this->safe_json_encode( $query, 'query body' );
1998 }
1999 else {
2000 $body = $query;
2001 }
2002
2003 // If we have form fields, we need to change the headers and the body.
2004 if ( !empty( $formFields ) ) {
2005 $boundary = wp_generate_password( 24, false );
2006 $headers = [
2007 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2008 ];
2009 if ( $isOpenAI ) {
2010 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
2011 if ( $this->organizationId ) {
2012 $headers['OpenAI-Organization'] = $this->organizationId;
2013 }
2014 }
2015 else if ( $isAzure ) {
2016 $headers['api-key'] = $this->apiKey;
2017 }
2018 $body = $this->build_form_body( $formFields, $boundary );
2019 }
2020
2021 // Maybe we should have headers always as an array... not sure why we have it as a string.
2022 if ( !empty( $extraHeaders ) ) {
2023 foreach ( $extraHeaders as $key => $value ) {
2024 if ( is_array( $headers ) ) {
2025 $headers[$key] = $value;
2026 }
2027 else {
2028 $headers .= "$key: $value\r\n";
2029 }
2030 }
2031 }
2032
2033 // Create the URL
2034 if ( $isOpenAI ) {
2035 $url = 'https://api.openai.com/v1' . $url;
2036 }
2037 else if ( $isAzure ) {
2038 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
2039 $hasQuery = strpos( $url, '?' ) !== false;
2040 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
2041 }
2042
2043 // If it's a GET, body should be null, and we should append the query to the URL.
2044 if ( $method === 'GET' ) {
2045 if ( !empty( $query ) ) {
2046 $hasQuery = strpos( $url, '?' ) !== false;
2047 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
2048 }
2049 $body = null;
2050 }
2051
2052 $options = [
2053 'headers' => $headers,
2054 'method' => $method,
2055 'timeout' => MWAI_TIMEOUT,
2056 'body' => $body,
2057 'sslverify' => MWAI_SSL_VERIFY
2058 ];
2059
2060 // Check if queries debug is enabled
2061 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
2062
2063 // Log the request if queries debug is enabled
2064 if ( $queries_debug ) {
2065 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
2066
2067 if ( !empty( $body ) ) {
2068 error_log( '[AI Engine Queries] Request Body:' );
2069 error_log( $body );
2070 }
2071
2072 if ( !is_null( $streamCallback ) ) {
2073 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
2074 }
2075 }
2076
2077 try {
2078 if ( !is_null( $streamCallback ) ) {
2079 $options['stream'] = true;
2080 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
2081 // The stream handler calls the streamCallback every time there is content
2082 // TODO: For assistants, we should probably have a different stream handler to
2083 // handle the assistant's specific reply and perform the necessary actions.
2084 $this->streamCallback = $streamCallback;
2085 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
2086 }
2087 $res = wp_remote_request( $url, $options );
2088 if ( is_wp_error( $res ) ) {
2089 throw new Exception( $res->get_error_message() );
2090 }
2091
2092 $res = wp_remote_retrieve_body( $res );
2093
2094 // Handle empty responses for container LIST API only (not for file content downloads)
2095 if ( strpos( $url, '/containers/' ) !== false &&
2096 strpos( $url, '/files' ) !== false &&
2097 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
2098 empty( $res ) ) {
2099 // Return empty array for empty container files LIST response
2100 $data = $json ? [] : '';
2101 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
2102 }
2103 else {
2104 $data = $json ? json_decode( $res, true ) : $res;
2105 }
2106
2107 // Debug logging for decoded data (skip for content downloads)
2108 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
2109 error_log( '[AI Engine] After json_decode:' );
2110 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
2111 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
2112 if ( $data !== null && is_array( $data ) ) {
2113 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
2114 error_log( '[AI Engine] - Data count: ' . count( $data ) );
2115 }
2116 if ( $json && $data === null && !empty( $res ) ) {
2117 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
2118 }
2119 }
2120
2121 $this->handle_response_errors( $data );
2122
2123 // Log the response if queries debug is enabled
2124 if ( $queries_debug && is_null( $streamCallback ) ) {
2125 error_log( '[AI Engine Queries] Response Body:' );
2126 error_log( $res );
2127 }
2128 return $data;
2129 }
2130 catch ( Exception $e ) {
2131 $service = $this->get_service_name();
2132 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
2133 throw new Exception( "$service: " . $e->getMessage() );
2134 }
2135 finally {
2136 if ( !is_null( $streamCallback ) ) {
2137 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2138 }
2139 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
2140 unlink( $options['filename'] );
2141 }
2142 }
2143 }
2144
2145 public function get_models() {
2146 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
2147 // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
2148 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
2149 foreach ( $finetunes as $finetune ) {
2150 if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
2151 continue;
2152 }
2153 if ( empty( $finetune['base_model'] ) || empty( $finetune['model'] ) || empty( $finetune['suffix'] ) ) {
2154 continue;
2155 }
2156 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
2157 if ( !empty( $baseModel ) ) {
2158 $model = null;
2159 foreach ( $models as $currentModel ) {
2160 if ( $currentModel['model'] === $baseModel ) {
2161 $model = $currentModel;
2162 break;
2163 }
2164 }
2165 if ( !empty( $model ) ) {
2166 $model['model'] = $finetune['model'];
2167 $model['name'] = $finetune['suffix'];
2168 $models[] = $model;
2169 }
2170 }
2171 }
2172 return $models;
2173 }
2174
2175 public static function get_models_static() {
2176 return MWAI_OPENAI_MODELS;
2177 }
2178
2179 /**
2180 * Recursively search for container_id in the response data
2181 */
2182 protected function search_for_container_id_recursive( $data, $path = '' ) {
2183 if ( is_array( $data ) || is_object( $data ) ) {
2184 foreach ( $data as $key => $value ) {
2185 $currentPath = $path ? $path . '.' . $key : $key;
2186
2187 // Check if this key is container_id
2188 if ( $key === 'container_id' ) {
2189 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2190 }
2191
2192 // Recursively search in nested structures
2193 if ( is_array( $value ) || is_object( $value ) ) {
2194 $this->search_for_container_id_recursive( $value, $currentPath );
2195 }
2196 }
2197 }
2198 }
2199
2200 // TODO: After 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06), drop the $finetune branch from calculate_price() and the finetune-detection block in get_price().
2201 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2202 $modelFamily = self::get_model_without_release_date( $modelFamily );
2203 $models = $this->get_models();
2204 foreach ( $models as $currentModel ) {
2205 if ( $currentModel['model'] === $modelFamily ) {
2206 if ( $finetune ) {
2207 if ( isset( $currentModel['finetune']['price'] ) ) {
2208 $currentModel['price'] = $currentModel['finetune']['price'];
2209 }
2210 else if ( isset( $currentModel['finetune']['in'] ) ) {
2211 $currentModel['price'] = [
2212 'in' => $currentModel['finetune']['in'],
2213 'out' => $currentModel['finetune']['out']
2214 ];
2215 }
2216 }
2217 $inPrice = $currentModel['price'];
2218 $outPrice = $currentModel['price'];
2219 if ( is_array( $currentModel['price'] ) ) {
2220 $inPrice = $currentModel['price']['in'];
2221 $outPrice = $currentModel['price']['out'];
2222 }
2223 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2224 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2225 return $inTotalPrice + $outTotalPrice;
2226 }
2227 }
2228 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2229 return null;
2230 }
2231
2232 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2233 $model = $query->model;
2234 $units = 0;
2235 $finetune = false;
2236 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2237 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2238 $finetune = true;
2239 }
2240 $inUnits = $reply->get_in_tokens( $query );
2241 $outUnits = $reply->get_out_tokens();
2242 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2243 }
2244 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2245 // gpt-image models are billed by input/output tokens like text models.
2246 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2247 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2248 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2249 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2250 }
2251 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2252 $model = 'whisper';
2253 $units = $reply->get_units();
2254 return $this->calculate_price( $model, 0, $units, null, $finetune );
2255 }
2256 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2257 $units = $reply->get_total_tokens();
2258 return $this->calculate_price( $model, 0, $units, null, $finetune );
2259 }
2260 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2261 return null;
2262 }
2263 }
2264