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