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