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