PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.4
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 2 days ago chatml.php 2 days ago core.php 1 day ago custom.php 2 days ago factory.php 1 month ago google-interactions.php 2 days ago google.php 2 days ago mistral.php 1 month ago open-router.php 1 month ago openai.php 2 days ago ovh.php 2 days ago perplexity.php 7 months ago replicate.php 6 months ago xai.php 2 months ago
chatml.php
2258 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 if ( !file_exists( $query->path ) ) {
1116 throw new Exception( 'Audio file not found: ' . $query->path );
1117 }
1118 if ( !is_readable( $query->path ) ) {
1119 throw new Exception( 'Audio file is not readable: ' . $query->path );
1120 }
1121 $audioData = [
1122 'data' => file_get_contents( $query->path ),
1123 'length' => filesize( $query->path ) / 1024 // KB
1124 ];
1125 }
1126 // Priority 3: Attached file object
1127 else if ( method_exists( $query, 'getAttachments' ) ) {
1128 $attachments = $query->getAttachments();
1129 if ( !empty( $attachments ) ) {
1130 $file = $attachments[0];
1131 $audioData = [
1132 'data' => $file->get_data(),
1133 'length' => strlen( $file->get_data() ) / 1024 // KB
1134 ];
1135 }
1136 }
1137 // Priority 4: URL (backward compatibility)
1138 else if ( !empty( $query->url ) ) {
1139 if ( !filter_var( $query->url, FILTER_VALIDATE_URL ) ) {
1140 throw new Exception( 'Invalid URL for transcription.' );
1141 }
1142 $audioData = $this->get_audio( $query->url );
1143 }
1144 else {
1145 throw new Exception( 'No audio source provided for transcription. Please provide either audioData, path, attachedFiles, or url.' );
1146 }
1147
1148 $body = $this->build_body( $query, null, $audioData['data'] );
1149 $url = $this->build_url( $query );
1150 $headers = $this->build_headers( $query );
1151 $options = $this->build_options( $headers, null, $body );
1152
1153 // Perform the request
1154 try {
1155 $res = $this->run_query( $url, $options );
1156 $data = $res['data'];
1157 if ( empty( $data ) ) {
1158 throw new Exception( 'Invalid data for transcription.' );
1159 }
1160 $usage = $this->core->record_audio_usage( $query->model, $audioData['length'] );
1161 $reply = new Meow_MWAI_Reply( $query );
1162 $reply->set_usage( $usage );
1163 $reply->set_choices( $data );
1164 return $reply;
1165 }
1166 catch ( Exception $e ) {
1167 $service = $this->get_service_name();
1168 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1169 throw new Exception( "$service: " . $e->getMessage() );
1170 }
1171 }
1172
1173 public function run_embedding_query( $query ) {
1174 // Pre-validate token count against the embedding model's limit.
1175 // OpenAI embedding models (text-embedding-3-small/large, ada-002) all cap
1176 // at 8192 input tokens; sending a longer chunk yields an opaque
1177 // "Invalid 'input': maximum context length is 8192 tokens" error. Count
1178 // locally via tiktoken (bundled) so we fail with a clear message up front.
1179 $embedding_max_tokens = apply_filters( 'mwai_embedding_max_tokens', 8192, $query->model );
1180 if ( !empty( $query->message ) && $embedding_max_tokens > 0 ) {
1181 $token_count = Meow_MWAI_Core::estimate_tokens( $query->message, $query->model );
1182 if ( $token_count > $embedding_max_tokens ) {
1183 throw new Exception( sprintf(
1184 'Embedding input is %d tokens, exceeds the %d-token limit for %s. Split this entry into smaller chunks and try again.',
1185 $token_count,
1186 $embedding_max_tokens,
1187 $query->model
1188 ) );
1189 }
1190 }
1191
1192 $body = $this->build_body( $query );
1193 $url = $this->build_url( $query );
1194 $headers = $this->build_headers( $query );
1195 $options = $this->build_options( $headers, $body );
1196
1197 try {
1198 $res = $this->run_query( $url, $options );
1199 $data = $res['data'];
1200 if ( empty( $data ) || !isset( $data['data'] ) ) {
1201 throw new Exception( 'Invalid data for embedding.' );
1202 }
1203 $usage = $data['usage'];
1204 $this->core->record_tokens_usage( $query->model, $usage['prompt_tokens'] );
1205 $reply = new Meow_MWAI_Reply( $query );
1206 $reply->set_usage( $usage );
1207 $reply->set_choices( $data['data'] );
1208 return $reply;
1209 }
1210 catch ( Exception $e ) {
1211 $message = $e->getMessage();
1212 $error = $this->try_decode_error( $message );
1213 if ( !is_null( $error ) ) {
1214 $message = $error;
1215 }
1216 $service = $this->get_service_name();
1217 Meow_MWAI_Logging::error( "$service: " . $message );
1218 throw new Exception( "$service: " . $message );
1219 }
1220 }
1221
1222 public function try_decode_error( $data ) {
1223 $json = json_decode( $data, true );
1224 if ( isset( $json['error']['message'] ) ) {
1225 return $json['error']['message'];
1226 }
1227 return null;
1228 }
1229
1230 protected function finalize_choices( $choices, $responseData, $query ) {
1231 // Clean up duplicate function calls: prefer tool_calls over legacy function_call
1232 foreach ( $choices as &$choice ) {
1233 if ( isset( $choice['message'] ) ) {
1234 // If we have both tool_calls and function_call, remove function_call
1235 if ( isset( $choice['message']['tool_calls'] ) && !empty( $choice['message']['tool_calls'] ) &&
1236 isset( $choice['message']['function_call'] ) ) {
1237 unset( $choice['message']['function_call'] );
1238 }
1239 }
1240 }
1241 return $choices;
1242 }
1243
1244 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1245 // Check if this is a GPT-5 model - they don't support Chat Completions API
1246 if ( $this->is_gpt5_model( $query->model ) ) {
1247 throw new Exception( 'GPT-5 models only support the Responses API. Please enable "Use Responses API" in AI Engine settings to use ' . $query->model . '.' );
1248 }
1249
1250 $isStreaming = !is_null( $streamCallback );
1251
1252 // Initialize debug mode
1253 $this->init_debug_mode( $query );
1254
1255 // Store the callback for event emission (both streaming and non-streaming debug mode)
1256 if ( !is_null( $streamCallback ) ) {
1257 $this->streamCallback = $streamCallback;
1258 }
1259
1260 if ( $isStreaming ) {
1261 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1262 }
1263
1264 $this->reset_stream();
1265 $body = $this->build_body( $query, $streamCallback );
1266 $url = $this->build_url( $query );
1267 $headers = $this->build_headers( $query );
1268 $options = $this->build_options( $headers, $body );
1269
1270 // Emit "Request sent" event for feedback queries
1271 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1272 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1273 $event = Meow_MWAI_Event::request_sent()
1274 ->set_metadata( 'is_feedback', true )
1275 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1276 call_user_func( $streamCallback, $event );
1277 }
1278
1279 try {
1280 $res = $this->run_query( $url, $options, $streamCallback );
1281 $reply = new Meow_MWAI_Reply( $query );
1282
1283 $returned_id = null;
1284 $returned_model = $this->inModel;
1285 $returned_in_tokens = null;
1286 $returned_out_tokens = null;
1287 $returned_price = null;
1288 $returned_choices = [];
1289
1290 // Streaming Mode
1291 if ( $isStreaming ) {
1292 if ( empty( $this->streamContent ) ) {
1293 $error = $this->try_decode_error( $this->streamBuffer );
1294 if ( !is_null( $error ) ) {
1295 throw new Exception( $error );
1296 }
1297 // A stream that ends without a single chunk (no id, no content, no
1298 // tool call, no thinking) is a provider failure, not a reply. OVH
1299 // answers 200 with a lone [DONE] when it rejects a parameter, which
1300 // used to be saved as a silent empty message.
1301 if ( empty( $this->streamToolCalls ) && empty( $this->streamFunctionCall ) &&
1302 empty( $this->streamThinking ) && is_null( $this->inId ) ) {
1303 throw new Exception( 'The AI service closed the stream without sending a reply. A parameter (often Max Tokens) may exceed what this model supports.' );
1304 }
1305 }
1306 $returned_id = $this->inId;
1307 $returned_model = $this->inModel ? $this->inModel : $query->model;
1308
1309 // Use regular content if available, otherwise fall back to thinking/reasoning
1310 $finalContent = $this->streamContent;
1311 if ( empty( $finalContent ) && !empty( $this->streamThinking ) ) {
1312 // Use thinking content as fallback when there's no regular content
1313 // This happens with Ollama when it returns only reasoning/thinking
1314 // Wrap in asterisks to show as italics in markdown
1315 $finalContent = '*' . $this->streamThinking . '*';
1316
1317 // Log this for debugging
1318 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1319 error_log( '[AI Engine] Using thinking/reasoning content as fallback (no regular content available)' );
1320 }
1321 }
1322
1323 $message = [ 'role' => 'assistant', 'content' => $finalContent ];
1324 // Prefer tool_calls; fall back to legacy only if necessary
1325 if ( !empty( $this->streamToolCalls ) ) {
1326 $message['tool_calls'] = $this->streamToolCalls;
1327 }
1328 elseif ( !empty( $this->streamFunctionCall ) ) {
1329 $message['function_call'] = $this->streamFunctionCall;
1330 }
1331
1332 // Optionally include thinking as metadata if both content and thinking exist
1333 if ( !empty( $this->streamContent ) && !empty( $this->streamThinking ) ) {
1334 $message['thinking'] = $this->streamThinking;
1335 }
1336 if ( !is_null( $this->streamInTokens ) ) {
1337 $returned_in_tokens = $this->streamInTokens;
1338 }
1339 if ( !is_null( $this->streamOutTokens ) ) {
1340 $returned_out_tokens = $this->streamOutTokens;
1341 }
1342 if ( !is_null( $this->streamCost ) ) {
1343 $returned_price = $this->streamCost;
1344 }
1345 $returned_choices = [ [ 'message' => $message ] ];
1346 $returned_choices = $this->finalize_choices( $returned_choices, null, $query );
1347
1348 // Log streaming response data if queries debug is enabled
1349 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1350 if ( $queries_debug ) {
1351 error_log( '[AI Engine Queries] Streaming Response Collected (ChatML):' );
1352 $streaming_data = [
1353 'id' => $returned_id,
1354 'model' => $returned_model,
1355 'content_length' => strlen( $this->streamContent ),
1356 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
1357 'function_calls' => !empty( $this->streamFunctionCall ) ? '1 function call' : 'none',
1358 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
1359 'usage' => [
1360 'input_tokens' => $returned_in_tokens,
1361 'output_tokens' => $returned_out_tokens,
1362 'cost' => $returned_price
1363 ]
1364 ];
1365
1366 // Log tool calls details if present
1367 if ( !empty( $this->streamToolCalls ) ) {
1368 $streaming_data['tool_calls_details'] = [];
1369 foreach ( $this->streamToolCalls as $tool_call ) {
1370 $streaming_data['tool_calls_details'][] = [
1371 'id' => $tool_call['id'] ?? 'unknown',
1372 'name' => $tool_call['function']['name'] ?? 'unknown',
1373 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
1374 ];
1375 }
1376 }
1377
1378 // Log function call if present
1379 if ( !empty( $this->streamFunctionCall ) ) {
1380 $streaming_data['function_call'] = [
1381 'name' => $this->streamFunctionCall['name'] ?? 'unknown',
1382 'arguments' => substr( $this->streamFunctionCall['arguments'] ?? '{}', 0, 100 ) . '...'
1383 ];
1384 }
1385
1386 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
1387 }
1388 }
1389 // Standard Mode
1390 else {
1391 $data = $res['data'];
1392 if ( empty( $data ) ) {
1393 throw new Exception( 'No content received (res is null).' );
1394 }
1395
1396 // Comprehensive logging for non-streaming mode - capture FULL response
1397 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1398 if ( $queries_debug ) {
1399 error_log( '[AI Engine Queries] ========================================' );
1400 error_log( '[AI Engine Queries] FULL RESPONSE STRUCTURE (Non-streaming ChatML):' );
1401 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
1402 error_log( '[AI Engine Queries] ========================================' );
1403
1404 // Look specifically for container_id
1405 $this->search_for_container_id_recursive( $data, '' );
1406 }
1407
1408 if ( !$data['model'] ) {
1409 $service = $this->get_service_name();
1410 Meow_MWAI_Logging::error( "$service: Invalid response (no model information)." );
1411 Meow_MWAI_Logging::error( print_r( $data, 1 ) );
1412 throw new Exception( 'Invalid response (no model information).' );
1413 }
1414 $returned_id = $data['id'];
1415 $returned_model = $data['model'];
1416 $usage = $data['usage'] ?? [];
1417 $returned_in_tokens = $usage['prompt_tokens'] ?? null;
1418 $returned_out_tokens = $usage['completion_tokens'] ?? null;
1419 $returned_price = $usage['total_cost'] ?? $usage['cost'] ?? null;
1420 $returned_choices = $data['choices'];
1421 $returned_choices = $this->finalize_choices( $returned_choices, $data, $query );
1422
1423 // Strip inline <think> reasoning (Qwen-style models) from the reply.
1424 foreach ( $returned_choices as &$choice ) {
1425 if ( isset( $choice['message']['content'] ) && is_string( $choice['message']['content'] ) ) {
1426 $choice['message']['content'] = self::strip_inline_thinking( $choice['message']['content'] );
1427 }
1428 }
1429 unset( $choice );
1430 }
1431
1432 // Set the results.
1433 $reply->set_choices( $returned_choices );
1434 if ( !empty( $returned_id ) ) {
1435 $reply->set_id( $returned_id );
1436 }
1437 if ( !empty( $returned_id ) ) {
1438 $reply->set_id( $returned_id );
1439 }
1440
1441 // Handle tokens.
1442 $this->handle_tokens_usage(
1443 $reply,
1444 $query,
1445 $returned_model,
1446 $returned_in_tokens,
1447 $returned_out_tokens,
1448 $returned_price
1449 );
1450
1451 return $reply;
1452 }
1453 catch ( Exception $e ) {
1454 $service = $this->get_service_name();
1455 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1456 $message = "$service: " . $e->getMessage();
1457 throw new Exception( $message );
1458 }
1459 finally {
1460 if ( !is_null( $streamCallback ) ) {
1461 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1462 }
1463 }
1464 }
1465
1466 public function handle_tokens_usage(
1467 $reply,
1468 $query,
1469 $returned_model,
1470 $returned_in_tokens,
1471 $returned_out_tokens,
1472 $returned_price = null
1473 ) {
1474 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens :
1475 $reply->get_in_tokens( $query );
1476 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens :
1477 $reply->get_out_tokens();
1478 $returned_price = !is_null( $returned_price ) ? $returned_price :
1479 $reply->get_price();
1480 $usage = $this->core->record_tokens_usage(
1481 $returned_model,
1482 $returned_in_tokens,
1483 $returned_out_tokens,
1484 $returned_price
1485 );
1486 $reply->set_usage( $usage );
1487
1488 // Store the actual model returned by the API
1489 $reply->set_model( $returned_model );
1490
1491 // Set default accuracy to 'estimated' for engines that don't override
1492 // Most engines (Google, Anthropic, etc.) estimate tokens and calculate price
1493 $reply->set_usage_accuracy( 'estimated' );
1494 }
1495
1496 // Request to the OpenAI Images API (gpt-image models, token-based pricing)
1497 public function run_image_query( $query, $streamCallback = null ) {
1498 $body = $this->build_body( $query );
1499 $url = $this->build_url( $query );
1500 $headers = $this->build_headers( $query );
1501 $options = $this->build_options( $headers, $body );
1502
1503 try {
1504 $res = $this->run_query( $url, $options );
1505 $data = $res['data'];
1506 $choices = $data['data'];
1507 $reply = new Meow_MWAI_Reply( $query );
1508 $model = $query->model;
1509
1510 // gpt-image models always return usage with input/output tokens
1511 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1512 $usage = $data['usage'] ?? [];
1513 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1514 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1515 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1516 $usage['queries'] = 1;
1517 $usage['accuracy'] = 'tokens';
1518 $reply->set_usage( $usage );
1519 $reply->set_usage_accuracy( 'tokens' );
1520
1521 $reply->set_choices( $choices );
1522 $reply->set_type( 'images' );
1523
1524 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1525 foreach ( $reply->results as &$result ) {
1526 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1527 'query_envId' => $query->envId,
1528 'query_session' => $query->session,
1529 'query_model' => $query->model,
1530 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1531 $fileUrl = $this->core->files->get_url( $fileId );
1532 $result = $fileUrl;
1533 }
1534 }
1535 $reply->result = $reply->results[0];
1536 return $reply;
1537 }
1538 catch ( Exception $e ) {
1539 $service = $this->get_service_name();
1540 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1541 throw new Exception( "$service: " . $e->getMessage() );
1542 }
1543 }
1544
1545 public function run_editimage_query( $query ) {
1546 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
1547 if ( empty( $attachments ) ) {
1548 throw new Exception( 'No image provided for editing.' );
1549 }
1550 // Ensure the model supports image editing
1551 $modelInfo = $this->retrieve_model_info( $query->model );
1552 if ( empty( $modelInfo['tags'] ) || !in_array( 'image-edit', $modelInfo['tags'] ) ) {
1553 throw new Exception( 'The model ' . $query->model . ' does not support image editing.' );
1554 }
1555 $file = $attachments[0];
1556 $imageData = $file->get_data();
1557 $body = $this->build_body( $query, null, $imageData );
1558 $url = $this->build_url( $query );
1559 $headers = $this->build_headers( $query );
1560 $options = $this->build_options( $headers, null, $body );
1561
1562 try {
1563 $res = $this->run_query( $url, $options );
1564 $data = $res['data'];
1565 $choices = $data['data'];
1566 $reply = new Meow_MWAI_Reply( $query );
1567 $model = $query->model;
1568
1569 // gpt-image models always return usage with input/output tokens
1570 // (Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens')
1571 $usage = $data['usage'] ?? [];
1572 $promptTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0;
1573 $completionTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0;
1574 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
1575 $usage['queries'] = 1;
1576 $usage['accuracy'] = 'tokens';
1577 $reply->set_usage( $usage );
1578 $reply->set_usage_accuracy( 'tokens' );
1579
1580 $reply->set_choices( $choices );
1581 $reply->set_type( 'images' );
1582
1583 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1584 foreach ( $reply->results as &$result ) {
1585 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
1586 'query_envId' => $query->envId,
1587 'query_session' => $query->session,
1588 'query_model' => $query->model,
1589 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1590 $fileUrl = $this->core->files->get_url( $fileId );
1591 $result = $fileUrl;
1592 }
1593 }
1594 $reply->result = $reply->results[0];
1595 return $reply;
1596 }
1597 catch ( Exception $e ) {
1598 $service = $this->get_service_name();
1599 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
1600 throw new Exception( "$service: " . $e->getMessage() );
1601 }
1602 }
1603
1604 /*
1605 This is the rest of the OpenAI API support, not related to the models directly.
1606 */
1607
1608 // Check if there are errors in the response from OpenAI, and throw an exception if so.
1609 // OpenAI uses { error: { message: ... } }; some compatible providers (xAI, etc.) use a
1610 // plain string in the error field — handle both shapes.
1611 protected function handle_response_errors( $data ) {
1612 if ( !isset( $data['error'] ) || empty( $data['error'] ) ) {
1613 return;
1614 }
1615 $err = $data['error'];
1616 if ( is_array( $err ) ) {
1617 $message = $err['message'] ?? json_encode( $err );
1618 }
1619 else {
1620 $message = (string) $err;
1621 }
1622 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
1623 $message = str_replace( $matches[1], '', $message );
1624 }
1625 throw new Exception( $message );
1626 }
1627
1628 public function list_files( $purposeFilter = null ) {
1629 if ( empty( $purposeFilter ) ) {
1630 return $this->execute( 'GET', '/files' );
1631 }
1632 return $this->execute( 'GET', '/files', [ 'purpose' => $purposeFilter ] );
1633 }
1634
1635 public static function get_suffix_for_model( $model ) {
1636 // Legacy fine-tuned models
1637 preg_match( "/:([a-zA-Z0-9\-]{1,40})-([0-9]{4})-([0-9]{2})-([0-9]{2})/", $model, $matches );
1638 if ( count( $matches ) > 0 ) {
1639 return $matches[1];
1640 }
1641
1642 // New fine-tuned models
1643 preg_match( '/:([^:]+)(?=:[^:]+$)/', $model, $matches );
1644 if ( count( $matches ) > 0 ) {
1645 return $matches[1];
1646 }
1647
1648 return 'N/A';
1649 }
1650
1651 public static function get_model_without_release_date( $model ) {
1652 if ( empty( $model ) ) {
1653 return null;
1654 }
1655 return preg_replace( '/-\d{4}-\d{2}-\d{2}$/', '', $model );
1656 }
1657
1658 // 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.
1659 public function list_deleted_finetunes( $envId = null, $legacy = false ) {
1660 $finetunes = $this->list_finetunes( $legacy );
1661 $deleted = [];
1662
1663 foreach ( $finetunes as $finetune ) {
1664 $name = $finetune['model'];
1665 $isSucceeded = $finetune['status'] === 'succeeded';
1666 if ( $isSucceeded ) {
1667 try {
1668 $finetune = $this->get_model( $name );
1669 }
1670 catch ( Exception $e ) {
1671 $deleted[] = $name;
1672 }
1673 }
1674 }
1675 if ( $legacy ) {
1676 $this->core->update_ai_env( $this->envId, 'legacy_finetunes_deleted', $deleted );
1677 }
1678 else {
1679 $this->core->update_ai_env( $this->envId, 'finetunes_deleted', $deleted );
1680 }
1681 return $deleted;
1682 }
1683
1684 // TODO: This was used to retrieve the fine-tuned models, but not sure this is how we should
1685 // retrieve all the models since Summer 2023, let's see! WIP.
1686 public function list_finetunes( $legacy = false ) {
1687 if ( $legacy ) {
1688 $res = $this->execute( 'GET', '/fine-tunes' );
1689 }
1690 else {
1691 $res = $this->execute( 'GET', '/fine_tuning/jobs' );
1692 }
1693 $finetunes = $res['data'];
1694
1695 // Add suffix
1696 $finetunes = array_map( function ( $finetune ) {
1697 if ( isset( $finetune['user_provided_suffix'] ) ) {
1698 $finetune['suffix'] = $finetune['user_provided_suffix'];
1699 }
1700 else {
1701 $finetune['suffix'] = self::get_suffix_for_model( $finetune['fine_tuned_model'] );
1702 }
1703 $finetune['createdOn'] = date( 'Y-m-d H:i:s', $finetune['created_at'] ) . ' UTC';
1704 if ( isset( $finetune['estimated_finish'] ) ) {
1705 $finetune['estimatedOn'] = date( 'Y-m-d H:i:s', $finetune['estimated_finish'] ) . ' UTC';
1706 }
1707 else {
1708 $finetune['estimatedOn'] = null;
1709 }
1710 //$finetune['updatedOn'] = date( 'Y-m-d H:i:s', $finetune['updated_at'] );
1711 $finetune['base_model'] = $finetune['model'];
1712 $finetune['model'] = $finetune['fine_tuned_model'];
1713 unset( $finetune['object'] );
1714 unset( $finetune['hyperparams'] );
1715 unset( $finetune['result_files'] );
1716 unset( $finetune['training_files'] );
1717 unset( $finetune['validation_files'] );
1718 unset( $finetune['created_at'] );
1719 unset( $finetune['updated_at'] );
1720 unset( $finetune['fine_tuned_model'] );
1721 return $finetune;
1722 }, $finetunes );
1723
1724 usort( $finetunes, function ( $a, $b ) {
1725 return strtotime( $b['createdOn'] ) - strtotime( $a['createdOn'] );
1726 } );
1727
1728 if ( $legacy ) {
1729 $this->core->update_ai_env( $this->envId, 'legacy_finetunes', $finetunes );
1730 }
1731 else {
1732 $this->core->update_ai_env( $this->envId, 'finetunes', $finetunes );
1733 }
1734
1735 return $finetunes;
1736 }
1737
1738 public function moderate( $input ) {
1739 $result = $this->execute( 'POST', '/moderations', [
1740 'input' => $input
1741 ] );
1742 return $result;
1743 }
1744
1745 public function upload_file( $filename, $data, $purpose = 'fine-tune' ) {
1746 global $wp_filter;
1747
1748 // Build multipart form data
1749 $boundary = wp_generate_password( 24, false );
1750 $body = '';
1751 $body .= "--$boundary\r\n";
1752 $body .= "Content-Disposition: form-data; name=\"purpose\"\r\n\r\n";
1753 $body .= $purpose . "\r\n";
1754 $body .= "--$boundary\r\n";
1755 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1756 $body .= "Content-Type: application/octet-stream\r\n\r\n";
1757 $body .= $data . "\r\n";
1758 $body .= "--$boundary--\r\n";
1759
1760 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1761 // Save current hooks
1762 $saved_hooks = null;
1763 if ( isset( $wp_filter['http_api_curl'] ) ) {
1764 $saved_hooks = $wp_filter['http_api_curl'];
1765 unset( $wp_filter['http_api_curl'] );
1766 }
1767
1768 // Upload using WordPress HTTP API
1769 $url = 'https://api.openai.com/v1/files';
1770 $response = wp_remote_post( $url, [
1771 'headers' => [
1772 'Authorization' => 'Bearer ' . $this->apiKey,
1773 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1774 ],
1775 'body' => $body,
1776 'timeout' => 60
1777 ] );
1778
1779 // Restore hooks
1780 if ( $saved_hooks !== null ) {
1781 $wp_filter['http_api_curl'] = $saved_hooks;
1782 }
1783
1784 if ( is_wp_error( $response ) ) {
1785 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1786 }
1787
1788 $response_body = wp_remote_retrieve_body( $response );
1789 return json_decode( $response_body, true );
1790 }
1791
1792 public function create_vector_store( $name = null, $expiry = null, $metadata = null ) {
1793 $body = [
1794 'name' => !empty( $name ) ? $name : 'default',
1795 // Ensure metadata is an object (stdClass) for JSON encoding, not an array
1796 'metadata' => !empty( $metadata ) ? $metadata : new stdClass()
1797 ];
1798 if ( $expiry !== 'never' ) {
1799 if ( is_string( $expiry ) ) {
1800 error_log( 'AI Engine: Expiry is a string, setting it to 7 days.' );
1801 $expiry = 7;
1802 }
1803 $expiryInDays = $expiry ? max( 1, ceil( (int) $expiry / 86400 ) ) : 7;
1804 if ( $expiry && is_numeric( $expiry ) ) {
1805 $body['expires_after'] = [
1806 'anchor' => 'last_active_at',
1807 'days' => $expiryInDays
1808 ];
1809 }
1810 }
1811 $result = $this->execute( 'POST', '/vector_stores', $body, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1812 return $result['id'];
1813 }
1814
1815 public function get_vector_store( $vectorStoreId ) {
1816 return $this->execute( 'GET', '/vector_stores/' . $vectorStoreId, null, null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1817 }
1818
1819 public function add_vector_store_file( $vectorStoreId, $fileId ) {
1820 $result = $this->execute( 'POST', '/vector_stores/' . $vectorStoreId . '/files', [
1821 'file_id' => $fileId
1822 ], null, true, [ 'OpenAI-Beta' => 'assistants=v2' ] );
1823 return $result['id'];
1824
1825 }
1826
1827 public function delete_file( $fileId ) {
1828 return $this->execute( 'DELETE', '/files/' . $fileId );
1829 }
1830
1831 public function get_model( $modelId ) {
1832 return $this->execute( 'GET', '/models/' . $modelId );
1833 }
1834
1835 public function cancel_finetune( $fineTuneId ) {
1836 return $this->execute( 'POST', '/fine-tunes/' . $fineTuneId . '/cancel' );
1837 }
1838
1839 public function delete_finetune( $modelId ) {
1840 return $this->execute( 'DELETE', '/models/' . $modelId );
1841 }
1842
1843 public function download_file( $fileId, $newFile = null ) {
1844 $fileInfo = $this->execute( 'GET', '/files/' . $fileId, null, null, false );
1845 $fileInfo = json_decode( (string) $fileInfo, true );
1846 if ( empty( $fileInfo ) ) {
1847 throw new Exception( 'File (' . ( $fileId ?? 'N/A' ) . ') not found.' );
1848 }
1849 $filename = $fileInfo['filename'];
1850 $extension = pathinfo( $filename, PATHINFO_EXTENSION );
1851 if ( empty( $newFile ) ) {
1852 include_once( ABSPATH . 'wp-admin/includes/file.php' );
1853 $tempFile = wp_tempnam( $filename );
1854 if ( !$tempFile ) {
1855 $tempFile = tempnam( sys_get_temp_dir(), 'download_' );
1856 }
1857 if ( pathinfo( $tempFile, PATHINFO_EXTENSION ) != $extension ) {
1858 $newFile = $tempFile . '.' . $extension;
1859 }
1860 else {
1861 $newFile = $tempFile;
1862 }
1863 }
1864 $data = $this->execute( 'GET', '/files/' . $fileId . '/content', null, null, false );
1865 file_put_contents( $newFile, $data );
1866 return $newFile;
1867 }
1868
1869 public function run_finetune( $fileId, $model, $suffix, $hyperparams = [], $legacy = false ) {
1870 $n_epochs = isset( $hyperparams['nEpochs'] ) ? (int) $hyperparams['nEpochs'] : null;
1871 $batch_size = isset( $hyperparams['batchSize'] ) ? (int) $hyperparams['batchSize'] : null;
1872 $learning_rate_multiplier = isset( $hyperparams['learningRateMultiplier'] ) ?
1873 (float) $hyperparams['learningRateMultiplier'] : null;
1874 $prompt_loss_weight = isset( $hyperparams['promptLossWeight'] ) ?
1875 (float) $hyperparams['promptLossWeight'] : null;
1876 $arguments = [
1877 'training_file' => $fileId,
1878 'model' => $model,
1879 'suffix' => $suffix
1880 ];
1881 if ( $legacy ) {
1882 $result = $this->execute( 'POST', '/fine-tunes', $arguments );
1883 }
1884 else {
1885 if ( $n_epochs ) {
1886 $arguments['hyperparams'] = [];
1887 $arguments['hyperparams']['n_epochs'] = $n_epochs;
1888 }
1889 if ( $batch_size ) {
1890 if ( empty( $arguments['hyperparams'] ) ) {
1891 $arguments['hyperparams'] = [];
1892 }
1893 $arguments['hyperparams']['batch_size'] = $batch_size;
1894 }
1895 if ( $learning_rate_multiplier ) {
1896 if ( empty( $arguments['hyperparams'] ) ) {
1897 $arguments['hyperparams'] = [];
1898 }
1899 $arguments['hyperparams']['learning_rate_multiplier'] = $learning_rate_multiplier;
1900 }
1901 if ( $prompt_loss_weight ) {
1902 if ( empty( $arguments['hyperparams'] ) ) {
1903 $arguments['hyperparams'] = [];
1904 }
1905 $arguments['hyperparams']['prompt_loss_weight'] = $prompt_loss_weight;
1906 }
1907 if ( $model === 'turbo' ) {
1908 $arguments['model'] = 'gpt-3.5-turbo';
1909 }
1910 $result = $this->execute( 'POST', '/fine_tuning/jobs', $arguments );
1911 }
1912 return $result;
1913 }
1914
1915 /**
1916 * Build the body of a form request.
1917 * If the field name is 'file', then the field value is the filename of the file to upload.
1918 * The file contents are taken from the 'data' field.
1919 *
1920 * @param array $fields
1921 * @param string $boundary
1922 * @return string
1923 */
1924 public function build_form_body( $fields, $boundary ) {
1925 $body = '';
1926 foreach ( $fields as $name => $value ) {
1927 if ( in_array( $name, [ 'data', 'mime', 'mask_data', 'mask_mime' ] ) ) {
1928 continue;
1929 }
1930 $body .= "--$boundary\r\n";
1931 $body .= "Content-Disposition: form-data; name=\"$name\"";
1932 if ( $name === 'image' || $name === 'file' ) {
1933 $body .= "; filename=\"{$value}\"\r\n";
1934 $mime = !empty( $fields['mime'] ) ? $fields['mime'] : 'application/octet-stream';
1935 $body .= "Content-Type: {$mime}\r\n\r\n";
1936 $body .= $fields['data'] . "\r\n";
1937 }
1938 else if ( $name === 'mask' ) {
1939 $body .= "; filename=\"{$value}\"\r\n";
1940 $mime = !empty( $fields['mask_mime'] ) ? $fields['mask_mime'] : 'application/octet-stream';
1941 $body .= "Content-Type: {$mime}\r\n\r\n";
1942 $body .= $fields['mask_data'] . "\r\n";
1943 }
1944 else {
1945 // JSON encode arrays and objects, keep scalars as-is
1946 $encodedValue = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : $value;
1947 $body .= "\r\n\r\n$encodedValue\r\n";
1948 }
1949 }
1950 $body .= "--$boundary--\r\n";
1951 return $body;
1952 }
1953
1954 /**
1955 * Run a request to the OpenAI API.
1956 * Fore more information about the $formFields, refer to the build_form_body method.
1957 *
1958 * @param string $method POST, PUT, GET, DELETE...
1959 * @param string $url The API endpoint
1960 * @param array $query The query parameters (json)
1961 * @param array $formFields The form fields (multipart/form-data)
1962 * @param bool $json Whether to return the response as json or not
1963 * @return array
1964 */
1965 public function execute(
1966 $method,
1967 $url,
1968 $query = null,
1969 $formFields = null,
1970 $json = true,
1971 $extraHeaders = null,
1972 $streamCallback = null
1973 ) {
1974 $isAzure = $this->envType === 'azure';
1975 $isOpenAI = !$isAzure;
1976
1977 // Prepare the headers
1978 $headers = "Content-Type: application/json\r\n";
1979 if ( $isOpenAI ) {
1980 $headers .= 'Authorization: Bearer ' . $this->apiKey . "\r\n";
1981 if ( $this->organizationId ) {
1982 $headers .= 'OpenAI-Organization: ' . $this->organizationId . "\r\n";
1983 }
1984 }
1985 else if ( $isAzure ) {
1986 $headers .= 'api-key: ' . $this->apiKey . "\r\n";
1987 }
1988
1989 // Prepare the body with json_encode, if it's not a string or null, otherwise we keep it as is.
1990 if ( !empty( $query ) && !is_string( $query ) ) {
1991 $body = $this->safe_json_encode( $query, 'query body' );
1992 }
1993 else {
1994 $body = $query;
1995 }
1996
1997 // If we have form fields, we need to change the headers and the body.
1998 if ( !empty( $formFields ) ) {
1999 $boundary = wp_generate_password( 24, false );
2000 $headers = [
2001 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
2002 ];
2003 if ( $isOpenAI ) {
2004 $headers['Authorization'] = 'Bearer ' . $this->apiKey;
2005 if ( $this->organizationId ) {
2006 $headers['OpenAI-Organization'] = $this->organizationId;
2007 }
2008 }
2009 else if ( $isAzure ) {
2010 $headers['api-key'] = $this->apiKey;
2011 }
2012 $body = $this->build_form_body( $formFields, $boundary );
2013 }
2014
2015 // Maybe we should have headers always as an array... not sure why we have it as a string.
2016 if ( !empty( $extraHeaders ) ) {
2017 foreach ( $extraHeaders as $key => $value ) {
2018 if ( is_array( $headers ) ) {
2019 $headers[$key] = $value;
2020 }
2021 else {
2022 $headers .= "$key: $value\r\n";
2023 }
2024 }
2025 }
2026
2027 // Create the URL
2028 if ( $isOpenAI ) {
2029 $url = 'https://api.openai.com/v1' . $url;
2030 }
2031 else if ( $isAzure ) {
2032 $url = trailingslashit( $this->env['endpoint'] ) . 'openai' . $url;
2033 $hasQuery = strpos( $url, '?' ) !== false;
2034 $url = $url . ( $hasQuery ? '&' : '?' ) . $this->azureApiVersion;
2035 }
2036
2037 // If it's a GET, body should be null, and we should append the query to the URL.
2038 if ( $method === 'GET' ) {
2039 if ( !empty( $query ) ) {
2040 $hasQuery = strpos( $url, '?' ) !== false;
2041 $url = $url . ( $hasQuery ? '&' : '?' ) . http_build_query( $query );
2042 }
2043 $body = null;
2044 }
2045
2046 $options = [
2047 'headers' => $headers,
2048 'method' => $method,
2049 'timeout' => MWAI_TIMEOUT,
2050 'body' => $body,
2051 'sslverify' => MWAI_SSL_VERIFY
2052 ];
2053
2054 // Check if queries debug is enabled
2055 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
2056
2057 // Log the request if queries debug is enabled
2058 if ( $queries_debug ) {
2059 error_log( '[AI Engine Queries] HTTP Request to: ' . $url );
2060
2061 if ( !empty( $body ) ) {
2062 error_log( '[AI Engine Queries] Request Body:' );
2063 error_log( $body );
2064 }
2065
2066 if ( !is_null( $streamCallback ) ) {
2067 error_log( '[AI Engine Queries] (Streaming mode - response will be streamed)' );
2068 }
2069 }
2070
2071 try {
2072 if ( !is_null( $streamCallback ) ) {
2073 $options['stream'] = true;
2074 $options['filename'] = tempnam( sys_get_temp_dir(), 'mwai-stream-' );
2075 // The stream handler calls the streamCallback every time there is content
2076 // TODO: For assistants, we should probably have a different stream handler to
2077 // handle the assistant's specific reply and perform the necessary actions.
2078 $this->streamCallback = $streamCallback;
2079 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
2080 }
2081 $res = wp_remote_request( $url, $options );
2082 if ( is_wp_error( $res ) ) {
2083 throw new Exception( $res->get_error_message() );
2084 }
2085
2086 $res = wp_remote_retrieve_body( $res );
2087
2088 // Handle empty responses for container LIST API only (not for file content downloads)
2089 if ( strpos( $url, '/containers/' ) !== false &&
2090 strpos( $url, '/files' ) !== false &&
2091 strpos( $url, '/content' ) === false && // Don't apply this to content downloads
2092 empty( $res ) ) {
2093 // Return empty array for empty container files LIST response
2094 $data = $json ? [] : '';
2095 error_log( '[AI Engine] Container LIST API returned empty response, treating as empty array' );
2096 }
2097 else {
2098 $data = $json ? json_decode( $res, true ) : $res;
2099 }
2100
2101 // Debug logging for decoded data (skip for content downloads)
2102 if ( strpos( $url, '/containers/' ) !== false && strpos( $url, '/files' ) !== false && strpos( $url, '/content' ) === false ) {
2103 error_log( '[AI Engine] After json_decode:' );
2104 error_log( '[AI Engine] - Data type: ' . gettype( $data ) );
2105 error_log( '[AI Engine] - Data is null: ' . ( $data === null ? 'YES' : 'NO' ) );
2106 if ( $data !== null && is_array( $data ) ) {
2107 error_log( '[AI Engine] - Data keys: ' . implode( ', ', array_keys( $data ) ) );
2108 error_log( '[AI Engine] - Data count: ' . count( $data ) );
2109 }
2110 if ( $json && $data === null && !empty( $res ) ) {
2111 error_log( '[AI Engine] - JSON decode error: ' . json_last_error_msg() );
2112 }
2113 }
2114
2115 $this->handle_response_errors( $data );
2116
2117 // Log the response if queries debug is enabled
2118 if ( $queries_debug && is_null( $streamCallback ) ) {
2119 error_log( '[AI Engine Queries] Response Body:' );
2120 error_log( $res );
2121 }
2122 return $data;
2123 }
2124 catch ( Exception $e ) {
2125 $service = $this->get_service_name();
2126 Meow_MWAI_Logging::error( "$service: " . $e->getMessage() );
2127 throw new Exception( "$service: " . $e->getMessage() );
2128 }
2129 finally {
2130 if ( !is_null( $streamCallback ) ) {
2131 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2132 }
2133 if ( !empty( $options['stream'] ) && file_exists( $options['filename'] ) ) {
2134 unlink( $options['filename'] );
2135 }
2136 }
2137 }
2138
2139 public function get_models() {
2140 $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
2141 // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
2142 $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
2143 foreach ( $finetunes as $finetune ) {
2144 if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
2145 continue;
2146 }
2147 if ( empty( $finetune['base_model'] ) || empty( $finetune['model'] ) || empty( $finetune['suffix'] ) ) {
2148 continue;
2149 }
2150 $baseModel = self::get_model_without_release_date( $finetune['base_model'] );
2151 if ( !empty( $baseModel ) ) {
2152 $model = null;
2153 foreach ( $models as $currentModel ) {
2154 if ( $currentModel['model'] === $baseModel ) {
2155 $model = $currentModel;
2156 break;
2157 }
2158 }
2159 if ( !empty( $model ) ) {
2160 $model['model'] = $finetune['model'];
2161 $model['name'] = $finetune['suffix'];
2162 $models[] = $model;
2163 }
2164 }
2165 }
2166 return $models;
2167 }
2168
2169 public static function get_models_static() {
2170 return MWAI_OPENAI_MODELS;
2171 }
2172
2173 /**
2174 * Recursively search for container_id in the response data
2175 */
2176 protected function search_for_container_id_recursive( $data, $path = '' ) {
2177 if ( is_array( $data ) || is_object( $data ) ) {
2178 foreach ( $data as $key => $value ) {
2179 $currentPath = $path ? $path . '.' . $key : $key;
2180
2181 // Check if this key is container_id
2182 if ( $key === 'container_id' ) {
2183 error_log( '[AI Engine Queries] *** FOUND container_id at path: ' . $currentPath . ' = ' . $value . ' ***' );
2184 }
2185
2186 // Recursively search in nested structures
2187 if ( is_array( $value ) || is_object( $value ) ) {
2188 $this->search_for_container_id_recursive( $value, $currentPath );
2189 }
2190 }
2191 }
2192 }
2193
2194 // 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().
2195 private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
2196 $modelFamily = self::get_model_without_release_date( $modelFamily );
2197 $models = $this->get_models();
2198 foreach ( $models as $currentModel ) {
2199 if ( $currentModel['model'] === $modelFamily ) {
2200 if ( $finetune ) {
2201 if ( isset( $currentModel['finetune']['price'] ) ) {
2202 $currentModel['price'] = $currentModel['finetune']['price'];
2203 }
2204 else if ( isset( $currentModel['finetune']['in'] ) ) {
2205 $currentModel['price'] = [
2206 'in' => $currentModel['finetune']['in'],
2207 'out' => $currentModel['finetune']['out']
2208 ];
2209 }
2210 }
2211 $inPrice = $currentModel['price'];
2212 $outPrice = $currentModel['price'];
2213 if ( is_array( $currentModel['price'] ) ) {
2214 $inPrice = $currentModel['price']['in'];
2215 $outPrice = $currentModel['price']['out'];
2216 }
2217 $inTotalPrice = $inPrice * $currentModel['unit'] * $inUnits;
2218 $outTotalPrice = $outPrice * $currentModel['unit'] * $outUnits;
2219 return $inTotalPrice + $outTotalPrice;
2220 }
2221 }
2222 Meow_MWAI_Logging::warn( "(OpenAI) Invalid model ($modelFamily)." );
2223 return null;
2224 }
2225
2226 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
2227 $model = $query->model;
2228 $units = 0;
2229 $finetune = false;
2230 if ( is_a( $query, 'Meow_MWAI_Query_Text' ) || is_a( $query, 'Meow_MWAI_Query_Assistant' ) ) {
2231 if ( preg_match( '/^([a-zA-Z]{0,32}):/', $model, $matches ) ) {
2232 $finetune = true;
2233 }
2234 $inUnits = $reply->get_in_tokens( $query );
2235 $outUnits = $reply->get_out_tokens();
2236 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2237 }
2238 else if ( is_a( $query, 'Meow_MWAI_Query_Image' ) || is_a( $query, 'Meow_MWAI_Query_EditImage' ) ) {
2239 // gpt-image models are billed by input/output tokens like text models.
2240 // IMPORTANT: Images API uses 'input_tokens'/'output_tokens', not 'prompt_tokens'/'completion_tokens'
2241 $inUnits = $reply->usage['input_tokens'] ?? $reply->usage['prompt_tokens'] ?? 0;
2242 $outUnits = $reply->usage['output_tokens'] ?? $reply->usage['completion_tokens'] ?? 0;
2243 return $this->calculate_price( $model, $inUnits, $outUnits, null, $finetune );
2244 }
2245 else if ( is_a( $query, 'Meow_MWAI_Query_Transcribe' ) ) {
2246 $model = 'whisper';
2247 $units = $reply->get_units();
2248 return $this->calculate_price( $model, 0, $units, null, $finetune );
2249 }
2250 else if ( is_a( $query, 'Meow_MWAI_Query_Embed' ) ) {
2251 $units = $reply->get_total_tokens();
2252 return $this->calculate_price( $model, 0, $units, null, $finetune );
2253 }
2254 Meow_MWAI_Logging::warn( "(OpenAI) Cannot calculate price for $model." );
2255 return null;
2256 }
2257 }
2258