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