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