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