PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.4
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 / openai.php
ai-engine / classes / engines Last commit date
anthropic.php 5 months ago chatml.php 5 months ago core.php 7 months ago factory.php 8 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 6 months ago replicate.php 5 months ago
openai.php
2896 lines
1 <?php
2
3 /**
4 * OpenAI Engine implementation.
5 *
6 * This engine supports both the standard Chat Completions API and the new Responses API.
7 * The Responses API is used automatically for models that support it (models with the 'responses' tag).
8 *
9 * Key differences when using the Responses API:
10 * - Function calls and results use specific message types instead of role-based messages
11 * - MCP (Model Context Protocol) tools are executed remotely by OpenAI
12 * - Different streaming event structure
13 *
14 * @see https://platform.openai.com/docs/api-reference/responses
15 */
16 class Meow_MWAI_Engines_OpenAI extends Meow_MWAI_Engines_ChatML {
17 // Static
18 private static $creating = false;
19
20 // Responses API specific properties
21 protected $previousResponseId = null;
22 protected $conversationState = [];
23 protected $mcpToolNames = [];
24 protected $mcpServerCount = 0;
25 protected $mcpTotalToolCount = 0;
26 protected $emittedFunctionResults = [];
27
28 // Code interpreter content (separate from main content)
29 protected $streamContentCode = '';
30 protected $streamContainerId = null;
31 protected $streamCodeInterpreterFiles = []; // Track files created by code interpreter
32 protected $currentQuery = null;
33 protected $streamImages = [];
34 protected $seenCallIds = []; // Track seen call IDs to prevent duplicates
35 protected $lastRequestBody = null; // For debugging
36 protected $contentStarted = false; // Track if content streaming has started
37 protected $codeInterpreterCompleted = false; // Track if code interpreter has completed
38 // IMPORTANT: OpenAI Responses API sends the same function call in both:
39 // 1. response.output_item.done - when individual function call completes
40 // 2. response.completed - with all function calls in the final response
41 // We must deduplicate to avoid processing the same function twice
42
43 public static function create( $core, $env ) {
44 self::$creating = true;
45 if ( class_exists( 'MeowPro_MWAI_OpenAI' ) ) {
46 $instance = new MeowPro_MWAI_OpenAI( $core, $env );
47 }
48 else {
49 $instance = new self( $core, $env );
50 }
51 self::$creating = false;
52 return $instance;
53 }
54
55 public function __construct( $core, $env ) {
56 $isOwnClass = get_class( $this ) === 'Meow_MWAI_Engines_OpenAI';
57 if ( $isOwnClass && !self::$creating ) {
58 throw new \Exception( 'Please use the create() method to instantiate the Meow_MWAI_Engines_OpenAI class.' );
59 }
60 parent::__construct( $core, $env );
61 $this->set_environment();
62 }
63
64 public function reset_stream() {
65 parent::reset_stream();
66 $this->mcpServerCount = 0;
67 $this->mcpTotalToolCount = 0;
68 $this->emittedFunctionResults = [];
69 $this->streamImages = [];
70 $this->seenCallIds = [];
71 }
72
73 /**
74 * Check if a model should use the new Responses API
75 */
76 protected function should_use_responses_api( $model ) {
77 // First check if Responses API is enabled in settings
78 $options = $this->core->get_all_options();
79 $responsesApiEnabled = $options['ai_responses_api'] ?? true;
80
81 if ( !$responsesApiEnabled ) {
82 return false;
83 }
84
85 // Check if this is a prompt query - prompts REQUIRE Responses API
86 if ( isset( $this->currentQuery ) ) {
87 $promptData = $this->currentQuery->getExtraParam( 'prompt' );
88 if ( !empty( $promptData ) && !empty( $promptData['id'] ) ) {
89 return true;
90 }
91 }
92
93 // Azure supports Responses API in preview
94 // Model tag check below will determine if the specific model supports it
95
96 // Check if the model has the 'responses' tag
97 $modelInfo = $this->retrieve_model_info( $model );
98 if ( $modelInfo && !empty( $modelInfo['tags'] ) ) {
99 return in_array( 'responses', $modelInfo['tags'] );
100 }
101
102 return false;
103 }
104
105 /**
106 * Set conversation state for stateful responses
107 */
108 public function set_previous_response_id( $responseId ) {
109 $this->previousResponseId = $responseId;
110 }
111
112 /**
113 * Get conversation state
114 */
115 public function get_conversation_state() {
116 return $this->conversationState;
117 }
118
119 /**
120 * Build body for Responses API
121 */
122 protected function build_responses_body( $query, $streamCallback = null ) {
123 // For Azure, we need to use the deployment name as the model
124 $model = $query->model;
125 if ( $this->envType === 'azure' ) {
126 // Find the deployment name for this model
127 if ( isset( $this->env['deployments'] ) && is_array( $this->env['deployments'] ) ) {
128 foreach ( $this->env['deployments'] as $deployment ) {
129 if ( isset( $deployment['model'] ) && $deployment['model'] === $query->model && isset( $deployment['name'] ) ) {
130 $model = $deployment['name'];
131 break;
132 }
133 }
134 }
135 }
136
137 $body = [
138 'model' => $model,
139 'stream' => !is_null( $streamCallback ),
140 ];
141
142 // Handle different query types for Responses API
143 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
144 // Check if using Prompt mode
145 $promptData = $query->getExtraParam( 'prompt' );
146 if ( !empty( $promptData ) && !empty( $promptData['id'] ) ) {
147 // Use prompt instead of instructions
148 $body['prompt'] = $promptData;
149 // Remove model since it's configured in the prompt
150 unset( $body['model'] );
151 }
152 else if ( !empty( $query->instructions ) ) {
153 // Use simplified instructions + input format for basic queries
154 $body['instructions'] = $query->instructions;
155 }
156
157 // Determine history strategy
158 $historyStrategy = $query->historyStrategy;
159
160 // Treat empty string as null for automatic mode
161 if ( empty( $historyStrategy ) ) {
162 $historyStrategy = null;
163 }
164
165 // If historyStrategy is null (automatic), use response_id when previousResponseId is available
166 if ( $historyStrategy === null && !empty( $query->previousResponseId ) ) {
167 $historyStrategy = 'response_id';
168 }
169
170 // Debug logging for all queries when using Responses API
171 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
172
173 if ( $queries_debug ) {
174 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
175 error_log( '[AI Engine] Feedback query blocks: ' . count( $query->blocks ?? [] ) );
176 }
177 }
178
179 // Handle based on history strategy
180 // For Responses API, feedback queries MUST use previous_response_id to maintain conversation state
181 if ( $historyStrategy === 'response_id' && !empty( $query->previousResponseId ) ) {
182 // Use ResponseIdManager to validate the response ID
183 if ( $this->core->responseIdManager->is_valid_for_responses_api( $query->previousResponseId ) ) {
184 // Use incremental mode with previous_response_id
185 $body['previous_response_id'] = $query->previousResponseId;
186
187 // Debug logging
188 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
189 if ( $queries_debug ) {
190 error_log( '[AI Engine Queries] Using previous_response_id: ' . $query->previousResponseId );
191 }
192 }
193 else {
194 // Log warning if queries debug is enabled
195 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
196 if ( $queries_debug ) {
197 error_log( '[AI Engine Queries] Warning: ' .
198 Meow_MWAI_FunctionCallException::invalid_response_id(
199 $query->previousResponseId,
200 'Responses API',
201 'resp'
202 )->getMessage() );
203 }
204 // Fall through to full history mode
205 $historyStrategy = 'full_history';
206 }
207
208 }
209
210 // If we're still in response_id mode after validation, use incremental input
211 if ( $historyStrategy === 'response_id' && !empty( $body['previous_response_id'] ) ) {
212 // Check if this is a feedback query (function call response)
213 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
214 // For feedback queries with previous_response_id, we need to include:
215 // 1. The function_call from the model
216 // 2. The function_call_output with the result
217 $body['input'] = $this->build_feedback_input_for_responses_api( $query );
218
219 // Debug: Log the feedback input structure
220 if ( $queries_debug ) {
221 error_log( '[AI Engine Queries] Feedback input structure: ' . json_encode( $body['input'], JSON_PRETTY_PRINT ) );
222 }
223 }
224 else {
225 // Regular user message
226 $content = [
227 [
228 'type' => 'input_text',
229 'text' => $query->get_message()
230 ]
231 ];
232
233 // Check for attached files (unified approach)
234 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
235 foreach ( $attachments as $file ) {
236 // Check if it's an image or a file (PDF, etc.) BEFORE trying to get data
237 $mimeType = $file->get_mimeType() ?? '';
238 $isImage = strpos( $mimeType, 'image/' ) === 0;
239
240 if ( $isImage ) {
241 $fileUrl = $query->image_remote_upload === 'url'
242 ? $file->get_url()
243 : $file->get_inline_base64_url();
244 $content[] = [
245 'type' => 'input_image',
246 'image_url' => $fileUrl
247 ];
248 }
249 else {
250 // For non-images (PDFs, documents), use file_id approach
251 // IMPORTANT: Only use files that have been uploaded to OpenAI (provider_file_id type)
252 if ( $file->get_type() === 'provider_file_id' ) {
253 $fileId = $file->get_refId();
254 $content[] = [
255 'type' => 'input_file',
256 'file_id' => $fileId
257 ];
258 }
259 else {
260 // File hasn't been uploaded to OpenAI yet - should have been done in prepare_query
261 Meow_MWAI_Logging::warn( 'Responses API: File not uploaded to OpenAI yet (type: ' . $file->get_type() . ')' );
262 }
263 }
264 }
265
266 $body['input'] = [
267 [
268 'role' => 'user',
269 'content' => $content
270 ]
271 ];
272
273 // Add context if present
274 if ( !empty( $query->context ) ) {
275 $framedContext = $this->core->frame_context( $query->context );
276 // Prepend context as a separate input_text in the same message
277 array_unshift( $body['input'][0]['content'], [
278 'type' => 'input_text',
279 'text' => $framedContext . "\n\n"
280 ] );
281 }
282 }
283 }
284 else {
285 // Use full history mode (internal) or when no previous_response_id
286
287 // Build input - always use array format for Responses API
288 $hasAttachments = method_exists( $query, 'getAttachments' ) && !empty( $query->getAttachments() );
289 if ( !empty( $query->messages ) || $hasAttachments || $query instanceof Meow_MWAI_Query_Feedback ) {
290 $body['input'] = $this->build_responses_input_array( $query );
291 }
292 else {
293 // Even for simple text, Responses API expects message format
294 $body['input'] = [
295 [
296 'role' => 'user',
297 'content' => [
298 [
299 'type' => 'input_text',
300 'text' => $query->get_message()
301 ]
302 ]
303 ]
304 ];
305 }
306
307 // Add context if present
308 if ( !empty( $query->context ) ) {
309 $framedContext = $this->core->frame_context( $query->context );
310 if ( isset( $body['input'] ) && is_string( $body['input'] ) ) {
311 $body['input'] = $framedContext . "\n\n" . $body['input'];
312 }
313 else {
314 // Add context as system message
315 array_unshift( $body['input'], [
316 'role' => 'system',
317 'content' => $framedContext
318 ] );
319 }
320 }
321 }
322
323 // Parameters - skip these when using Prompt mode
324 $promptData = $query->getExtraParam( 'prompt' );
325 $isPromptMode = !empty( $promptData ) && !empty( $promptData['id'] );
326
327 if ( !$isPromptMode ) {
328 if ( !empty( $query->maxTokens ) ) {
329 $body['max_output_tokens'] = $query->maxTokens;
330 }
331
332 // Handle temperature parameter - GPT-5 models don't support it
333 if ( !empty( $query->temperature ) && $query->temperature !== 1 ) {
334 // Check if this is a GPT-5 model (gpt-5, gpt-5-mini, gpt-5-nano)
335 if ( strpos( $query->model, 'gpt-5' ) !== 0 ) {
336 $body['temperature'] = $query->temperature;
337 }
338 // For GPT-5 models, skip the temperature parameter entirely
339 }
340 }
341
342 // Handle reasoning parameter only for models that support it
343 if ( !$isPromptMode && !empty( $query->reasoning ) ) {
344 // Check if the model has the 'reasoning' tag
345 $modelInfo = $this->retrieve_model_info( $query->model );
346 if ( $modelInfo && !empty( $modelInfo['tags'] ) && in_array( 'reasoning', $modelInfo['tags'] ) ) {
347 // Add reasoning parameter as an object (Responses API expects object)
348 // { reasoning: { effort: 'none|minimal|low|medium|high|xhigh' } }
349 $body['reasoning'] = [ 'effort' => $query->reasoning ];
350 }
351 }
352
353 // Handle verbosity parameter only for models that support it
354 if ( !$isPromptMode && !empty( $query->verbosity ) ) {
355 // Check if the model has the 'verbosity' tag
356 $modelInfo = $this->retrieve_model_info( $query->model );
357 if ( $modelInfo && !empty( $modelInfo['tags'] ) && in_array( 'verbosity', $modelInfo['tags'] ) ) {
358 // Add verbosity parameter if set (inside text object)
359 if ( !isset( $body['text'] ) || !is_array( $body['text'] ) ) {
360 $body['text'] = [];
361 }
362 $body['text']['verbosity'] = $query->verbosity;
363 }
364 }
365
366 // Note: The Responses API does not support the 'n' parameter for multiple results
367 // Unlike the Chat Completions API, Responses API generates one response at a time
368 // If multiple results are needed, separate requests must be made
369 // Reference: https://platform.openai.com/docs/api-reference/responses
370 if ( !empty( $query->maxResults ) && $query->maxResults > 1 ) {
371 Meow_MWAI_Logging::warn( 'Responses API does not support multiple results (n parameter). Only one result will be generated.' );
372 }
373
374 if ( !empty( $query->stop ) ) {
375 $body['stop'] = $query->stop;
376 }
377
378 if ( !empty( $query->responseFormat ) && $query->responseFormat === 'json' ) {
379 // Responses API uses 'text.format' instead of 'response_format'
380 if ( !isset( $body['text'] ) || !is_array( $body['text'] ) ) {
381 $body['text'] = [];
382 }
383 $body['text']['format'] = [ 'type' => 'json_object' ];
384 }
385
386 // Function calling - convert to tools
387 // IMPORTANT: Tools must be included in ALL requests, even when using previous_response_id
388 // The API needs to know which functions are available throughout the entire conversation
389 if ( !empty( $query->functions ) ) {
390 $body['tools'] = $this->build_responses_tools( $query->functions );
391 // IMPORTANT: Enable parallel tool calls to allow multiple function calls in one response
392 // TODO: OpenAI's Responses API has a bug where it only returns ONE function call even when
393 // parallel_tool_calls=true is set and multiple functions are clearly needed. This works correctly
394 // with the Chat Completions API. Monitor OpenAI's updates and test again in the future.
395 // Issue discovered: August 2025 - Only getDeskTemperature is called when both desk AND outdoor are requested.
396 $body['parallel_tool_calls'] = true;
397 }
398
399 // Add MCP servers if available
400 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
401 $mcp_envs = $this->core->get_option( 'mcp_envs' );
402 $this->mcpServerCount = count( $query->mcpServers );
403
404 foreach ( $query->mcpServers as $mcpServer ) {
405 if ( isset( $mcpServer['id'] ) ) {
406 // Find the full MCP server configuration by ID
407 foreach ( $mcp_envs as $env ) {
408 if ( $env['id'] === $mcpServer['id'] ) {
409 // Sanitize server label for OpenAI requirements
410 $server_label = $env['name'] . '_' . $env['id'];
411 // Remove spaces and special characters
412 $server_label = preg_replace( '/[^a-zA-Z0-9_]/', '', $server_label );
413 // Replace double or tripe underscores with single underscore
414 $server_label = preg_replace( '/_{2,}/', '_', $server_label );
415 // Ensure it starts with a letter
416 if ( !preg_match( '/^[a-zA-Z]/', $server_label ) ) {
417 $server_label = 'mcp_' . $server_label;
418 }
419
420 $mcp_tool = [
421 'type' => 'mcp',
422 'server_label' => $server_label,
423 'server_url' => $env['url'],
424 'require_approval' => 'never'
425 ];
426
427 // Add authorization header if available
428 if ( !empty( $env['token'] ) ) {
429 $mcp_tool['headers'] = [
430 'Authorization' => 'Bearer ' . $env['token']
431 ];
432 }
433
434 // Add to tools array
435 if ( !isset( $body['tools'] ) ) {
436 $body['tools'] = [];
437 }
438 $body['tools'][] = $mcp_tool;
439
440 break;
441 }
442 }
443 }
444 }
445 }
446
447 // Add tool_choice parameter if tools are present
448 if ( !empty( $body['tools'] ) ) {
449 // Default to 'auto' to let the model choose
450 $body['tool_choice'] = 'auto';
451 }
452
453 // Add tools (web_search, image_generation, code_interpreter) if specified
454 if ( !empty( $query->tools ) && is_array( $query->tools ) ) {
455
456 // Ensure tools array exists
457 if ( !isset( $body['tools'] ) ) {
458 $body['tools'] = [];
459 }
460
461 // Add each enabled tool
462 foreach ( $query->tools as $tool ) {
463 if ( in_array( $tool, ['web_search', 'image_generation', 'code_interpreter'] ) ) {
464 $toolConfig = [ 'type' => $tool ];
465
466 // Image generation requires partial_images when streaming
467 if ( $tool === 'image_generation' && !empty( $streamCallback ) ) {
468 $toolConfig['partial_images'] = 1;
469 }
470
471 // Code interpreter requires container configuration
472 if ( $tool === 'code_interpreter' ) {
473 $toolConfig['container'] = [ 'type' => 'auto' ];
474 // Add file_ids if available in the query
475 if ( !empty( $query->fileIds ) && is_array( $query->fileIds ) ) {
476 $toolConfig['container']['file_ids'] = $query->fileIds;
477 }
478 // Code interpreter tool configured
479 }
480
481 $body['tools'][] = $toolConfig;
482 Meow_MWAI_Logging::log( 'Responses API: Added tool ' . $tool . ' to request' );
483 }
484 }
485 }
486
487 // Add file_search tool if OpenAI Vector Store is configured
488 if ( !empty( $query->embeddingsEnvId ) ) {
489 Meow_MWAI_Logging::log( 'Responses API: Checking embeddings environment - embeddingsEnvId: ' . $query->embeddingsEnvId );
490
491 $embeddingsEnv = $this->core->get_embeddings_env( $query->embeddingsEnvId );
492
493 if ( $embeddingsEnv && $embeddingsEnv['type'] === 'openai-vector-store' ) {
494 Meow_MWAI_Logging::log( 'Responses API: Found OpenAI Vector Store environment' );
495
496 // Check if the OpenAI environment matches
497 $openai_env_id = $embeddingsEnv['openai_env_id'] ?? null;
498
499 Meow_MWAI_Logging::log( 'Responses API: Comparing environments - embeddings OpenAI env: ' . ( $openai_env_id ?? 'null' ) . ', current env: ' . $this->envId );
500
501 if ( $openai_env_id === $this->envId && !empty( $embeddingsEnv['store_id'] ) ) {
502 // Ensure tools array exists
503 if ( !isset( $body['tools'] ) ) {
504 $body['tools'] = [];
505 }
506
507 // Add file_search tool with vector store ID
508 $body['tools'][] = [
509 'type' => 'file_search',
510 'vector_store_ids' => [ $embeddingsEnv['store_id'] ]
511 ];
512
513 Meow_MWAI_Logging::log( 'Responses API: Added file_search tool with vector store: ' . $embeddingsEnv['store_id'] );
514 }
515 else {
516 if ( $openai_env_id !== $this->envId ) {
517 Meow_MWAI_Logging::log( 'Responses API: Environment mismatch - file_search tool not added' );
518 }
519 if ( empty( $embeddingsEnv['store_id'] ) ) {
520 Meow_MWAI_Logging::log( 'Responses API: No store_id configured - file_search tool not added' );
521 }
522 }
523 }
524 else {
525 Meow_MWAI_Logging::log( 'Responses API: Embeddings environment is not OpenAI Vector Store type (type: ' . ( $embeddingsEnv['type'] ?? 'null' ) . ')' );
526 }
527 }
528 else {
529 Meow_MWAI_Logging::log( 'Responses API: No embeddingsEnvId in query - file_search tool not added' );
530 }
531
532 // Note: Responses API doesn't support stream_options parameter
533 // Usage tracking is handled differently in the streaming response
534 }
535 else if ( $query instanceof Meow_MWAI_Query_Image ) {
536 // For image generation, we can use the integrated approach
537 if ( strpos( $query->model, 'gpt-image' ) === 0 ) {
538 $body['tools'] = [[
539 'type' => 'image_generation'
540 ]];
541 $body['input'] = $query->get_message();
542 }
543 else {
544 // Fallback to old API for DALL-E models
545 return $this->build_body( $query, $streamCallback );
546 }
547 }
548
549 // Debug logging for feedback queries
550 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
551 Meow_MWAI_Logging::log( 'Responses API: Feedback query body: ' . json_encode( $body ) );
552 }
553
554 // Ensure parallel_tool_calls is set when we have tools
555 if ( !empty( $body['tools'] ) && !isset( $body['parallel_tool_calls'] ) ) {
556 $body['parallel_tool_calls'] = true;
557 }
558
559 // Azure Responses API doesn't support web_search tool yet (preview limitation)
560 if ( $this->envType === 'azure' && !empty( $body['tools'] ) ) {
561 $body['tools'] = array_values( array_filter( $body['tools'], function ( $tool ) {
562 $toolType = $tool['type'] ?? null;
563 if ( $toolType === 'web_search' ) {
564 Meow_MWAI_Logging::log( 'Responses API: Removing web_search tool for Azure (not supported in preview)' );
565 return false;
566 }
567 return true;
568 } ) );
569 }
570
571 return $body;
572 }
573
574 /**
575 * Build tool messages for feedback when using previous_response_id
576 */
577 protected function build_tool_messages_for_feedback( $query ) {
578 $messages = [];
579
580 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
581 foreach ( $query->blocks as $block ) {
582 if ( isset( $block['feedbacks'] ) ) {
583 foreach ( $block['feedbacks'] as $feedback ) {
584 // Get the tool call ID from the original request
585 $toolId = $feedback['request']['toolId'] ?? null;
586
587 if ( $toolId ) {
588 // According to Responses API spec, tool results should use role:"tool"
589 $toolMessage = [
590 'role' => 'tool',
591 'tool_call_id' => $toolId,
592 'content' => [
593 [
594 'type' => 'tool_result',
595 'tool_result' => (string) ( $feedback['reply']['value'] ?? '' )
596 ]
597 ]
598 ];
599 $messages[] = $toolMessage;
600
601 Meow_MWAI_Logging::log( 'Responses API: Added tool result with tool_call_id ' . $toolId . ' - Message: ' . json_encode( $toolMessage ) );
602 }
603 }
604 }
605 }
606 }
607
608 return $messages;
609 }
610
611 /**
612 * Build input array for complex message structures
613 */
614 protected function build_responses_input_array( $query ) {
615 // Use the MessageBuilder service for streamlined message building
616 // Note: Files are uploaded via prepare_query() BEFORE streaming hooks are set
617 $messages = $this->core->messageBuilder->build_responses_api_messages( $query );
618
619 // Note: Function result events are now emitted centrally in core.php
620 // when the function is actually executed
621
622 // Debug logging
623 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
624 if ( $queries_debug && $query instanceof Meow_MWAI_Query_Feedback ) {
625 error_log( '[AI Engine Queries] Feedback query messages order:' );
626 foreach ( $messages as $idx => $msg ) {
627 if ( isset( $msg['type'] ) ) {
628 $log_msg = ' [' . $idx . '] ' . $msg['type'];
629 if ( $msg['type'] === 'function_call' ) {
630 $log_msg .= ' - ' . ( $msg['name'] ?? 'unknown' ) . ' (call_id: ' . ( $msg['call_id'] ?? 'none' ) . ')';
631 }
632 elseif ( $msg['type'] === 'function_call_output' ) {
633 $log_msg .= ' (call_id: ' . ( $msg['call_id'] ?? 'none' ) . ', output: ' . substr( $msg['output'] ?? '', 0, 50 ) . ')';
634 }
635 error_log( '[AI Engine Queries]' . $log_msg );
636 }
637 elseif ( isset( $msg['role'] ) ) {
638 $content_preview = '';
639 if ( isset( $msg['content'] ) ) {
640 if ( is_string( $msg['content'] ) ) {
641 $content_preview = ' - "' . substr( $msg['content'], 0, 50 ) . '"';
642 }
643 elseif ( is_array( $msg['content'] ) && isset( $msg['content'][0]['text'] ) ) {
644 $content_preview = ' - "' . substr( $msg['content'][0]['text'], 0, 50 ) . '"';
645 }
646 elseif ( is_array( $msg['content'] ) && isset( $msg['content'][0]['type'] ) && $msg['content'][0]['type'] === 'input_text' ) {
647 $content_preview = ' - "' . substr( $msg['content'][0]['text'] ?? '', 0, 50 ) . '"';
648 }
649 }
650 error_log( '[AI Engine Queries] [' . $idx . '] ' . $msg['role'] . $content_preview );
651 }
652 }
653 }
654
655 return $messages;
656 }
657
658 /**
659 * Convert functions to Responses API tools format
660 */
661 protected function build_responses_tools( $functions ) {
662 $tools = [];
663
664 foreach ( $functions as $function ) {
665 $functionData = $function->serializeForOpenAI();
666
667 // Ensure the function data has all required fields
668 if ( !isset( $functionData['name'] ) || empty( $functionData['name'] ) ) {
669 Meow_MWAI_Logging::warn( 'Function missing required name field' );
670 continue;
671 }
672
673 // Responses API expects a flatter structure
674 $parameters = $functionData['parameters'] ?? null;
675
676 // Ensure parameters has the correct structure
677 if ( !$parameters ) {
678 $parameters = [
679 'type' => 'object',
680 'properties' => new stdClass(),
681 'required' => []
682 ];
683 }
684 else {
685 // Ensure properties is an object, not an array when empty
686 if ( isset( $parameters['properties'] ) &&
687 is_array( $parameters['properties'] ) &&
688 empty( $parameters['properties'] ) ) {
689 $parameters['properties'] = new stdClass();
690 }
691 }
692
693 $tool = [
694 'type' => 'function',
695 'name' => $functionData['name'],
696 'description' => $functionData['description'] ?? '',
697 'parameters' => $parameters,
698 'strict' => false // Set to false for now, can be made configurable later
699 ];
700
701 $tools[] = $tool;
702 }
703
704 return $tools;
705 }
706
707 /**
708 * Build feedback input for Responses API when using previous_response_id.
709 *
710 * The Responses API requires a very specific format for function results:
711 * 1. Echo the exact function_call message from the model
712 * 2. Provide the function_call_output with matching call_id
713 *
714 * This method extracts these from the feedback blocks and formats them correctly.
715 *
716 * @param Meow_MWAI_Query_Feedback $query The feedback query containing function results
717 * @return array Array of messages in Responses API format
718 */
719 protected function build_feedback_input_for_responses_api( $query ) {
720 // Use the MessageBuilder service for streamlined message building
721 $messages = $this->core->messageBuilder->build_feedback_only_messages( $query );
722
723 // For Responses API, the input should be wrapped in a specific structure
724 // According to OpenAI docs, function results should be sent as an array of messages
725 return $messages;
726 }
727
728 /**
729 * Build URL for Responses API
730 */
731 protected function build_responses_url() {
732 if ( $this->envType === 'azure' ) {
733 // Azure v1 Responses API endpoint (preview)
734 $endpoint = isset( $this->env['endpoint'] ) ? rtrim( $this->env['endpoint'], '/' ) : null;
735
736 // Handle legacy full path endpoints for backward compatibility
737 if ( strpos( $endpoint, '/openai/responses' ) !== false || strpos( $endpoint, '/openai/v1/responses' ) !== false ) {
738 // Extract the base URL (remove the path and query params)
739 $baseUrl = str_replace( '/openai/responses', '', $endpoint );
740 $baseUrl = str_replace( '/openai/v1/responses', '', $baseUrl );
741 $baseUrl = preg_replace( '/\?.*$/', '', $baseUrl );
742
743 // For Azure v1 Responses API, we do NOT include deployment in the URL
744 // The deployment name goes in the request body as 'model'
745 $url = $baseUrl . '/openai/v1/responses';
746
747 // Preserve the API version if it was included
748 if ( strpos( $endpoint, 'api-version=' ) !== false ) {
749 preg_match( '/api-version=([^&]+)/', $endpoint, $matches );
750 $apiVersion = $matches[1] ?? 'preview';
751 $url .= '?api-version=' . $apiVersion;
752 }
753 else {
754 $url .= '?api-version=preview';
755 }
756 }
757 else {
758 // Standard format: just the resource domain
759 // Ensure the endpoint has the proper protocol
760 if ( strpos( $endpoint, 'http' ) !== 0 ) {
761 $endpoint = 'https://' . $endpoint;
762 }
763
764 // Build the v1 endpoint without deployment in path
765 // For Azure v1 Responses API, deployment goes in the body, not the URL
766 $url = rtrim( $endpoint, '/' ) . '/openai/v1/responses?api-version=preview';
767 }
768 }
769 else {
770 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
771 $url = trailingslashit( $endpoint ) . 'responses';
772 }
773
774 return $url;
775 }
776
777 /**
778 * Get Azure endpoint with protocol
779 */
780 private function get_azure_endpoint() {
781 $endpoint = isset( $this->env['endpoint'] ) ? rtrim( $this->env['endpoint'], '/' ) : null;
782
783 if ( empty( $endpoint ) ) {
784 throw new Exception( 'Azure endpoint not configured. Please set the endpoint URL in your Azure environment settings.' );
785 }
786
787 // Ensure endpoint has protocol
788 if ( strpos( $endpoint, 'http://' ) !== 0 && strpos( $endpoint, 'https://' ) !== 0 ) {
789 $endpoint = 'https://' . $endpoint;
790 }
791
792 return $endpoint;
793 }
794
795 /**
796 * Extract Azure region from endpoint URL
797 * Azure OpenAI endpoints can be in different formats:
798 * - https://my-resource.openai.azure.com (custom subdomain - region not in URL)
799 * - https://eastus2.api.cognitive.microsoft.com (region-based)
800 *
801 * For custom subdomains, we need to use the Azure REST API to get the region,
802 * but for simplicity we'll check if a region is explicitly set in the env,
803 * otherwise default to common regions based on the resource name pattern.
804 */
805 private function get_azure_region( $endpoint ) {
806 // Check if region is explicitly set in environment
807 if ( isset( $this->env['region'] ) && !empty( $this->env['region'] ) ) {
808 return $this->env['region'];
809 }
810
811 // Try to extract from region-based endpoint format
812 if ( preg_match( '/([a-z0-9]+)\.api\.cognitive\.microsoft\.com/', $endpoint, $matches ) ) {
813 return $matches[1];
814 }
815
816 // For custom subdomain endpoints, try to infer from common patterns
817 // or default to the most common realtime regions
818 if ( preg_match( '/\.openai\.azure\.com/', $endpoint ) ) {
819 // Default to eastus2 as it's one of the primary realtime regions
820 // User can override this by setting the region in their environment
821 return 'eastus2';
822 }
823
824 // Fallback default
825 return 'eastus2';
826 }
827
828 /**
829 * Build Azure realtime sessions URL
830 */
831 private function build_azure_realtime_url( $endpoint ) {
832 // Azure uses /openai/realtimeapi/sessions (not /openai/realtime/sessions)
833 // The deployment is sent in the POST body, not in the URL
834 return $endpoint . '/openai/realtimeapi/sessions?api-version=2025-04-01-preview';
835 }
836
837 /**
838 * Build Azure v1 URL for containers/files
839 */
840 private function build_azure_v1_url( $endpoint, $url ) {
841 $fullUrl = $endpoint . '/openai/v1' . $url;
842
843 // Add API version
844 $hasQuery = strpos( $fullUrl, '?' ) !== false;
845 return $fullUrl . ( $hasQuery ? '&' : '?' ) . 'api-version=preview';
846 }
847
848 /**
849 * Override execute to handle Azure v1 endpoints for containers, files, and realtime
850 */
851 public function execute(
852 $method,
853 $url,
854 $query = null,
855 $formFields = null,
856 $json = true,
857 $extraHeaders = null,
858 $streamCallback = null
859 ) {
860 // For Azure container/files/realtime operations, use v1 endpoint
861 if ( $this->envType === 'azure' &&
862 ( strpos( $url, '/containers/' ) !== false ||
863 strpos( $url, '/files/' ) !== false ||
864 strpos( $url, '/realtime/' ) !== false ) ) {
865
866 $endpoint = $this->get_azure_endpoint();
867
868 // Build the appropriate URL based on the operation type
869 if ( strpos( $url, '/realtime/sessions' ) !== false ) {
870 $fullUrl = $this->build_azure_realtime_url( $endpoint );
871 }
872 else {
873 $fullUrl = $this->build_azure_v1_url( $endpoint, $url );
874 }
875
876 // Prepare headers
877 $headers = [
878 'Content-Type' => 'application/json',
879 'api-key' => $this->apiKey
880 ];
881
882 if ( $extraHeaders ) {
883 $headers = array_merge( $headers, $extraHeaders );
884 }
885
886 // Prepare body
887 $body = null;
888 if ( $method !== 'GET' && !empty( $query ) ) {
889 if ( is_string( $query ) ) {
890 $body = $query;
891 }
892 else {
893 $body = $this->safe_json_encode( $query, 'Azure v1 query' );
894 }
895 }
896
897 $options = [
898 'headers' => $headers,
899 'method' => $method,
900 'timeout' => MWAI_TIMEOUT,
901 'body' => $body,
902 'sslverify' => MWAI_SSL_VERIFY
903 ];
904
905 // Log if debug enabled
906 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
907 if ( $queries_debug ) {
908 error_log( '[AI Engine Queries] Azure v1 Request to: ' . $fullUrl );
909 error_log( '[AI Engine Queries] Method: ' . $method );
910 error_log( '[AI Engine Queries] Headers: ' . json_encode( array_keys( $headers ) ) );
911 if ( !empty( $body ) ) {
912 error_log( '[AI Engine Queries] Request Body: ' . $body );
913 }
914 }
915
916 // Make the request
917 $res = wp_remote_request( $fullUrl, $options );
918
919 if ( is_wp_error( $res ) ) {
920 throw new Exception( $res->get_error_message() );
921 }
922
923 $response_code = wp_remote_retrieve_response_code( $res );
924 $res = wp_remote_retrieve_body( $res );
925
926 // Log response
927 if ( $queries_debug ) {
928 error_log( '[AI Engine Queries] Azure v1 Response Code: ' . $response_code );
929 error_log( '[AI Engine Queries] Azure v1 Response: ' . $res );
930 }
931
932 // Handle response
933 if ( strpos( $url, '/content' ) !== false ) {
934 // Binary content download
935 return $res;
936 }
937
938 // JSON response
939 $data = json_decode( $res, true );
940
941 $this->handle_response_errors( $data );
942
943 return $data;
944 }
945
946 // For non-container operations, use parent implementation
947 return parent::execute( $method, $url, $query, $formFields, $json, $extraHeaders, $streamCallback );
948 }
949
950 /**
951 * Override build_options to add Azure-specific headers for Responses API
952 */
953 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
954 // Add Azure-specific headers if using Azure with Responses API
955 if ( $this->envType === 'azure' && !empty( $json ) ) {
956 // Check if image_generation tool is present
957 if ( isset( $json['tools'] ) && is_array( $json['tools'] ) ) {
958 foreach ( $json['tools'] as $tool ) {
959 if ( isset( $tool['type'] ) && $tool['type'] === 'image_generation' ) {
960 // For Azure, add the image generation deployment header
961 // Look for an image deployment in the Azure deployments
962 if ( isset( $this->env['deployments'] ) && is_array( $this->env['deployments'] ) ) {
963 foreach ( $this->env['deployments'] as $deployment ) {
964 // Check if this is a gpt-image model deployment
965 if ( isset( $deployment['model'] ) && strpos( $deployment['model'], 'gpt-image' ) === 0 && isset( $deployment['name'] ) ) {
966 $headers['x-ms-oai-image-generation-deployment'] = $deployment['name'];
967 Meow_MWAI_Logging::log( 'Responses API: Added Azure image generation deployment header: ' . $deployment['name'] );
968 break;
969 }
970 }
971 }
972 break;
973 }
974 }
975 }
976 }
977
978 // Call parent's build_options
979 return parent::build_options( $headers, $json, $forms, $method );
980 }
981
982 /**
983 * Handle Responses API streaming data
984 */
985 protected function responses_stream_data_handler( $json ) {
986 $content = null;
987 static $currentItemType = null; // Track the current output item type
988 // Load event helper
989 if ( !class_exists( 'Meow_MWAI_Event' ) ) {
990 require_once MWAI_PATH . '/classes/event.php';
991 }
992
993 // Get response metadata
994 if ( isset( $json['id'] ) ) {
995 $this->inId = $json['id'];
996 Meow_MWAI_Logging::log( 'Responses API Streaming: Found response ID in stream: ' . $this->inId );
997 }
998 if ( isset( $json['model'] ) ) {
999 $this->inModel = $json['model'];
1000 }
1001
1002 // Handle different event types for Responses API
1003 $eventType = $json['type'] ?? null;
1004
1005 // Debug streaming events
1006 if ( isset( $_GET['debug_mcp'] ) ) {
1007 error_log( 'AI_ENGINE_DEBUG: Streaming type: ' . ( $eventType ?? 'no_type' ) . ' - Data: ' . json_encode( $json ) );
1008 }
1009
1010 switch ( $eventType ) {
1011 // ===== LIFECYCLE EVENTS =====
1012
1013 case 'response.created':
1014 // Emitted when a response object is created - contains initial response metadata
1015 $response = $json['response'] ?? [];
1016 $this->inId = $response['id'] ?? null;
1017 $this->inModel = $response['model'] ?? null;
1018 if ( $this->inId ) {
1019 }
1020 break;
1021
1022 case 'response.queued':
1023 // Response is queued and waiting to start processing
1024 // We can log this for debugging purposes
1025 Meow_MWAI_Logging::log( 'Responses API: Response queued for processing' );
1026 break;
1027
1028 case 'response.in_progress':
1029 // Emitted repeatedly while the response is being generated
1030 // Contains partial response state but typically not used for streaming text
1031 break;
1032
1033 case 'response.completed':
1034 // Response is fully generated - extract any function calls from completed output
1035 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1036 error_log( '[AI Engine Queries] Current streamToolCalls count: ' . count( $this->streamToolCalls ) );
1037 }
1038
1039 $response = $json['response'] ?? [];
1040
1041 // Extract usage information from response.completed event
1042 if ( isset( $response['usage'] ) ) {
1043 $usage = $response['usage'];
1044
1045 // Set stream tokens from usage data
1046 // Responses API uses input_tokens/output_tokens
1047 $inputTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? null;
1048 $outputTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? null;
1049
1050 if ( $inputTokens !== null ) {
1051 $this->streamInTokens = (int) $inputTokens;
1052 }
1053 if ( $outputTokens !== null ) {
1054 $this->streamOutTokens = (int) $outputTokens;
1055 }
1056 if ( isset( $usage['cost'] ) ) {
1057 $this->streamCost = (float) $usage['cost'];
1058 }
1059 }
1060
1061 $outputs = $response['output'] ?? [];
1062
1063 foreach ( $outputs as $idx => $output ) {
1064 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1065 error_log( '[AI Engine Queries] Output ' . $idx . ' type: ' . ( $output['type'] ?? 'unknown' ) . ', status: ' . ( $output['status'] ?? 'no-status' ) );
1066 }
1067
1068 if ( isset( $output['type'] ) && $output['type'] === 'function_call' &&
1069 isset( $output['status'] ) && $output['status'] === 'completed' ) {
1070 // Note: Responses API uses 'call_id' not 'id' for function calls
1071 $callId = $output['call_id'] ?? $output['id'] ?? null;
1072 $functionName = $output['name'] ?? '';
1073
1074 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1075 error_log( '[AI Engine Queries] Processing function_call: ' . $functionName . ' (id: ' . $callId . ')' );
1076 }
1077
1078 // IMPORTANT: Deduplicate function calls
1079 // OpenAI sends the same function call in both response.output_item.done
1080 // and response.completed events. We track call IDs to avoid duplicates.
1081 if ( in_array( $callId, $this->seenCallIds, true ) ) {
1082 // Skip duplicate - already processed in response.output_item.done
1083 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1084 error_log( '[AI Engine Queries] Skipping duplicate call ID: ' . $callId );
1085 }
1086 continue;
1087 }
1088
1089 // First time seeing this call ID - add it
1090 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1091 error_log( '[AI Engine Queries] response.completed adding tool call: ' . $functionName . ' (id: ' . $callId . ')' );
1092 }
1093 $this->seenCallIds[] = $callId;
1094 $this->streamToolCalls[] = [
1095 'id' => $callId,
1096 'type' => 'function',
1097 'function' => [
1098 'name' => $functionName,
1099 'arguments' => $output['arguments'] ?? '{}'
1100 ]
1101 ];
1102 }
1103 }
1104 break;
1105
1106 case 'response.incomplete':
1107 // Response stopped before completion (e.g., max_tokens reached)
1108 $details = $json['response']['incomplete_details'] ?? [];
1109 Meow_MWAI_Logging::warn( 'Responses API: Response incomplete - ' . json_encode( $details ) );
1110 break;
1111
1112 case 'response.failed':
1113 // Response generation failed
1114 $error = $json['response']['error'] ?? [];
1115 $message = $error['message'] ?? 'Response generation failed';
1116 throw new Exception( $message );
1117
1118 // ===== OUTPUT ITEM EVENTS =====
1119
1120 case 'response.output_item.added':
1121 // New output item added (e.g., message, function_call, etc.)
1122 // Track the type of the current output item
1123 if ( isset( $json['item'] ) && isset( $json['item']['type'] ) ) {
1124 $item = $json['item'];
1125 $itemType = $item['type'];
1126 $currentItemType = $itemType;
1127
1128 // Code interpreter items are handled in event processing
1129
1130 // Don't emit events here for web search or image generation - wait for more specific events
1131 // This prevents duplicate events
1132
1133 // If it's an MCP call, store the tool name
1134 if ( $itemType === 'mcp_call' && isset( $item['id'] ) && isset( $item['name'] ) ) {
1135 $this->mcpToolNames[$item['id']] = $item['name'];
1136 Meow_MWAI_Logging::log( 'Responses API: MCP tool call added - ' . $item['name'] . ' (id: ' . $item['id'] . ')' );
1137
1138 if ( $this->currentDebugMode ) {
1139 $event = Meow_MWAI_Event::mcp_calling( $item['name'], $item['id'] )
1140 ->set_metadata( 'name', $item['name'] )
1141 ->set_metadata( 'server_label', $item['server_label'] ?? null );
1142 call_user_func( $this->streamCallback, $event );
1143 }
1144 }
1145 }
1146 break;
1147
1148 case 'response.output_item.done':
1149 // Output item completed - check for MCP approval requests or tool lists
1150 if ( isset( $json['item'] ) && isset( $json['item']['type'] ) ) {
1151 $item = $json['item'];
1152 $itemType = $item['type'];
1153
1154 // Reset current item type when we complete a message item
1155 if ( $itemType === 'message' ) {
1156 $currentItemType = null;
1157 }
1158
1159 if ( $itemType === 'function_call' ) {
1160 // Regular function call completed - send event
1161 if ( $this->currentDebugMode && $this->streamCallback ) {
1162 $event = Meow_MWAI_Event::function_calling( $item['name'] ?? 'unknown', json_decode( $item['arguments'] ?? '{}', true ) )
1163 ->set_metadata( 'call_id', $item['call_id'] ?? null );
1164 call_user_func( $this->streamCallback, $event );
1165 }
1166
1167 // Add to streamToolCalls for execution
1168 // Note: Responses API uses 'call_id' not 'id' for function calls
1169 $callId = $item['call_id'] ?? $item['id'] ?? null;
1170 $functionName = $item['name'] ?? '';
1171
1172 // Add to our deduplication tracking
1173 // We process function calls here as they complete individually during streaming
1174 // The response.completed event will also try to add them, so we track IDs
1175 if ( !in_array( $callId, $this->seenCallIds, true ) ) {
1176 $this->seenCallIds[] = $callId;
1177
1178 $this->streamToolCalls[] = [
1179 'id' => $callId,
1180 'type' => 'function',
1181 'function' => [
1182 'name' => $functionName,
1183 'arguments' => $item['arguments'] ?? '{}'
1184 ]
1185 ];
1186 }
1187 }
1188 elseif ( $itemType === 'mcp_approval_request' ) {
1189 // IMPORTANT: MCP (Model Context Protocol) tools are executed remotely by OpenAI
1190 // Unlike regular function calls, MCP tools do NOT need local execution
1191 // Therefore, we should NOT add them to streamToolCalls array
1192 // This prevents creation of unnecessary feedback queries and second response cycles
1193 Meow_MWAI_Logging::log( 'Responses API: MCP approval request for ' . $item['name'] . ' from server ' . $item['server_label'] . ' (handled remotely)' );
1194 }
1195 elseif ( $item['type'] === 'mcp_call' ) {
1196 // IMPORTANT: MCP calls are already executed remotely by OpenAI's infrastructure
1197 // The result is included in the same response stream
1198 // We must NOT add these to streamToolCalls to avoid duplicate execution attempts
1199 Meow_MWAI_Logging::log( 'Responses API: MCP call completed - ' . $item['name'] . ' (already executed remotely)' );
1200
1201 // Send event for completed MCP call when debug is enabled
1202 if ( $this->currentDebugMode && isset( $item['name'] ) ) {
1203 $args = json_decode( $item['arguments'] ?? '{}', true );
1204 $output = $item['output'] ?? null;
1205
1206 // Skip the tool_call event for MCP calls since we already sent mcp_tool_call
1207 // This prevents duplicate events in the UI
1208
1209 // Then send a separate event for the tool result
1210 if ( $output ) {
1211 // Format the output preview
1212 $outputPreview = is_array( $output ) ? json_encode( $output ) : (string) $output;
1213 if ( strlen( $outputPreview ) > 100 ) {
1214 $outputPreview = substr( $outputPreview, 0, 100 ) . '...';
1215 }
1216
1217 $resultEvent = Meow_MWAI_Event::mcp_result( $item['name'] )
1218 ->set_metadata( 'output', $output );
1219 call_user_func( $this->streamCallback, $resultEvent );
1220 }
1221
1222 // Don't return content since we've already sent events
1223 $content = null;
1224 }
1225 }
1226 elseif ( $itemType === 'web_search_call' ) {
1227 // Web search completed - don't emit event here
1228 // The event will be emitted by the response.web_search_call.completed handler
1229 // This prevents duplicate events
1230 Meow_MWAI_Logging::log( 'Responses API: Web search output item completed (event handled by specific handler)' );
1231 }
1232 elseif ( $itemType === 'code_interpreter_call' ) {
1233 // Code interpreter completed
1234 Meow_MWAI_Logging::log( 'Responses API: Code interpreter output item completed' );
1235
1236 // Store container ID if available
1237 if ( isset( $item['container_id'] ) ) {
1238 $this->streamContainerId = $item['container_id'];
1239 Meow_MWAI_Logging::log( 'Responses API: Found container_id in streaming: ' . $this->streamContainerId );
1240 }
1241
1242 // Check for files in the result
1243 if ( isset( $item['result'] ) ) {
1244 $result = $item['result'];
1245
1246 // Look for files in the result
1247 if ( isset( $result['files'] ) ) {
1248 // Store these files
1249 if ( !isset( $this->streamCodeInterpreterFiles ) ) {
1250 $this->streamCodeInterpreterFiles = [];
1251 }
1252
1253 foreach ( $result['files'] as $file ) {
1254 $this->streamCodeInterpreterFiles[] = $file;
1255 Meow_MWAI_Logging::log( 'Responses API: Captured file from result: ' . ( $file['filename'] ?? $file['id'] ?? 'unknown' ) );
1256 }
1257 }
1258
1259 // Handle standard output
1260 if ( isset( $result['stdout'] ) && !empty( $result['stdout'] ) ) {
1261 // Add code output to the response content
1262 $content = "\n```\n" . $result['stdout'] . "\n```\n";
1263 Meow_MWAI_Logging::log( 'Responses API: Code interpreter stdout: ' . substr( $result['stdout'], 0, 100 ) );
1264 }
1265 }
1266 }
1267 elseif ( $itemType === 'image_generation_call' ) {
1268 // Image generation completed
1269 Meow_MWAI_Logging::log( 'Responses API: Image generation output item completed' );
1270
1271 // Extract the base64 image from the result
1272 if ( isset( $item['result'] ) ) {
1273 $base64Image = $item['result'];
1274
1275 // Store the image for later processing
1276 if ( !isset( $this->streamImages ) ) {
1277 $this->streamImages = [];
1278 }
1279
1280 $this->streamImages[] = $base64Image;
1281
1282 Meow_MWAI_Logging::log( 'Responses API: Stored generated image (base64 length: ' . strlen( $base64Image ) . ')' );
1283 }
1284 }
1285 elseif ( $item['type'] === 'mcp_list_tools' ) {
1286 // MCP tools list discovered
1287 $server_label = $item['server_label'] ?? 'unknown';
1288 $tools_count = isset( $item['tools'] ) ? count( $item['tools'] ) : 0;
1289 $this->mcpTotalToolCount += $tools_count;
1290 Meow_MWAI_Logging::log( 'Responses API: MCP tools list from server ' . $server_label . ' containing ' . $tools_count . ' tools' );
1291
1292 // Send event for tools discovery using the aggregated format
1293 if ( $this->currentDebugMode ) {
1294 $serverCount = $this->mcpServerCount > 0 ? $this->mcpServerCount : 1;
1295 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $this->mcpTotalToolCount );
1296 call_user_func( $this->streamCallback, $event );
1297 }
1298
1299 // Log first few tools for debugging
1300 if ( isset( $item['tools'] ) && is_array( $item['tools'] ) ) {
1301 $sample_tools = array_slice( $item['tools'], 0, 3 );
1302 foreach ( $sample_tools as $tool ) {
1303 Meow_MWAI_Logging::log( 'Responses API: MCP tool "' . ( $tool['name'] ?? 'unnamed' ) . '": ' . ( $tool['description'] ?? 'no description' ) );
1304 }
1305 if ( $tools_count > 3 ) {
1306 Meow_MWAI_Logging::log( 'Responses API: ... and ' . ( $tools_count - 3 ) . ' more tools' );
1307 }
1308 }
1309 }
1310 }
1311 break;
1312
1313 // ===== CONTENT PART EVENTS =====
1314
1315 case 'response.content_part.added':
1316 // New content part added to an output item
1317 // Indicates start of a new content section (text, image, etc.)
1318 // Check if this is MCP-related content that shouldn't be shown
1319 if ( isset( $json['part']['type'] ) ) {
1320 $partType = $json['part']['type'];
1321
1322 // Just log the part type for debugging
1323 // We can use this info later if needed
1324 }
1325 break;
1326
1327 case 'response.content_part.done':
1328 // Content part is finalized
1329 // No more deltas will be sent for this content part
1330 break;
1331
1332 // ===== TEXT STREAMING EVENTS =====
1333
1334 case 'response.output_text.delta':
1335 // Streaming text chunk for the current content part
1336 if ( isset( $json['delta'] ) ) {
1337 // Send a status event for the first content chunk
1338 if ( $this->currentDebugMode && !$this->contentStarted ) {
1339 $this->contentStarted = true;
1340 $statusEvent = Meow_MWAI_Event::generating_response();
1341 call_user_func( $this->streamCallback, $statusEvent );
1342 }
1343 $content = $json['delta'];
1344 }
1345 break;
1346
1347 case 'response.output_text.done':
1348 // Final text for the content part
1349 // Contains the complete accumulated text
1350 // Don't send response_completed here - ChatbotContext adds "Request completed"
1351 $this->contentStarted = false;
1352 break;
1353
1354 case 'response.refusal.delta':
1355 // Streaming refusal message chunk
1356 // Model is refusing to generate the requested content
1357 if ( isset( $json['delta'] ) ) {
1358 // We might want to stream refusals as regular content
1359 $content = $json['delta'];
1360 }
1361 break;
1362
1363 case 'response.refusal.done':
1364 // Final refusal message
1365 // Contains the complete refusal reason
1366 break;
1367
1368 case 'response.function_call_arguments.delta':
1369 // Streaming JSON arguments for a function call
1370 // We don't stream these to UI as they're not human-readable
1371 break;
1372
1373 case 'response.function_call_arguments.done':
1374 // Complete function call arguments
1375 // Already handled in response.output_item.done for function_call type
1376 break;
1377
1378 // ===== FILE & WEB SEARCH EVENTS =====
1379
1380 case 'response.file_search_call.in_progress':
1381 // File search started
1382 Meow_MWAI_Logging::log( 'Responses API: File search in progress' );
1383 break;
1384
1385 case 'response.file_search_call.searching':
1386 // Actively searching files
1387 break;
1388
1389 case 'response.file_search_call.completed':
1390 // File search finished
1391 break;
1392
1393 case 'response.web_search_call.in_progress':
1394 // Web search started - only emit one event at the start
1395 Meow_MWAI_Logging::log( 'Responses API: Web search in progress' );
1396 if ( $this->currentDebugMode && $this->streamCallback ) {
1397 $event = Meow_MWAI_Event::status( 'Searching the web...' );
1398 call_user_func( $this->streamCallback, $event );
1399 }
1400 break;
1401
1402 case 'response.web_search_call.searching':
1403 // Actively searching - don't emit duplicate events
1404 if ( isset( $json['query'] ) ) {
1405 Meow_MWAI_Logging::log( 'Responses API: Searching for: ' . $json['query'] );
1406 }
1407 break;
1408
1409 case 'response.web_search_call.completed':
1410 // Web search finished
1411 Meow_MWAI_Logging::log( 'Responses API: Web search completed' );
1412
1413 // The completed event doesn't contain results, just metadata
1414 // Results are likely embedded in the model's response text
1415 if ( $this->currentDebugMode && $this->streamCallback ) {
1416 $message = 'Web search completed';
1417 $event = Meow_MWAI_Event::status( $message );
1418 call_user_func( $this->streamCallback, $event );
1419 }
1420 break;
1421
1422 // ===== IMAGE GENERATION EVENTS =====
1423
1424 case 'response.image_generation_call.in_progress':
1425 // Image generation started
1426 Meow_MWAI_Logging::log( 'Responses API: Image generation in progress' );
1427 if ( $this->currentDebugMode && $this->streamCallback ) {
1428 $event = Meow_MWAI_Event::status( 'Generating image...' );
1429 call_user_func( $this->streamCallback, $event );
1430 }
1431 break;
1432
1433 case 'response.image_generation_call.generating':
1434 // Image is being generated
1435 break;
1436
1437 case 'response.image_generation_call.partial_image':
1438 // Partial image data (base64)
1439 // Could be used for progressive image display
1440 if ( isset( $json['partial_image_b64'] ) ) {
1441 Meow_MWAI_Logging::log( 'Responses API: Received partial image index ' . ( $json['partial_image_index'] ?? 'unknown' ) );
1442 // For now, we don't display partial images, but we could in the future
1443 }
1444 break;
1445
1446 case 'response.image_generation_call.completed':
1447 // Image generation finished
1448 Meow_MWAI_Logging::log( 'Responses API: Image generation completed' );
1449
1450 // Note: The actual image data comes in response.output_item.done event
1451 // This event just signals completion
1452
1453 if ( $this->currentDebugMode && $this->streamCallback ) {
1454 $event = Meow_MWAI_Event::status( 'Image generated.' );
1455 call_user_func( $this->streamCallback, $event );
1456 }
1457 break;
1458
1459 // ===== CODE INTERPRETER EVENTS =====
1460
1461 case 'response.code_interpreter_call.in_progress':
1462 // Code interpreter started
1463
1464 // Check for container_id in the event
1465 if ( isset( $json['container_id'] ) ) {
1466 $this->streamContainerId = $json['container_id'];
1467 error_log( '[AI Engine] Found container_id in code_interpreter_call.in_progress: ' . $this->streamContainerId );
1468 }
1469
1470 // Also check in item if present
1471 if ( isset( $json['item']['container_id'] ) ) {
1472 $this->streamContainerId = $json['item']['container_id'];
1473 error_log( '[AI Engine] Found container_id in item: ' . $this->streamContainerId );
1474 }
1475
1476 // Container ID captured if available
1477 break;
1478
1479 case 'response.code_interpreter_call.running':
1480 // Code is being executed
1481 // Check for container_id here too
1482 if ( isset( $json['container_id'] ) ) {
1483 $this->streamContainerId = $json['container_id'];
1484 Meow_MWAI_Logging::log( 'Responses API: Found container_id in running event: ' . $this->streamContainerId );
1485 }
1486 break;
1487
1488 case 'response.code_interpreter_call.stdout':
1489 // Standard output from code execution
1490 if ( isset( $json['stdout'] ) ) {
1491 Meow_MWAI_Logging::log( 'Responses API: Code output - ' . substr( $json['stdout'], 0, 100 ) );
1492 }
1493 break;
1494
1495 case 'response.code_interpreter_call.stderr':
1496 // Standard error from code execution
1497 if ( isset( $json['stderr'] ) ) {
1498 Meow_MWAI_Logging::log( 'Responses API: Code error - ' . $json['stderr'] );
1499 }
1500 break;
1501
1502 case 'response.code_interpreter_call.completed':
1503 // Code interpreter finished - files are now ready for download
1504 Meow_MWAI_Logging::log( 'Responses API: Code interpreter completed' );
1505
1506 // Check for container_id in completed event
1507 if ( isset( $json['container_id'] ) ) {
1508 $this->streamContainerId = $json['container_id'];
1509 Meow_MWAI_Logging::log( 'Responses API: Container ID: ' . $this->streamContainerId );
1510 }
1511
1512 // Mark that code interpreter has completed
1513 $this->codeInterpreterCompleted = true;
1514
1515 // Send CODE event to client
1516 if ( $this->currentDebugMode && $this->streamCallback ) {
1517 $codeEvent = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['CODE'] ) )
1518 ->set_content( 'Code execution completed.' );
1519 call_user_func( $this->streamCallback, $codeEvent );
1520 }
1521 break;
1522
1523 case 'response.code_interpreter_call_code.delta':
1524 // Streaming code being written/executed by the code interpreter
1525 // This should NOT be added to the main content
1526 if ( isset( $json['delta'] ) ) {
1527 // Send CODE event only for the first code delta
1528 if ( empty( $this->streamContentCode ) && $this->currentDebugMode && $this->streamCallback ) {
1529 $codeEvent = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['CODE'] ) )
1530 ->set_content( 'Writing code...' );
1531 call_user_func( $this->streamCallback, $codeEvent );
1532 }
1533
1534 // Accumulate code in streamContentCode instead of content
1535 $this->streamContentCode .= $json['delta'];
1536
1537 Meow_MWAI_Logging::log( 'Responses API: Code interpreter code delta - ' . substr( $json['delta'], 0, 100 ) );
1538 }
1539 // Important: Don't return any content here so it's not added to streamContent
1540 return null;
1541
1542 case 'response.code_interpreter_call_code.done':
1543 // Code interpreter code writing completed
1544 if ( !empty( $this->streamContentCode ) && $this->currentDebugMode && $this->streamCallback ) {
1545 $lines = substr_count( $this->streamContentCode, "\n" ) + 1;
1546
1547 // Send the complete code as a collapsed CODE event
1548 // Set summary as content (shown when collapsed) and full code as metadata (shown when expanded)
1549 $codeEvent = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['CODE'] ) )
1550 ->set_content( "Wrote Python code ($lines lines)" )
1551 ->set_visibility( MWAI_STREAM_VISIBILITY['COLLAPSED'] )
1552 ->set_metadata( 'full_code', $this->streamContentCode );
1553 call_user_func( $this->streamCallback, $codeEvent );
1554
1555 Meow_MWAI_Logging::log( 'Responses API: Code interpreter code completed - ' . strlen( $this->streamContentCode ) . ' bytes' );
1556 }
1557 break;
1558
1559 case 'response.code_interpreter_file_citation':
1560 case 'code_interpreter_file_citation':
1561 // Code interpreter has created or cited a file
1562 // This event contains the file_id for files generated during code execution
1563 if ( isset( $json['file_id'] ) ) {
1564 if ( !isset( $this->streamCodeInterpreterFiles ) ) {
1565 $this->streamCodeInterpreterFiles = [];
1566 }
1567 $file_info = [
1568 'file_id' => $json['file_id'],
1569 'filename' => $json['filename'] ?? null,
1570 'file_type' => $json['file_type'] ?? null,
1571 'path' => isset( $json['path'] ) ? $json['path'] : ( isset( $json['filename'] ) ? '/mnt/data/' . $json['filename'] : null )
1572 ];
1573 $this->streamCodeInterpreterFiles[] = $file_info;
1574 error_log( '[AI Engine] File citation captured: ' . json_encode( $file_info ) );
1575 Meow_MWAI_Logging::log( 'Responses API: Code interpreter file citation - file_id: ' . $json['file_id'] );
1576 }
1577 break;
1578
1579 // ===== MCP (Model Context Protocol) EVENTS =====
1580
1581 case 'response.mcp_call.in_progress':
1582 // MCP tool call is running
1583 $itemId = $json['item_id'] ?? null;
1584 $toolName = isset( $this->mcpToolNames[$itemId] ) ? $this->mcpToolNames[$itemId] : 'unknown';
1585
1586 Meow_MWAI_Logging::log( 'Responses API: MCP tool call in progress - ' . $toolName );
1587 break;
1588
1589 case 'response.mcp_call.arguments.delta':
1590 case 'response.mcp_call_arguments.delta':
1591 // Streaming arguments for MCP tool call
1592 // Don't stream these JSON arguments to the UI
1593 // These contain the function parameters like {"post_type":"post",...}
1594 break;
1595
1596 case 'response.mcp_call.arguments.done':
1597 case 'response.mcp_call_arguments.done':
1598 // Complete arguments for MCP tool call
1599 break;
1600
1601 case 'response.mcp_call.completed':
1602 // MCP tool call succeeded
1603 break;
1604
1605 case 'response.mcp_call.failed':
1606 // MCP tool call failed
1607 $error = $json['error'] ?? [];
1608 Meow_MWAI_Logging::error( 'Responses API: MCP tool call failed - ' . ( $error['message'] ?? 'Unknown error' ) );
1609 break;
1610
1611 case 'response.mcp_list_tools.in_progress':
1612 // Listing MCP tools has started
1613 Meow_MWAI_Logging::log( 'Responses API: MCP tools discovery in progress' );
1614 break;
1615
1616 case 'response.mcp_list_tools.completed':
1617 // MCP tools listing completed successfully
1618 break;
1619
1620 case 'response.mcp_list_tools.failed':
1621 // MCP tools listing failed
1622 $error = $json['error'] ?? [];
1623 $message = 'MCP tools listing failed: ' . ( $error['message'] ?? 'Unknown error' );
1624 Meow_MWAI_Logging::error( 'Responses API: ' . $message );
1625 throw new Exception( $message );
1626 break;
1627
1628 // ===== REASONING EVENTS (for o1/o3 models) =====
1629
1630 case 'response.reasoning.delta':
1631 // Streaming reasoning text chunk
1632 // Internal reasoning process of the model
1633 break;
1634
1635 case 'response.reasoning.done':
1636 // Complete reasoning text
1637 break;
1638
1639 case 'response.reasoning_summary_part.added':
1640 // New reasoning summary part added
1641 break;
1642
1643 case 'response.reasoning_summary_part.done':
1644 // Reasoning summary part completed
1645 break;
1646
1647 case 'response.reasoning_summary_text.delta':
1648 // Streaming reasoning summary text
1649 break;
1650
1651 case 'response.reasoning_summary_text.done':
1652 // Complete reasoning summary
1653 break;
1654
1655 // ===== ANNOTATION EVENTS =====
1656
1657 case 'response.output_text_annotation.added':
1658 case 'response.output_text.annotation.added':
1659 // Text annotation added - check for container file citations
1660 if ( isset( $json['annotation'] ) ) {
1661 $annotation = $json['annotation'];
1662
1663 // Check if this is a container file citation
1664 if ( isset( $annotation['type'] ) && $annotation['type'] === 'container_file_citation' ) {
1665 // Initialize files array if needed
1666 if ( !isset( $this->streamCodeInterpreterFiles ) ) {
1667 $this->streamCodeInterpreterFiles = [];
1668 }
1669
1670 // Extract file information
1671 $fileInfo = [
1672 'file_id' => $annotation['file_id'] ?? null,
1673 'filename' => $annotation['filename'] ?? null,
1674 'container_id' => $annotation['container_id'] ?? null
1675 ];
1676
1677 // Store the file info if we have a file_id
1678 if ( $fileInfo['file_id'] ) {
1679 $this->streamCodeInterpreterFiles[] = $fileInfo;
1680
1681 // Also store container ID if available
1682 if ( $fileInfo['container_id'] && !$this->streamContainerId ) {
1683 $this->streamContainerId = $fileInfo['container_id'];
1684 }
1685
1686 Meow_MWAI_Logging::log( 'Responses API: File citation - ' . $fileInfo['filename'] . ' (' . $fileInfo['file_id'] . ')' );
1687 }
1688 }
1689 }
1690 break;
1691
1692 case 'response.completed':
1693 // Response fully completed - function calls are already handled in response.output_item.done
1694 break;
1695
1696 // ===== ERROR EVENTS =====
1697
1698 case 'error':
1699 // Generic error event
1700 $error = $json['error'] ?? $json;
1701 $message = $error['message'] ?? 'Unknown error occurred';
1702 $code = $error['code'] ?? null;
1703 if ( $code ) {
1704 $message .= " (Code: $code)";
1705 }
1706 throw new Exception( $message );
1707
1708 default:
1709 // Unknown event type - log for debugging
1710 Meow_MWAI_Logging::log( 'Responses API: Unknown event type: ' . $eventType );
1711
1712 // Check if this might be a different streaming format
1713 if ( isset( $json['delta'] ) && is_string( $json['delta'] ) ) {
1714 $content = $json['delta'];
1715 }
1716 elseif ( isset( $json['content'] ) && is_string( $json['content'] ) ) {
1717 $content = $json['content'];
1718 }
1719 }
1720
1721 // Handle usage data (legacy - kept for Chat Completions API compatibility)
1722 // Note: Responses API sets usage in response.completed event instead
1723 $usage = $json['usage'] ?? [];
1724 $inputTokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? null;
1725 $outputTokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? null;
1726
1727 if ( $inputTokens !== null && $outputTokens !== null ) {
1728 $this->streamInTokens = (int) $inputTokens;
1729 $this->streamOutTokens = (int) $outputTokens;
1730 if ( isset( $usage['cost'] ) ) {
1731 $this->streamCost = (float) $usage['cost'];
1732 }
1733 }
1734
1735 return $content;
1736 }
1737
1738 /**
1739 * Override stream data handler to support both APIs
1740 */
1741 protected function stream_data_handler( $json ) {
1742 // Check if this is a Responses API event (uses 'type' field)
1743 if ( isset( $json['type'] ) && strpos( $json['type'], 'response.' ) === 0 ) {
1744 return $this->responses_stream_data_handler( $json );
1745 }
1746
1747 // Fallback to ChatML handler
1748 return parent::stream_data_handler( $json );
1749 }
1750
1751 /**
1752 * Override reset to include OpenAI-specific state
1753 */
1754 protected function reset_request_state() {
1755 parent::reset_request_state();
1756
1757 // Reset OpenAI-specific state
1758 $this->streamImages = [];
1759 $this->streamContentCode = '';
1760 $this->streamContainerId = null;
1761 $this->streamCodeInterpreterFiles = [];
1762 $this->codeInterpreterCompleted = false;
1763 }
1764
1765 /**
1766 * Override run_completion_query to route to appropriate API
1767 */
1768 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1769 // Reset request-specific state to prevent leakage between requests
1770 $this->reset_request_state();
1771
1772 // Store current query for should_use_responses_api check
1773 $this->currentQuery = $query;
1774
1775 // Check if this is a GPT-5 model
1776 $isGpt5Model = strpos( $query->model, 'gpt-5' ) === 0;
1777
1778 // Check which API to use
1779 $useResponsesApi = $this->should_use_responses_api( $query->model );
1780
1781 // GPT-5 models MUST use Responses API
1782 if ( $isGpt5Model && !$useResponsesApi ) {
1783 $options = $this->core->get_all_options();
1784 $responsesApiEnabled = $options['ai_responses_api'] ?? true;
1785
1786 if ( !$responsesApiEnabled ) {
1787 throw new Exception( 'GPT-5 models require the Responses API to be enabled. Please enable "Use Responses API" in AI Engine settings.' );
1788 }
1789
1790 // If Responses API is enabled but model doesn't have the tag, force it
1791 return $this->run_responses_completion_query( $query, $streamCallback );
1792 }
1793
1794 // Check if we should use Responses API
1795 if ( $useResponsesApi ) {
1796 return $this->run_responses_completion_query( $query, $streamCallback );
1797 }
1798
1799 // Fallback to ChatML implementation
1800 $reply = parent::run_completion_query( $query, $streamCallback );
1801
1802 // Check for empty output when reasoning is enabled (GPT-5 models)
1803 // This safety check is here in case GPT-5 somehow uses the regular API
1804 if ( strpos( $query->model, 'gpt-5' ) === 0 && !empty( $query->reasoning ) ) {
1805 if ( empty( $reply->result ) || trim( $reply->result ) === '' ) {
1806 if ( empty( $reply->needFeedbacks ) && empty( $reply->needClientActions ) ) {
1807 throw new Exception(
1808 'The model returned an empty response. This typically happens when reasoning consumes all available tokens. ' .
1809 'Please increase the Max Tokens setting to allow space for both reasoning and the actual response. ' .
1810 'Current Max Tokens: ' . ( $query->maxTokens ?? 'default' ) . '. ' .
1811 'Try setting it to at least ' . ( ( $query->maxTokens ?? 4096 ) + 2000 ) . ' tokens.'
1812 );
1813 }
1814 }
1815 }
1816
1817 return $reply;
1818 }
1819
1820 /**
1821 * Run completion query using Responses API
1822 */
1823 protected function run_responses_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1824 // Store current query for URL building (needed for Azure deployment name)
1825 $this->currentQuery = $query;
1826
1827 // Check if we have functions that might require feedback
1828 $hasFunctions = !empty( $query->functions );
1829
1830 $isStreaming = !is_null( $streamCallback );
1831
1832 // Initialize debug mode
1833 $this->init_debug_mode( $query );
1834
1835 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
1836 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
1837 $this->prepare_query( $query );
1838
1839 if ( $isStreaming ) {
1840 $this->streamCallback = $streamCallback;
1841 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1842 }
1843
1844 $this->reset_stream();
1845 $body = $this->build_responses_body( $query, $streamCallback );
1846 $url = $this->build_responses_url();
1847 $headers = $this->build_headers( $query );
1848 $options = $this->build_options( $headers, $body );
1849
1850 // Store the request body for debugging
1851 $this->lastRequestBody = $body;
1852
1853 // Debug log for Responses API
1854 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
1855 if ( $queries_debug ) {
1856 error_log( '[AI Engine Queries] Using Responses API' );
1857 error_log( '[AI Engine Queries] Request URL: ' . $url );
1858 error_log( '[AI Engine Queries] Request Body: ' . json_encode( $body, JSON_PRETTY_PRINT ) );
1859
1860 // Log specific tool information
1861 if ( isset( $body['tools'] ) && is_array( $body['tools'] ) ) {
1862 error_log( '[AI Engine Queries] Tools included in request:' );
1863 foreach ( $body['tools'] as $index => $tool ) {
1864 $toolInfo = 'Tool ' . $index . ': type=' . ( $tool['type'] ?? 'unknown' );
1865 if ( $tool['type'] === 'file_search' && isset( $tool['vector_store_ids'] ) ) {
1866 $toolInfo .= ', vector_store_ids=' . json_encode( $tool['vector_store_ids'] );
1867 }
1868 error_log( '[AI Engine Queries] - ' . $toolInfo );
1869 }
1870 }
1871 else {
1872 error_log( '[AI Engine Queries] No tools included in request' );
1873 }
1874 }
1875
1876 // Emit "Request sent" event for feedback queries
1877 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1878 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1879 $event = Meow_MWAI_Event::request_sent()
1880 ->set_metadata( 'is_feedback', true )
1881 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1882 call_user_func( $streamCallback, $event );
1883 }
1884
1885 try {
1886 // Log the input being sent for feedback queries
1887 if ( $queries_debug && $query instanceof Meow_MWAI_Query_Feedback && isset( $body['input'] ) ) {
1888 error_log( '[AI Engine Queries] Sending feedback with ' . count( $body['input'] ) . ' messages to Responses API' );
1889 error_log( '[AI Engine Queries] Previous Response ID: ' . ( $body['previous_response_id'] ?? 'none' ) );
1890 foreach ( $body['input'] as $idx => $msg ) {
1891 $msgType = is_array( $msg ) && isset( $msg['type'] ) ? $msg['type'] : 'unknown';
1892 $callId = is_array( $msg ) && isset( $msg['call_id'] ) ? $msg['call_id'] : 'no-id';
1893 error_log( '[AI Engine Queries] Message ' . $idx . ': type=' . $msgType . ', call_id=' . $callId );
1894 if ( $msgType === 'function_call' && isset( $msg['name'] ) ) {
1895 error_log( '[AI Engine Queries] Function name: ' . $msg['name'] );
1896 }
1897 if ( $msgType === 'function_call_output' && isset( $msg['output'] ) ) {
1898 error_log( '[AI Engine Queries] Output: ' . substr( $msg['output'], 0, 50 ) . '...' );
1899 }
1900 }
1901 }
1902
1903 $res = $this->run_query( $url, $options, $streamCallback );
1904 $reply = new Meow_MWAI_Reply( $query );
1905
1906 $returned_id = null;
1907 $returned_model = $this->inModel;
1908 $returned_in_tokens = null;
1909 $returned_out_tokens = null;
1910 $returned_price = null;
1911 $returned_choices = [];
1912
1913 // Streaming Mode
1914 if ( $isStreaming ) {
1915 if ( empty( $this->streamContent ) ) {
1916 $error = $this->try_decode_error( $this->streamBuffer );
1917 if ( !is_null( $error ) ) {
1918 throw new Exception( $error );
1919 }
1920 }
1921
1922 $returned_id = $this->inId;
1923 $returned_model = $this->inModel ? $this->inModel : $query->model;
1924
1925 // Debug: Log model extraction for streaming
1926 if ( $queries_debug ) {
1927 error_log( '[AI Engine Queries] Model extraction (streaming):' );
1928 error_log( ' - Stream model: ' . ( $this->inModel ?? 'NOT SET' ) );
1929 error_log( ' - Query model: ' . $query->model );
1930 error_log( ' - Using model: ' . $returned_model );
1931 }
1932
1933 $message = [ 'role' => 'assistant', 'content' => $this->streamContent ];
1934
1935 // Store code interpreter code if any
1936 if ( !empty( $this->streamContentCode ) ) {
1937 $reply->contentCode = $this->streamContentCode;
1938 Meow_MWAI_Logging::log( 'Responses API: Stored ' . strlen( $this->streamContentCode ) . ' bytes of code interpreter code' );
1939 }
1940
1941 // REMOVED - We'll handle files after streaming completes, not here
1942
1943 if ( !empty( $this->streamToolCalls ) ) {
1944 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1945 error_log( '[AI Engine Queries] Responses API: Found ' . count( $this->streamToolCalls ) . ' tool calls in streaming response' );
1946 foreach ( $this->streamToolCalls as $idx => $toolCall ) {
1947 error_log( '[AI Engine Queries] Tool call ' . $idx . ': ' . $toolCall['function']['name'] . ' (id: ' . $toolCall['id'] . ')' );
1948 }
1949 }
1950 $message['tool_calls'] = $this->streamToolCalls;
1951 }
1952
1953 if ( !is_null( $this->streamInTokens ) ) {
1954 $returned_in_tokens = $this->streamInTokens;
1955 }
1956 if ( !is_null( $this->streamOutTokens ) ) {
1957 $returned_out_tokens = $this->streamOutTokens;
1958 }
1959 if ( !is_null( $this->streamCost ) ) {
1960 $returned_price = $this->streamCost;
1961 }
1962
1963 // Handle code interpreter sandbox files ONLY if code interpreter has completed
1964 if ( !empty( $this->streamContainerId ) && !empty( $this->streamContent ) && $this->codeInterpreterCompleted ) {
1965 // Check for sandbox links before processing
1966 if ( strpos( $this->streamContent, 'sandbox:' ) !== false ) {
1967 // Pass file citations if available
1968 $fileCitations = isset( $this->streamCodeInterpreterFiles ) ? $this->streamCodeInterpreterFiles : [];
1969
1970 // Download files and replace sandbox links
1971 $this->streamContent = $this->handle_code_interpreter_sandbox_files(
1972 $this->streamContent,
1973 $this->streamContainerId,
1974 $query,
1975 $fileCitations,
1976 true // streaming mode
1977 );
1978 }
1979
1980 // Update the message content with replaced links
1981 $message['content'] = $this->streamContent;
1982 }
1983
1984 $returned_choices = [ [ 'message' => $message ] ];
1985
1986 // Add generated images to the content if any
1987 if ( !empty( $this->streamImages ) ) {
1988 // Add images as additional choices with b64_json format
1989 foreach ( $this->streamImages as $base64Image ) {
1990 $returned_choices[] = [ 'b64_json' => $base64Image ];
1991 }
1992 Meow_MWAI_Logging::log( 'Responses API: Added ' . count( $this->streamImages ) . ' images to choices (streaming)' );
1993 }
1994
1995 // Log streaming response data if queries debug is enabled
1996 if ( $queries_debug ) {
1997 error_log( '[AI Engine Queries] Streaming Response Collected:' );
1998 $streaming_data = [
1999 'id' => $returned_id,
2000 'model' => $returned_model,
2001 'content_length' => strlen( $this->streamContent ),
2002 'content_preview' => substr( $this->streamContent, 0, 200 ) . ( strlen( $this->streamContent ) > 200 ? '...' : '' ),
2003 'tool_calls' => !empty( $this->streamToolCalls ) ? count( $this->streamToolCalls ) . ' tool calls' : 'none',
2004 'usage' => [
2005 'input_tokens' => $returned_in_tokens,
2006 'output_tokens' => $returned_out_tokens,
2007 'cost' => $returned_price
2008 ]
2009 ];
2010
2011 // Log tool calls details if present
2012 if ( !empty( $this->streamToolCalls ) ) {
2013 $streaming_data['tool_calls_details'] = [];
2014 foreach ( $this->streamToolCalls as $tool_call ) {
2015 $streaming_data['tool_calls_details'][] = [
2016 'id' => $tool_call['id'] ?? 'unknown',
2017 'name' => $tool_call['function']['name'] ?? 'unknown',
2018 'arguments' => substr( $tool_call['function']['arguments'] ?? '{}', 0, 100 ) . '...'
2019 ];
2020 }
2021 }
2022
2023 error_log( json_encode( $streaming_data, JSON_PRETTY_PRINT ) );
2024 }
2025 }
2026 // Standard Mode
2027 else {
2028 $data = $res['data'];
2029 if ( empty( $data ) ) {
2030 throw new Exception( 'No content received (res is null).' );
2031 }
2032
2033 // Debug logging for non-streaming mode
2034 if ( $queries_debug ) {
2035 error_log( '[AI Engine Queries] Full response structure (non-streaming):' );
2036 error_log( json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
2037
2038 // Look for container_id in the response
2039 $this->search_for_container_id_recursive( $data, '' );
2040 }
2041
2042 // Ensure $data is an array
2043 if ( !is_array( $data ) ) {
2044 $error_message = is_string( $data ) ? $data : 'Invalid response format';
2045 throw new Exception( 'Responses API error: ' . $error_message );
2046 }
2047
2048 // Handle Responses API response format
2049 $returned_id = $data['id'] ?? null;
2050 $returned_model = $data['model'] ?? $query->model;
2051
2052 // Debug: Log model extraction
2053 if ( $queries_debug ) {
2054 error_log( '[AI Engine Queries] Model extraction:' );
2055 error_log( ' - Response model: ' . ( $data['model'] ?? 'NOT SET' ) );
2056 error_log( ' - Query model: ' . $query->model );
2057 error_log( ' - Using model: ' . $returned_model );
2058 }
2059
2060 // Extract content from Responses API format
2061 $content = '';
2062 $tool_calls = [];
2063 $images = [];
2064
2065 if ( isset( $data['output'] ) && is_array( $data['output'] ) ) {
2066
2067 foreach ( $data['output'] as $idx => $output_item ) {
2068 if ( isset( $output_item['type'] ) && $output_item['type'] === 'message' && isset( $output_item['content'] ) ) {
2069 // Handle message content array - this is the actual text content
2070 if ( is_array( $output_item['content'] ) ) {
2071 foreach ( $output_item['content'] as $content_item ) {
2072 // The actual text is in content_item['text'] for type 'output_text'
2073 if ( isset( $content_item['type'] ) && $content_item['type'] === 'output_text' && isset( $content_item['text'] ) ) {
2074 $content .= $content_item['text'];
2075 }
2076 // Fallback checks for other possible structures
2077 elseif ( isset( $content_item['content'] ) && is_string( $content_item['content'] ) ) {
2078 $content .= $content_item['content'];
2079 }
2080 elseif ( is_string( $content_item ) ) {
2081 $content .= $content_item;
2082 }
2083 }
2084 }
2085 }
2086 elseif ( isset( $output_item['type'] ) && $output_item['type'] === 'function_call' ) {
2087 // Responses API returns function_call type with call_id
2088 $callId = $output_item['call_id'] ?? $output_item['id'] ?? null;
2089 $functionName = $output_item['name'] ?? '';
2090 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
2091 error_log( '[AI Engine Queries] Found function_call: ' . $functionName . ' (call_id: ' . $callId . ')' );
2092 }
2093
2094 $tool_calls[] = [
2095 'id' => $callId,
2096 'type' => 'function',
2097 'function' => [
2098 'name' => $functionName,
2099 'arguments' => $output_item['arguments'] ?? '{}'
2100 ]
2101 ];
2102 }
2103 elseif ( isset( $output_item['type'] ) && $output_item['type'] === 'code_interpreter_call' ) {
2104 // Handle code interpreter calls - both with and without results
2105
2106 // Store container ID if available (this is the primary location)
2107 if ( isset( $output_item['container_id'] ) ) {
2108 $codeInterpreterContainerId = $output_item['container_id'];
2109 Meow_MWAI_Logging::log( 'Responses API: Found container_id for code interpreter: ' . $codeInterpreterContainerId );
2110 }
2111
2112 // Log the entire output_item structure for debugging
2113 if ( $queries_debug ) {
2114 error_log( '[AI Engine Queries] Code interpreter output_item structure:' );
2115 error_log( json_encode( $output_item, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
2116 }
2117
2118 // Handle results if they exist
2119 if ( isset( $output_item['result'] ) ) {
2120 $result = $output_item['result'];
2121
2122 // Also check for container_id in the result itself (backup location)
2123 if ( isset( $result['container_id'] ) && !isset( $codeInterpreterContainerId ) ) {
2124 $codeInterpreterContainerId = $result['container_id'];
2125 Meow_MWAI_Logging::log( 'Responses API: Found container_id in result: ' . $codeInterpreterContainerId );
2126 }
2127
2128 // Append stdout to content if available
2129 if ( isset( $result['stdout'] ) && !empty( $result['stdout'] ) ) {
2130 $content .= "\n```\n" . $result['stdout'] . "\n```\n";
2131 Meow_MWAI_Logging::log( 'Responses API: Found code interpreter output in non-streaming mode' );
2132 }
2133 }
2134 }
2135 elseif ( isset( $output_item['type'] ) && $output_item['type'] === 'image_generation_call' && isset( $output_item['result'] ) ) {
2136 // Handle image generation results
2137 $base64Image = $output_item['result'];
2138 $images[] = $base64Image;
2139
2140 Meow_MWAI_Logging::log( 'Responses API: Found generated image in non-streaming mode' );
2141 }
2142 elseif ( isset( $output_item['type'] ) && $output_item['type'] === 'mcp_approval_request' ) {
2143 // IMPORTANT: MCP approval requests are already handled via streaming events
2144 // We must skip them here to prevent duplicate function calls
2145 // MCP tools are executed remotely by OpenAI and don't need local execution
2146 Meow_MWAI_Logging::log( 'Responses API: Skipping MCP approval request for ' . $output_item['name'] . ' (already handled via events)' );
2147 }
2148 }
2149 }
2150
2151 // If we couldn't find content in output, try other locations
2152 if ( empty( $content ) ) {
2153 if ( isset( $data['text'] ) ) {
2154 if ( is_string( $data['text'] ) ) {
2155 $content = $data['text'];
2156 }
2157 elseif ( is_array( $data['text'] ) ) {
2158 // Only implode if it's an array of strings, not complex structures
2159 $textParts = array_filter( $data['text'], 'is_string' );
2160 if ( !empty( $textParts ) ) {
2161 $content = implode( '', $textParts );
2162 }
2163 }
2164 }
2165 elseif ( isset( $data['content'] ) ) {
2166 if ( is_array( $data['content'] ) && isset( $data['content'][0]['text'] ) ) {
2167 $content = $data['content'][0]['text'];
2168 }
2169 elseif ( is_string( $data['content'] ) ) {
2170 $content = $data['content'];
2171 }
2172 }
2173 }
2174
2175 // If still no content found, log for debugging
2176 if ( empty( $content ) ) {
2177 // Check if $data is actually an array before using array_keys
2178 if ( is_array( $data ) ) {
2179 Meow_MWAI_Logging::log( 'Responses API: No content found in response. Structure: ' . json_encode( array_keys( $data ) ) );
2180 if ( isset( $data['output'][0] ) ) {
2181 Meow_MWAI_Logging::log( 'Responses API: First output item: ' . json_encode( $data['output'][0] ) );
2182 }
2183 if ( isset( $data['text'] ) ) {
2184 Meow_MWAI_Logging::log( 'Responses API: Text field structure: ' . json_encode( $data['text'] ) );
2185 }
2186 }
2187 else {
2188 // If $data is not an array, it might be an error string
2189 Meow_MWAI_Logging::log( 'Responses API: Invalid response data type. Data: ' . ( is_string( $data ) ? $data : json_encode( $data ) ) );
2190 }
2191 // Log the entire response for debugging
2192 Meow_MWAI_Logging::log( 'Responses API: Full response data: ' . json_encode( $data ) );
2193 }
2194
2195 // Handle code interpreter sandbox files if we have a container ID
2196 if ( !empty( $codeInterpreterContainerId ) ) {
2197 $content = $this->handle_code_interpreter_sandbox_files(
2198 $content,
2199 $codeInterpreterContainerId,
2200 $query
2201 );
2202 }
2203
2204 $message = [ 'role' => 'assistant', 'content' => $content ];
2205 if ( !empty( $tool_calls ) ) {
2206 $message['tool_calls'] = $tool_calls;
2207 Meow_MWAI_Logging::log( 'Responses API: Found ' . count( $tool_calls ) . ' tool calls' );
2208 }
2209
2210 $returned_choices = [[ 'message' => $message ]];
2211
2212 // Add images as additional choices
2213 if ( !empty( $images ) ) {
2214 foreach ( $images as $base64Image ) {
2215 $returned_choices[] = [ 'b64_json' => $base64Image ];
2216 }
2217 Meow_MWAI_Logging::log( 'Responses API: Added ' . count( $images ) . ' images to choices' );
2218 }
2219
2220 // Extract usage information
2221 // Responses API uses input_tokens/output_tokens
2222 $usage = $data['usage'] ?? [];
2223 $returned_in_tokens = $usage['input_tokens'] ?? $usage['prompt_tokens'] ?? null;
2224 $returned_out_tokens = $usage['output_tokens'] ?? $usage['completion_tokens'] ?? null;
2225 $returned_price = $usage['cost'] ?? null;
2226 }
2227
2228 // Store response ID for future stateful requests
2229 if ( !empty( $returned_id ) ) {
2230 $this->previousResponseId = $returned_id;
2231 $reply->set_id( $returned_id );
2232 }
2233 // Set the results
2234 $reply->set_choices( $returned_choices );
2235
2236 // Check for empty output when reasoning is enabled (GPT-5 models)
2237 // This can happen when reasoning consumes all available tokens
2238 if ( strpos( $query->model, 'gpt-5' ) === 0 && !empty( $query->reasoning ) ) {
2239 // Check if the reply has no content
2240 if ( empty( $reply->result ) || trim( $reply->result ) === '' ) {
2241 // Check if we have function calls - those are valid even without text content
2242 if ( empty( $reply->needFeedbacks ) && empty( $reply->needClientActions ) ) {
2243 throw new Exception(
2244 'The model returned an empty response. This typically happens when reasoning consumes all available tokens. ' .
2245 'Please increase the Max Tokens setting to allow space for both reasoning and the actual response. ' .
2246 'Current Max Tokens: ' . ( $query->maxTokens ?? 'default' ) . '. ' .
2247 'Try setting it to at least ' . ( ( $query->maxTokens ?? 4096 ) + 2000 ) . ' tokens.'
2248 );
2249 }
2250 }
2251 }
2252
2253 // Handle tokens usage
2254 $this->handle_tokens_usage(
2255 $reply,
2256 $query,
2257 $returned_model,
2258 $returned_in_tokens,
2259 $returned_out_tokens,
2260 $returned_price
2261 );
2262
2263 return $reply;
2264 }
2265 catch ( Exception $e ) {
2266 $service = $this->get_service_name();
2267 Meow_MWAI_Logging::error( "$service (Responses API): " . $e->getMessage() );
2268 $message = "$service (Responses API): " . $e->getMessage();
2269 throw new Exception( $message );
2270 }
2271 finally {
2272 if ( !is_null( $streamCallback ) ) {
2273 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
2274 }
2275 }
2276 }
2277
2278 /**
2279 * Override handle_tokens_usage to set accuracy properly
2280 */
2281 public function handle_tokens_usage(
2282 $reply,
2283 $query,
2284 $returned_model,
2285 $returned_in_tokens,
2286 $returned_out_tokens,
2287 $returned_price = null
2288 ) {
2289 // Call parent to handle the actual usage recording
2290 parent::handle_tokens_usage(
2291 $reply,
2292 $query,
2293 $returned_model,
2294 $returned_in_tokens,
2295 $returned_out_tokens,
2296 $returned_price
2297 );
2298
2299 // Set accuracy based on data availability
2300 if ( !is_null( $returned_price ) && !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
2301 // Responses API with cost field or OpenRouter style = full accuracy
2302 $reply->set_usage_accuracy( 'full' );
2303 }
2304 elseif ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
2305 // Tokens from API but price calculated = tokens accuracy
2306 $reply->set_usage_accuracy( 'tokens' );
2307 }
2308 else {
2309 // Everything estimated
2310 $reply->set_usage_accuracy( 'estimated' );
2311 }
2312 }
2313
2314 /**
2315 * Override image query handling for gpt-image-1 model
2316 */
2317 public function run_image_query( $query, $streamCallback = null ) {
2318 // IMPORTANT: We use the standard Images API for gpt-image-1 (not Responses API)
2319 // Even though Responses API supports image_generation tool, it would let the
2320 // orchestrator model choose which image model to use. By using the Images API
2321 // directly, we ensure gpt-image-1 is actually used as requested by the user.
2322
2323 // Use standard implementation for all image models including gpt-image-1
2324 return parent::run_image_query( $query, $streamCallback );
2325 }
2326
2327 /**
2328 * Override transcription to support new models
2329 */
2330 public function run_transcribe_query( $query ) {
2331 // Check if using new transcription models
2332 $newTranscribeModels = ['gpt-4o-transcribe', 'gpt-4o-mini-transcribe'];
2333 if ( in_array( $query->model, $newTranscribeModels ) ) {
2334 // These still use the /audio/transcriptions endpoint but with new models
2335 // Just need to make sure the model name is passed correctly
2336 }
2337
2338 // Use parent implementation (still uses audio endpoint)
2339 return parent::run_transcribe_query( $query );
2340 }
2341
2342 /**
2343 * Override embedding query to support new models
2344 */
2345 public function run_embedding_query( $query ) {
2346 // Check if using new embedding models
2347 $newEmbeddingModels = ['text-embedding-3-small', 'text-embedding-3-large'];
2348 if ( in_array( $query->model, $newEmbeddingModels ) ) {
2349 // These still use the /embeddings endpoint but with improved models
2350 // The parent implementation should handle this correctly
2351 }
2352
2353 // Use parent implementation
2354 return parent::run_embedding_query( $query );
2355 }
2356
2357 /**
2358 * Enhanced error handling for Responses API
2359 */
2360 protected function handle_responses_errors( $data ) {
2361 // Handle Responses API specific errors
2362 if ( isset( $data['error'] ) ) {
2363 $error = $data['error'];
2364 $message = $error['message'] ?? 'Unknown error';
2365 $type = $error['type'] ?? null;
2366 $code = $error['code'] ?? null;
2367
2368 // Special handling for "No tool output found" errors
2369 if ( strpos( $message, 'No tool output found' ) !== false ) {
2370 // Log this error with details when queries debug is enabled
2371 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
2372 error_log( '[AI Engine Queries] Responses API Tool Output Error:' );
2373 error_log( '[AI Engine Queries] Error: ' . $message );
2374 error_log( '[AI Engine Queries] This typically means the function call outputs were not properly formatted or are missing.' );
2375
2376 // Log the last request body if available
2377 if ( property_exists( $this, 'lastRequestBody' ) && $this->lastRequestBody ) {
2378 error_log( '[AI Engine Queries] Last request body: ' . json_encode( $this->lastRequestBody, JSON_PRETTY_PRINT ) );
2379 }
2380 }
2381 }
2382
2383 $errorMessage = $message;
2384 if ( $type ) {
2385 $errorMessage .= " (Type: $type)";
2386 }
2387 if ( $code ) {
2388 $errorMessage .= " (Code: $code)";
2389 }
2390
2391 throw new Exception( $errorMessage );
2392 }
2393
2394 // Check for event-based errors
2395 if ( isset( $data['event'] ) && $data['event'] === 'response.error' ) {
2396 $error = $data['error'] ?? [];
2397 $message = $error['message'] ?? 'Response API error';
2398 throw new Exception( $message );
2399 }
2400
2401 // Fallback to parent error handling
2402 parent::handle_response_errors( $data );
2403 }
2404
2405 /**
2406 * Add method to reset conversation state
2407 */
2408 public function reset_conversation_state() {
2409 $this->previousResponseId = null;
2410 $this->conversationState = [];
2411 }
2412
2413 /**
2414 * Check the connection to OpenAI by listing models.
2415 * This is a free metadata call that verifies API key validity.
2416 */
2417 public function connection_check() {
2418 try {
2419 $url = $this->get_models_endpoint();
2420 $response = $this->execute( 'GET', $url );
2421
2422 if ( !isset( $response['data'] ) || !is_array( $response['data'] ) ) {
2423 throw new Exception( 'Invalid response format from OpenAI' );
2424 }
2425
2426 $modelCount = count( $response['data'] );
2427 $availableModels = [];
2428
2429 // Get first 5 models for display
2430 $displayModels = array_slice( $response['data'], 0, 5 );
2431 foreach ( $displayModels as $model ) {
2432 if ( isset( $model['id'] ) ) {
2433 $availableModels[] = $model['id'];
2434 }
2435 }
2436
2437 return [
2438 'success' => true,
2439 'service' => 'OpenAI',
2440 'message' => "Connection successful. Found {$modelCount} models.",
2441 'details' => [
2442 'endpoint' => $url,
2443 'model_count' => $modelCount,
2444 'sample_models' => $availableModels,
2445 'organization' => $response['organization'] ?? null
2446 ]
2447 ];
2448 }
2449 catch ( Exception $e ) {
2450 return [
2451 'success' => false,
2452 'service' => 'OpenAI',
2453 'error' => $e->getMessage(),
2454 'details' => [
2455 'endpoint' => $this->get_models_endpoint()
2456 ]
2457 ];
2458 }
2459 }
2460
2461 /**
2462 * Handle code interpreter sandbox files
2463 * Parses sandbox links from content, downloads files, and replaces links
2464 */
2465 protected function handle_code_interpreter_sandbox_files( $content, $containerId, $query, $fileCitations = [], $isStreaming = false ) {
2466 if ( empty( $containerId ) || empty( $content ) ) {
2467 return $content;
2468 }
2469
2470 // Use streamCodeInterpreterFiles if available (from annotations)
2471 if ( !empty( $this->streamCodeInterpreterFiles ) ) {
2472 $fileCitations = $this->streamCodeInterpreterFiles;
2473 }
2474
2475 // Parse sandbox links from content
2476 $sandboxLinks = $this->parse_sandbox_links( $content );
2477
2478 if ( empty( $sandboxLinks ) ) {
2479 return $content;
2480 }
2481
2482 Meow_MWAI_Logging::log( 'Code Interpreter: Processing ' . count( $sandboxLinks ) . ' sandbox files' );
2483
2484 $containerFiles = [];
2485
2486 // If we have file citations, use them directly (skip container list API)
2487 if ( !empty( $fileCitations ) ) {
2488 foreach ( $fileCitations as $citation ) {
2489 if ( isset( $citation['file_id'] ) ) {
2490 $containerFiles[] = [
2491 'id' => $citation['file_id'],
2492 'path' => $citation['path'] ?? ( '/mnt/data/' . $citation['filename'] ),
2493 'filename' => $citation['filename'] ?? null
2494 ];
2495 }
2496 }
2497 }
2498 else {
2499 // Only try container API if we don't have file citations
2500 error_log( '[AI Engine] No file citations, will try container list API' );
2501 $containerFiles = $this->list_container_files( $containerId, $query );
2502 }
2503
2504 if ( empty( $containerFiles ) ) {
2505 error_log( '[AI Engine] WARNING: No files found from citations or container API' );
2506 Meow_MWAI_Logging::warn( 'No files found in container ' . $containerId );
2507 return $content;
2508 }
2509
2510 // Process each sandbox link
2511 $replacements = 0;
2512 foreach ( $sandboxLinks as $sandboxPath ) {
2513 $filename = basename( $sandboxPath );
2514
2515 // Find the file in container
2516 $fileId = $this->find_container_file_id( $containerFiles, $filename );
2517
2518 if ( !$fileId ) {
2519 error_log( '[AI Engine] ERROR: File ID not found for: ' . $filename );
2520 Meow_MWAI_Logging::warn( 'Code Interpreter: File not found in container: ' . $filename );
2521 continue;
2522 }
2523
2524 // Try to download the file with retries if streaming
2525 $publicUrl = null;
2526 $maxRetries = $isStreaming ? 3 : 1;
2527 $retryDelay = 2; // seconds
2528
2529 for ( $attempt = 1; $attempt <= $maxRetries; $attempt++ ) {
2530 if ( $attempt > 1 ) {
2531 error_log( '[AI Engine] Retry attempt ' . $attempt . ' after ' . $retryDelay . ' seconds...' );
2532 sleep( $retryDelay );
2533 $retryDelay *= 2; // exponential backoff
2534 }
2535
2536 $publicUrl = $this->download_container_file( $containerId, $fileId, $filename, $query );
2537
2538 if ( $publicUrl ) {
2539 break;
2540 }
2541 }
2542
2543 if ( $publicUrl ) {
2544 // Replace sandbox link with public URL
2545 $content = str_replace( $sandboxPath, $publicUrl, $content );
2546 $replacements++;
2547 Meow_MWAI_Logging::log( 'Replaced sandbox link: ' . $filename . ' -> ' . $publicUrl );
2548 }
2549 else {
2550
2551 // If download fails, create a message about it
2552 $errorMessage = sprintf(
2553 '[File: %s - Download temporarily unavailable, refresh page to retry]',
2554 $filename
2555 );
2556 $content = str_replace( $sandboxPath, $errorMessage, $content );
2557 $replacements++;
2558 }
2559 }
2560
2561 return $content;
2562 }
2563
2564 /**
2565 * Parse sandbox links from content
2566 */
2567 protected function parse_sandbox_links( $content ) {
2568 $links = [];
2569
2570 // Match various sandbox link patterns
2571 $patterns = [
2572 '/sandbox:\/mnt\/data\/[^)\s]+/', // Basic pattern
2573 '/\(sandbox:\/mnt\/data\/[^)]+\)/', // In parentheses
2574 '/\[([^\]]*)\]\(sandbox:\/mnt\/data\/[^)]+\)/', // Markdown links
2575 ];
2576
2577 foreach ( $patterns as $pattern ) {
2578 if ( preg_match_all( $pattern, $content, $matches ) ) {
2579 foreach ( $matches[0] as $match ) {
2580 // Extract just the sandbox path
2581 if ( preg_match( '/sandbox:\/mnt\/data\/[^)\s\]]+/', $match, $pathMatch ) ) {
2582 $links[] = $pathMatch[0];
2583 }
2584 }
2585 }
2586 }
2587
2588 return array_unique( $links );
2589 }
2590
2591 /**
2592 * List files in a container
2593 */
2594 protected function list_container_files( $containerId, $query ) {
2595 try {
2596 // Use the execute function with the path format it expects
2597 $path = '/containers/' . $containerId . '/files';
2598
2599 // Try to call the API (remove streaming handler for JSON requests)
2600 $response = null;
2601 try {
2602 $response = $this->without_stream_handler( function () use ( $path ) {
2603 return $this->execute( 'GET', $path, null, null, true );
2604 } );
2605 }
2606 catch ( Exception $api_exception ) {
2607 // If it's a 404, the container might not exist yet or might be expired
2608 if ( strpos( $api_exception->getMessage(), '404' ) !== false ) {
2609 // Wait a moment and retry once
2610 sleep( 2 );
2611
2612 try {
2613 $response = $this->without_stream_handler( function () use ( $path ) {
2614 return $this->execute( 'GET', $path, null, null, true );
2615 } );
2616 }
2617 catch ( Exception $retry_exception ) {
2618 throw $retry_exception;
2619 }
2620 }
2621 else {
2622 throw $api_exception;
2623 }
2624 }
2625
2626 // Check if response is null or empty array
2627 if ( $response === null || ( is_array( $response ) && empty( $response ) ) ) {
2628 // Try waiting a bit for files to be ready
2629 sleep( 3 );
2630
2631 // Try one more time
2632 $response = $this->execute( 'GET', $path, null, null, true );
2633
2634 // If still empty, wait longer and try once more
2635 if ( $response === null || ( is_array( $response ) && empty( $response ) ) ) {
2636 sleep( 5 );
2637 $response = $this->execute( 'GET', $path, null, null, true );
2638 }
2639 }
2640
2641 if ( isset( $response['data'] ) && is_array( $response['data'] ) ) {
2642 return $response['data'];
2643 }
2644 else if ( is_array( $response ) && isset( $response[0] ) ) {
2645 // Maybe the response is directly an array of files
2646 return $response;
2647 }
2648 }
2649 catch ( Exception $e ) {
2650 Meow_MWAI_Logging::warn( 'Failed to list container files: ' . $e->getMessage() );
2651 }
2652
2653 return [];
2654 }
2655
2656 /**
2657 * Find file ID by filename in container files list
2658 */
2659 protected function find_container_file_id( $containerFiles, $filename ) {
2660 foreach ( $containerFiles as $file ) {
2661 // Check if filename matches the end of the path
2662 if ( isset( $file['path'] ) ) {
2663 // Handle cases where path might contain multiple filenames separated by spaces
2664 $paths = preg_split( '/\s+/', $file['path'] );
2665 foreach ( $paths as $path ) {
2666 if ( str_ends_with( $path, $filename ) ) {
2667 return $file['id'];
2668 }
2669 }
2670 }
2671
2672 // Also check direct filename match
2673 if ( isset( $file['filename'] ) && $file['filename'] === $filename ) {
2674 return $file['id'];
2675 }
2676 }
2677
2678 return null;
2679 }
2680
2681 /**
2682 * Execute HTTP request without streaming handler interference
2683 */
2684 private function without_stream_handler( callable $fn ) {
2685 $cb = [ $this, 'stream_handler' ];
2686 $had = has_action( 'http_api_curl', $cb );
2687 if ( $had ) {
2688 remove_action( 'http_api_curl', $cb );
2689 }
2690 try {
2691 return $fn();
2692 }
2693 finally {
2694 if ( $had ) {
2695 add_action( 'http_api_curl', $cb, 10, 3 );
2696 }
2697 }
2698 }
2699
2700 /**
2701 * Download a file from container and store it locally
2702 */
2703 protected function download_container_file( $containerId, $fileId, $filename, $query ) {
2704 try {
2705 $fileContent = null;
2706
2707 // For container files (cfile_*), we MUST use the Container API
2708 if ( strpos( $fileId, 'cfile_' ) === 0 ) {
2709 if ( empty( $containerId ) ) {
2710 throw new Exception( 'Container ID is required for downloading container files' );
2711 }
2712
2713 // Use the Container API endpoint
2714 $path = '/containers/' . $containerId . '/files/' . $fileId . '/content';
2715
2716 try {
2717 // Remove streaming handler and download binary content
2718 $headers = [ 'Accept' => '*/*' ];
2719 $fileContent = $this->without_stream_handler( function () use ( $path, $headers ) {
2720 // false = raw binary content, not JSON
2721 return $this->execute( 'GET', $path, null, $headers, false );
2722 } );
2723
2724 if ( strlen( $fileContent ) > 0 ) {
2725 Meow_MWAI_Logging::log( 'Container API: Downloaded ' . strlen( $fileContent ) . ' bytes for ' . $filename );
2726 }
2727 else {
2728 throw new Exception( 'Container file returned empty content' );
2729 }
2730 }
2731 catch ( Exception $e ) {
2732 throw $e;
2733 }
2734 }
2735 else {
2736 // Regular file_* files use the standard Files API
2737 $filesPath = '/files/' . $fileId . '/content';
2738 $headers = [ 'Accept' => '*/*' ];
2739 $fileContent = $this->without_stream_handler( function () use ( $filesPath, $headers ) {
2740 return $this->execute( 'GET', $filesPath, null, $headers, false );
2741 } );
2742 }
2743
2744 if ( empty( $fileContent ) ) {
2745 error_log( '[AI Engine] ERROR: Both APIs failed to return content' );
2746 throw new Exception( 'Empty file content received from both Files API and Container API' );
2747 }
2748
2749 // Save to temporary file
2750 $tmpFile = tempnam( sys_get_temp_dir(), 'mwai_code_' );
2751 file_put_contents( $tmpFile, $fileContent );
2752
2753 // Upload to our file system
2754 $purpose = 'assistant-out';
2755 $metadata = [
2756 'source' => 'code_interpreter',
2757 'container_id' => $containerId,
2758 'file_id' => $fileId
2759 ];
2760
2761 $refId = $this->core->files->upload_file( $tmpFile, $filename, $purpose, $metadata, $query->envId );
2762
2763 // Update the file's refId to match the OpenAI file ID
2764 $internalFileId = $this->core->files->get_id_from_refId( $refId );
2765 $this->core->files->update_refId( $internalFileId, $fileId );
2766
2767 // Get the public URL
2768 $publicUrl = $this->core->files->get_url( $fileId );
2769
2770 // Clean up temp file
2771 @unlink( $tmpFile );
2772
2773 return $publicUrl;
2774 }
2775 catch ( Exception $e ) {
2776 error_log( '[AI Engine] EXCEPTION in download_container_file: ' . $e->getMessage() );
2777 error_log( '[AI Engine] Stack trace: ' . $e->getTraceAsString() );
2778 Meow_MWAI_Logging::warn( 'Failed to download container file ' . $filename . ': ' . $e->getMessage() );
2779 return null;
2780 }
2781 }
2782
2783 /**
2784 * Get the models endpoint URL
2785 */
2786 protected function get_models_endpoint() {
2787 $endpoint = null;
2788
2789 // Same logic as build_url to determine the endpoint
2790 if ( $this->envType === 'openai' ) {
2791 $endpoint = apply_filters( 'mwai_openai_endpoint', 'https://api.openai.com/v1', $this->env );
2792 }
2793 else if ( $this->envType === 'azure' ) {
2794 $endpoint = isset( $this->env['endpoint'] ) ? $this->env['endpoint'] : null;
2795 }
2796
2797 if ( empty( $endpoint ) ) {
2798 throw new Exception( 'Endpoint is not defined for envType: ' . $this->envType );
2799 }
2800
2801 // Remove any existing API paths to get base URL
2802 $endpoint = str_replace( '/chat/completions', '', $endpoint );
2803 $endpoint = str_replace( '/v1/responses', '', $endpoint );
2804 $endpoint = rtrim( $endpoint, '/' );
2805
2806 // For Azure, use the v1 endpoint for consistency with Responses API
2807 if ( $this->envType === 'azure' ) {
2808 // Use v1 models endpoint with preview API version
2809 return $endpoint . '/openai/v1/models?api-version=preview';
2810 }
2811
2812 // For OpenAI, ensure we have the /v1 prefix
2813 if ( strpos( $endpoint, '/v1' ) === false ) {
2814 $endpoint .= '/v1';
2815 }
2816
2817 return $endpoint . '/models';
2818 }
2819
2820 /**
2821 * Prepare query by uploading files to OpenAI Files API.
2822 *
2823 * This method overrides the base prepare_query() to handle OpenAI-specific file uploads.
2824 * Files are uploaded to OpenAI's Files API before the query is executed, ensuring they
2825 * have provider_file_id references that can be used in messages.
2826 *
2827 * @param Meow_MWAI_Query_Text $query The query with potential file attachments
2828 */
2829 protected function prepare_query( $query ) {
2830 // Get all attachments using the unified method
2831 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
2832
2833 if ( empty( $attachments ) ) {
2834 return;
2835 }
2836
2837 // Process each attachment - upload non-images to OpenAI Files API
2838 foreach ( $attachments as $index => $file ) {
2839 $mimeType = $file->get_mimeType() ?? '';
2840 $isImage = strpos( $mimeType, 'image/' ) === 0;
2841
2842 // Skip images - they're sent as base64/URL, not uploaded to Files API
2843 if ( $isImage ) {
2844 continue;
2845 }
2846
2847 if ( $file->get_type() !== 'provider_file_id' ) {
2848 // File hasn't been uploaded to OpenAI yet - upload it now
2849 try {
2850 // Get data directly from file system (not via URL download)
2851 $refId = $file->get_refId();
2852 $data = $this->core->files->get_data( $refId );
2853 $filename = $file->get_filename();
2854
2855 // WORKAROUND: Create a fresh engine instance for upload (matches chatbot.php approach)
2856 $uploadEngine = Meow_MWAI_Engines_Factory::get_openai( $this->core, $query->envId );
2857 $uploadedFile = $uploadEngine->upload_file( $filename, $data, 'user_data' );
2858
2859 $fileId = $uploadedFile['id'] ?? null;
2860
2861 if ( $fileId ) {
2862 // Store provider file_id in metadata for cleanup later
2863 $localFileId = $this->core->files->get_id_from_refId( $refId );
2864 if ( $localFileId ) {
2865 $this->core->files->add_metadata( $localFileId, 'file_id', $fileId );
2866 $this->core->files->add_metadata( $localFileId, 'provider', 'openai' );
2867 }
2868
2869 // Replace with provider_file_id reference in both arrays
2870 if ( !empty( $query->attachedFiles ) && isset( $query->attachedFiles[$index] ) ) {
2871 $query->attachedFiles[$index] = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
2872 $fileId,
2873 $file->get_purpose(),
2874 $file->get_mimeType()
2875 );
2876 }
2877 // Also update legacy attachedFile if this is the first file
2878 if ( $index === 0 && !empty( $query->attachedFile ) ) {
2879 $query->attachedFile = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
2880 $fileId,
2881 $file->get_purpose(),
2882 $file->get_mimeType()
2883 );
2884 }
2885 }
2886 }
2887 catch ( Exception $e ) {
2888 error_log( '[AI Engine] Failed to upload file to OpenAI Files API: ' . $e->getMessage() );
2889 // Keep the original file - MessageBuilder will skip it
2890 }
2891 }
2892 }
2893 }
2894
2895 }
2896