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