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