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