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