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