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