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