PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.6.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.6.4
3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / core.php
ai-engine / classes / engines Last commit date
anthropic.php 2 days ago chatml.php 2 days ago core.php 1 day ago custom.php 2 days ago factory.php 1 month ago google-interactions.php 2 days ago google.php 2 days ago mistral.php 1 month ago open-router.php 1 month ago openai.php 2 days ago ovh.php 2 days ago perplexity.php 7 months ago replicate.php 6 months ago xai.php 2 months ago
core.php
845 lines
1 <?php
2
3 class Meow_MWAI_Engines_Core {
4 protected $core = null;
5 public $env = null;
6 public $envId = null;
7 public $envType = null;
8
9 // Streaming
10 protected $streamCallback = null;
11 protected $streamTemporaryBuffer = '';
12 protected $streamBuffer = '';
13 protected $streamHeaders = [];
14 protected $streamContent = '';
15
16 // Debug mode for stream events
17 protected $currentDebugMode = false;
18 protected $currentQuery = null;
19 protected $emittedFunctionResults = [];
20
21 public function __construct( $core, $env ) {
22 $this->core = $core;
23 $this->env = $env;
24 $this->envId = isset( $env['id'] ) ? $env['id'] : null;
25 $this->envType = isset( $env['type'] ) ? $env['type'] : null;
26 }
27
28 /**
29 * Reset all request-specific state variables.
30 * This should be called at the start of each new request to prevent
31 * state leakage between requests.
32 */
33 protected function reset_request_state() {
34 // Reset streaming state
35 $this->streamCallback = null;
36 $this->streamTemporaryBuffer = '';
37 $this->streamBuffer = '';
38 $this->streamHeaders = [];
39 $this->streamContent = '';
40
41 // Reset debug/event state
42 $this->currentDebugMode = false;
43 $this->currentQuery = null;
44 $this->emittedFunctionResults = [];
45 }
46
47 /**
48 * Safely encode data to JSON for API requests with UTF-8 error handling.
49 *
50 * WordPress content can contain malformed UTF-8 characters from various sources:
51 * - Copy-paste from Microsoft Word or other rich text editors
52 * - Database migrations from different character sets
53 * - Old content created before proper UTF-8 handling
54 * - User input with mixed encodings
55 * - WooCommerce product descriptions with special characters
56 *
57 * Without proper handling, json_encode() silently returns FALSE when encountering
58 * invalid UTF-8, causing API requests to fail cryptically with no error message.
59 *
60 * This method:
61 * 1. Uses JSON_INVALID_UTF8_SUBSTITUTE to replace invalid UTF-8 with � (U+FFFD)
62 * 2. Detects encoding failures and logs detailed debugging information
63 * 3. Throws descriptive exceptions instead of failing silently
64 *
65 * The replacement character (�) is handled correctly by all modern AI APIs and is
66 * far better than complete request failure.
67 *
68 * @param mixed $data The data to encode (array, object, string, etc.)
69 * @param string $context Optional context for error messages (e.g., 'request body', 'query')
70 * @return string The JSON-encoded string
71 * @throws Exception If JSON encoding fails even with UTF-8 substitution
72 */
73 protected function safe_json_encode( $data, $context = 'data' ) {
74 // Use JSON_INVALID_UTF8_SUBSTITUTE to handle malformed UTF-8 gracefully
75 // This flag replaces invalid sequences with the Unicode replacement character (U+FFFD)
76 $json = json_encode( $data, JSON_INVALID_UTF8_SUBSTITUTE );
77
78 if ( $json === false ) {
79 // Encoding failed even with UTF-8 substitution - log detailed debug info
80 $error_msg = json_last_error_msg();
81 error_log( "[AI Engine] JSON encode failed for {$context}: {$error_msg}" );
82 error_log( '[AI Engine] Data type: ' . gettype( $data ) );
83
84 if ( is_array( $data ) || is_object( $data ) ) {
85 // Log structure (limited to prevent massive logs)
86 $structure = print_r( $data, true );
87 $preview = substr( $structure, 0, 1000 );
88 if ( strlen( $structure ) > 1000 ) {
89 $preview .= "\n... (truncated, total length: " . strlen( $structure ) . ' chars)';
90 }
91 error_log( "[AI Engine] Data structure: {$preview}" );
92 }
93
94 throw new Exception( "Failed to encode {$context} as JSON: {$error_msg}" );
95 }
96
97 return $json;
98 }
99
100 /**
101 * Prepare query before execution.
102 * This method is called BEFORE any streaming hooks are set up.
103 * Engines should override this to perform preliminary tasks like:
104 * - Uploading files to provider APIs
105 * - Preprocessing data
106 * - Validating query parameters
107 *
108 * @param Meow_MWAI_Query_Base $query The query to prepare
109 */
110 protected function prepare_query( $query ) {
111 // Base implementation does nothing
112 // Child engines can override to add provider-specific preparation
113 }
114
115 public function run( $query, $streamCallback = null, $maxDepth = 5 ) {
116
117 // Apply filter to allow overriding maxDepth (only on first call)
118 if ( !isset( $query->_maxDepthConfigured ) ) {
119 $maxDepth = apply_filters( 'mwai_function_call_max_depth', $maxDepth, $query );
120 $query->_maxDepthConfigured = $maxDepth;
121 }
122
123 // Check if queries debug is enabled
124 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
125
126 // Log query start if debug is enabled
127 if ( $queries_debug ) {
128 // We'll let the individual engines log the actual HTTP requests/responses
129 // Just log a simple start marker here
130 error_log( '[AI Engine Queries] ========================================' );
131 $query_type = get_class( $query );
132 error_log( '[AI Engine Queries] Starting ' . $query_type . ' to ' . ( $query->model ?? 'unknown model' ) );
133 }
134
135 // Check if the query is allowed. Filters refuse with a string (message) or
136 // a WP_Error whose code carries the machine-readable reason (e.g.
137 // mwai_over_limit), which REST layers use to flag clients.
138 $limits = $this->core->get_option( 'limits' );
139 $allowed = apply_filters( 'mwai_ai_allowed', true, $query, $limits );
140 if ( $allowed !== true ) {
141 if ( is_wp_error( $allowed ) ) {
142 $reason = $allowed->get_error_code() === 'mwai_over_limit' ? 'limits' : null;
143 throw new Meow_MWAI_RefusedException( $allowed->get_error_message(), $reason );
144 }
145 $message = is_string( $allowed ) ? $allowed : 'Unauthorized query.';
146 throw new Meow_MWAI_RefusedException( $message );
147 }
148
149 // Important as it makes sure everything is consolidated in the query and the engine.
150 $this->final_checks( $query );
151
152 // Run the query
153 $reply = null;
154 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
155 $reply = $this->run_completion_query( $query, $streamCallback );
156 }
157 else if ( $query instanceof Meow_MWAI_Query_Assistant || $query instanceof Meow_MWAI_Query_AssistFeedback ) {
158 $reply = $this->run_assistant_query( $query, $streamCallback );
159 if ( $reply === null ) {
160 throw new Exception( 'Assistants are not supported in this version of AI Engine.' );
161 }
162 }
163 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
164 $reply = $this->run_embedding_query( $query );
165 }
166 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
167 $reply = $this->run_editimage_query( $query );
168 }
169 else if ( $query instanceof Meow_MWAI_Query_Image ) {
170 $reply = $this->run_image_query( $query, $streamCallback );
171 }
172 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
173 $reply = $this->run_transcribe_query( $query );
174 }
175 else {
176 throw new Exception( 'Unknown query type.' );
177 }
178
179 // Allow to modify the reply before it is sent.
180 $reply = apply_filters( 'mwai_ai_reply', $reply, $query );
181
182 // Log query completion if debug is enabled
183 if ( $queries_debug && empty( $reply->needFeedbacks ) ) {
184 // For embedding queries, just log the dimensions count
185 if ( $query instanceof Meow_MWAI_Query_Embed && !empty( $reply->result ) && is_array( $reply->result ) ) {
186 error_log( '[AI Engine Queries] Embedding completed with ' . count( $reply->result ) . ' dimensions' );
187 }
188 else {
189 error_log( '[AI Engine Queries] Query completed' );
190 }
191 error_log( '[AI Engine Queries] ========================================' );
192 }
193
194 // Function Call Handling - This is where the magic happens!
195 // When the AI model requests function calls, we execute them and send results back
196 if ( !empty( $reply->needFeedbacks ) ) {
197
198 // Debug: Log how many needFeedbacks we have
199 if ( $queries_debug ) {
200 error_log( '[AI Engine Queries] Core: Processing ' . count( $reply->needFeedbacks ) . ' needFeedbacks' );
201 foreach ( $reply->needFeedbacks as $idx => $feedback ) {
202 error_log( '[AI Engine Queries] Core: needFeedback[' . $idx . ']: name=' . $feedback['name'] . ', toolId=' . ( $feedback['toolId'] ?? 'none' ) );
203 }
204 }
205
206 // Prevent infinite loops - each function call reduces maxDepth by 1
207 if ( $maxDepth <= 0 ) {
208 // Build call stack for better debugging
209 $callStack = [];
210 foreach ( $reply->needFeedbacks as $feedback ) {
211 $callStack[] = $feedback['name'] ?? 'unknown';
212 }
213
214 throw Meow_MWAI_FunctionCallException::loop_detected(
215 $query->_maxDepthConfigured ?? 5, // Use configured max depth
216 $callStack
217 );
218 }
219
220 // Create a feedback query if we're not already in one
221 // This wraps the original query with function execution results
222 if ( !( $query instanceof Meow_MWAI_Query_AssistFeedback ) && !( $query instanceof Meow_MWAI_Query_Feedback ) ) {
223 $queryClass = $query instanceof Meow_MWAI_Query_Assistant ?
224 Meow_MWAI_Query_AssistFeedback::class : Meow_MWAI_Query_Feedback::class;
225 // Note: $reply->query contains the original query that produced this reply
226 $query = new $queryClass( $reply, $reply->query );
227 }
228 else {
229 // Already inside a feedback chain (recursion depth >= 2). Each new reply produces a
230 // fresh response_id; the next request must reference THAT, not the original turn's id.
231 // Otherwise OpenAI's Responses API rejects with "No tool output found" because, from
232 // the original response's perspective, the new function_call_output answers a call
233 // that doesn't exist in its chain. Refresh previousResponseId from the latest reply
234 // so each recursion targets the immediately preceding turn.
235 if ( !empty( $reply->id ) ) {
236 $query->previousResponseId = $reply->id;
237 }
238 // The per-turn feedback blocks are reset (or kept, for stateless
239 // providers) further down, right before they are repopulated.
240 }
241
242 // Determine whether every function call in this turn is "static" (no AI
243 // feedback wanted). If so, we still execute the functions below but
244 // skip the recursive AI round-trip — the AI's reply stands as-is.
245 // Mixed turns degrade to dynamic for all calls, since OpenAI/Anthropic
246 // tool-call protocols require a result for every requested call in the
247 // same message.
248 $all_static = true;
249 foreach ( $reply->needFeedbacks as $needFeedback ) {
250 $fn = $needFeedback['function'] ?? null;
251 if ( !$fn || !isset( $fn->behavior ) || $fn->behavior !== 'static' ) {
252 $all_static = false;
253 break;
254 }
255 }
256
257 // OpenAI's Responses API is stateful: when the next turn references previous_response_id,
258 // every function_call from the prior response must have a matching function_call_output,
259 // or the API rejects with "No tool output found for function call call_xxx". Skipping the
260 // round-trip on all-static turns satisfies the local user-facing reply but leaves the
261 // server-side conversation in a half-answered state, breaking any follow-up. So we
262 // disable the static-skip optimization for stateful replies (OpenAI Responses
263 // and Google Interactions) — the round-trip stays mandatory there. Chat
264 // Completions and Anthropic are stateless on tool calls and remain safe to skip.
265 if ( $all_static && !empty( $reply->id ) && $this->core->responseIdManager->is_stateful_conversation_id( $reply->id ) ) {
266 $all_static = false;
267 }
268
269 // Validate that all function calls have proper function definitions.
270 // An unknown name is not fatal: models sometimes hallucinate a variant
271 // (e.g. a "{MCP server}_{function}" mashup when MCP servers and local
272 // functions are mixed). The error is returned to the AI as that call's
273 // result so it can correct itself, instead of failing the whole reply.
274 foreach ( $reply->needFeedbacks as $idx => $needFeedback ) {
275 if ( !isset( $needFeedback['function'] ) ) {
276 $functionName = $needFeedback['name'] ?? 'unknown';
277 $availableFunctions = array_map( function ( $f ) { return $f->name; }, $query->functions );
278 $errorMessage = sprintf(
279 "Error: the function '%s' does not exist. The available functions are: %s.",
280 $functionName,
281 implode( ', ', $availableFunctions )
282 );
283 Meow_MWAI_Logging::warn( $errorMessage );
284 $reply->needFeedbacks[$idx]['unknownFunction'] = $errorMessage;
285 }
286 }
287
288 // Group function calls by their source message to maintain proper context
289 // This ensures related function calls are processed together
290 $feedback_blocks = [];
291
292 // Special handling for Responses API - group all function calls together
293 // Check if we're using Responses API by looking at the query's previous response ID or reply ID
294 $isResponsesApi = false;
295
296 // Method 1: Check if query has a previous response ID from Responses API
297 if ( !empty( $query->previousResponseId ) && $this->core->responseIdManager->is_responses_api_id( $query->previousResponseId ) ) {
298 $isResponsesApi = true;
299 }
300
301 // Method 2: Check if the reply has a Responses API response ID
302 if ( !$isResponsesApi && !empty( $reply->id ) && $this->core->responseIdManager->is_responses_api_id( $reply->id ) ) {
303 $isResponsesApi = true;
304 }
305
306 // Method 3: Check the model tags for 'responses' tag
307 if ( !$isResponsesApi && !empty( $query->model ) ) {
308 $modelInfo = $this->retrieve_model_info( $query->model );
309 if ( $modelInfo && !empty( $modelInfo['tags'] ) && in_array( 'responses', $modelInfo['tags'] ) ) {
310 $isResponsesApi = true;
311 }
312 }
313
314 // Method 4: For OpenAI engine, check if we're already using Responses API
315 // This is important for models that use Responses API but don't have the tag
316 if ( !$isResponsesApi && method_exists( $this, 'should_use_responses_api' ) ) {
317 // This is an OpenAI engine, check if it should use Responses API
318 $isResponsesApi = $this->should_use_responses_api( $query->model );
319 }
320
321 // Debug: Log grouping information
322 if ( $queries_debug ) {
323 error_log( '[AI Engine Queries] Grouping ' . count( $reply->needFeedbacks ) . ' function calls' );
324 error_log( '[AI Engine Queries] Is Responses API: ' . ( $isResponsesApi ? 'yes' : 'no' ) );
325 error_log( '[AI Engine Queries] Detection methods:' );
326 error_log( '[AI Engine Queries] - previousResponseId: ' . ( $query->previousResponseId ?? 'null' ) );
327 error_log( '[AI Engine Queries] - reply->id: ' . ( $reply->id ?? 'null' ) );
328 error_log( '[AI Engine Queries] - model: ' . ( $query->model ?? 'null' ) );
329 error_log( '[AI Engine Queries] - method_exists should_use_responses_api: ' . ( method_exists( $this, 'should_use_responses_api' ) ? 'yes' : 'no' ) );
330 error_log( '[AI Engine Queries] - engine class: ' . get_class( $this ) );
331 if ( $isResponsesApi ) {
332 error_log( '[AI Engine Queries] All function calls will be grouped together for Responses API' );
333 }
334 }
335
336 foreach ( $reply->needFeedbacks as $idx => $needFeedback ) {
337 // For Responses API, use a single key to group all function calls together
338 $rawMessageKey = md5( serialize( $needFeedback['rawMessage'] ) );
339
340 if ( $queries_debug ) {
341 error_log( '[AI Engine Queries] Function call ' . $idx . ': ' . $needFeedback['name'] . ' (key: ' . substr( $rawMessageKey, 0, 8 ) . ')' );
342 }
343
344 // Initialize the feedback block for this rawMessage if it hasn't been initialized yet
345 if ( !isset( $feedback_blocks[$rawMessageKey] ) ) {
346 $feedback_blocks[$rawMessageKey] = [
347 'rawMessage' => $needFeedback['rawMessage'],
348 'feedbacks' => []
349 ];
350 }
351
352 // Unknown function: skip execution and hand the error back to the AI
353 // as this call's result, so it can retry with a valid name.
354 if ( isset( $needFeedback['unknownFunction'] ) ) {
355 $feedback_blocks[$rawMessageKey]['feedbacks'][] = [
356 'request' => $needFeedback,
357 'reply' => [ 'value' => $needFeedback['unknownFunction'] ]
358 ];
359 $reply->add_tool_call(
360 $needFeedback['name'] ?? 'unknown',
361 $needFeedback['arguments'] ?? [],
362 $needFeedback['unknownFunction'],
363 false
364 );
365 continue;
366 }
367
368 // Allow modifying function call arguments before execution
369 $needFeedback['arguments'] = apply_filters(
370 'mwai_function_call_params',
371 $needFeedback['arguments'],
372 $needFeedback,
373 $reply
374 );
375
376 // Get the value related to this feedback (usually, a function call).
377 // A function that throws must not kill the whole turn: feed the error
378 // back to the model as this function's result, so one failing tool does
379 // not lose the results that succeeded and the model can recover (report
380 // the failure, retry, or answer from what it has). Mirrors the
381 // Workspace/MCP path, which already returns tool crashes as a result.
382 // The two control-flow exceptions keep propagating: the Workspace throws
383 // ApprovalRequiredException here to pause the turn for user approval, and
384 // RefusedException carries a user-facing refusal.
385 $callSucceeded = true;
386 try {
387 $value = apply_filters( 'mwai_ai_feedback', null, $needFeedback, $reply );
388 }
389 catch ( Meow_MWAI_ApprovalRequiredException $e ) {
390 throw $e;
391 }
392 catch ( Meow_MWAI_RefusedException $e ) {
393 throw $e;
394 }
395 catch ( Exception $e ) {
396 Meow_MWAI_Logging::error( "The function '{$needFeedback['name']}' threw an exception: " . $e->getMessage() );
397 $value = "The function '{$needFeedback['name']}' failed with an error: " . $e->getMessage();
398 $callSucceeded = false;
399 }
400
401 if ( $value === null ) {
402 // Check if the function handler exists
403 if ( !has_filter( 'mwai_ai_feedback' ) ) {
404 Meow_MWAI_Logging::error(
405 Meow_MWAI_FunctionCallException::missing_function_handler(
406 $needFeedback['name']
407 )->getMessage()
408 );
409 }
410 else {
411 Meow_MWAI_Logging::warn( "The returned value for '{$needFeedback['name']}' was null." );
412 }
413 $value = '[NO VALUE RETURNED - DO NOT SHOW THIS]';
414 }
415
416 // Emit "Got result" event and log for debugging
417 if ( $this->currentDebugMode ) {
418 // Format the result preview
419 $resultPreview = is_array( $value ) ? json_encode( $value ) : (string) $value;
420 if ( strlen( $resultPreview ) > 100 ) {
421 $resultPreview = substr( $resultPreview, 0, 100 ) . '...';
422 }
423
424 // Log the function result for debugging
425 Meow_MWAI_Logging::log( "Function '{$needFeedback['name']}' returned: " . $resultPreview );
426
427 // Emit function result event if we have a callback
428 if ( !empty( $streamCallback ) ) {
429 // Load event helper if not already loaded
430 if ( !class_exists( 'Meow_MWAI_Event' ) ) {
431 require_once MWAI_PATH . '/classes/event.php';
432 }
433
434 $functionName = $needFeedback['name'];
435
436 $event = Meow_MWAI_Event::function_result( $functionName )
437 ->set_metadata( 'result', $resultPreview )
438 ->set_metadata( 'tool_id', $needFeedback['toolId'] ?? null );
439 call_user_func( $streamCallback, $event );
440 }
441 }
442
443 $reply->add_tool_call( $needFeedback['name'], $needFeedback['arguments'], $value, $callSucceeded );
444
445 // Add the feedback information to the appropriate feedback block
446 $feedback_blocks[$rawMessageKey]['feedbacks'][] = [
447 'request' => $needFeedback,
448 'reply' => [ 'value' => $value ]
449 ];
450 }
451
452 // Stateless providers (Anthropic, Chat Completions, classic Gemini)
453 // replay the whole tool exchange from the blocks on every request, so on
454 // recursion the earlier rounds must be KEPT and the new one appended.
455 // Resetting them every round made the model forget every result of the
456 // turn, re-issue the same calls, and trip the loop detector. Stateful
457 // providers (OpenAI Responses, Google Interactions) hold the prior
458 // rounds server-side via previousResponseId and must only receive the
459 // new results; Assistants runs also start fresh.
460 $keepBlocks = $query instanceof Meow_MWAI_Query_Feedback &&
461 isset( $query->blocks ) &&
462 !( !empty( $reply->id ) && $this->core->responseIdManager->is_stateful_conversation_id( $reply->id ) );
463 if ( !$keepBlocks ) {
464 $query->clear_feedback_blocks();
465 }
466 foreach ( $feedback_blocks as $feedback_block ) {
467 $query->add_feedback_block( $feedback_block );
468 }
469
470 // Log feedback query if debug is enabled
471 if ( $queries_debug ) {
472 error_log( '[AI Engine Queries] Created ' . count( $feedback_blocks ) . ' feedback blocks from ' . count( $reply->needFeedbacks ) . ' function calls' );
473 foreach ( $feedback_blocks as $key => $block ) {
474 error_log( '[AI Engine Queries] Block ' . substr( $key, 0, 8 ) . ' has ' . count( $block['feedbacks'] ) . ' feedbacks' );
475 }
476 }
477
478 // Static-only turn: functions executed above for their side effects;
479 // skip the recursive AI feedback so the original reply stands.
480 if ( $all_static ) {
481 if ( $queries_debug ) {
482 error_log( '[AI Engine Queries] All function calls are static. Skipping AI feedback round-trip.' );
483 }
484 return $reply;
485 }
486
487 // Run the feedback query. The recursion returns a brand new reply (the final,
488 // tool-free round), so the calls executed above would be lost with the old one.
489 // Prepend them to keep the whole turn's chain in chronological order.
490 $executedToolCalls = $reply->toolCalls;
491 $reply = $this->run( $query, $streamCallback, $maxDepth - 1 );
492 $reply->toolCalls = array_merge( $executedToolCalls, $reply->toolCalls );
493 }
494
495 return $reply;
496 }
497
498 public function retrieve_model_info( $model ) {
499 $models = $this->get_models();
500 foreach ( $models as $currentModel ) {
501 if ( $currentModel['model'] === $model ) {
502 return $currentModel;
503 }
504 }
505 return false;
506 }
507
508 public function final_checks( Meow_MWAI_Query_Base $query ) {
509 $query->final_checks();
510 //$found = false;
511
512 // Check if the model is available, except if it's an assistant
513 if ( !( $query instanceof Meow_MWAI_Query_Assistant ) ) {
514 // TODO: Remove the ft: bypass after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06). Until then we skip model validation for fine-tunes so user-created models still resolve.
515 if ( substr( $query->model, 0, 3 ) === 'ft:' ) {
516 return;
517 }
518 $model_info = $this->retrieve_model_info( $query->model );
519 if ( $model_info === false ) {
520 // Provide a more helpful error message for embeddings queries without a configured environment
521 if ( $query instanceof Meow_MWAI_Query_Embed && empty( $query->envId ) ) {
522 throw new Exception( __( 'No embeddings environment is configured. Please go to Settings > Default Environments for AI > Embeddings and select an environment.', 'ai-engine' ) );
523 }
524 throw new Exception( sprintf( __( "The model '%s' is not available.", 'ai-engine' ), $query->model ) );
525 }
526 if ( isset( $model_info['mode'] ) ) {
527 $query->mode = $model_info['mode'];
528 }
529 }
530 }
531
532 // Streamline the messages:
533 // - Concatenate consecutive model messages into a single message for the model role
534 // - Make sure the first message is a user message
535 // - Make sure the last message is a user message
536 protected function streamline_messages( $messages, $systemRole = 'assistant', $messageType = 'content' ) {
537 $processedMessages = [];
538 $lastRole = '';
539 $concatenatedText = '';
540
541 // Determine the way to access message content based on messageType
542 $getContent = function ( $message ) use ( $messageType ) {
543 if ( $messageType == 'parts' ) {
544 return $message['parts'][0]['text'];
545 }
546 else { // Default to 'content'
547 return $message['content'];
548 }
549 };
550
551 // Set content to a message depending on the messageType
552 $setContent = function ( &$message, $content ) use ( $messageType ) {
553 if ( $messageType == 'parts' ) {
554 $message['parts'] = [['text' => $content]];
555 }
556 else { // Default to 'content'
557 $message['content'] = $content;
558 }
559 };
560
561 // Concatenate consecutive model messages into a single message for the model role
562 foreach ( $messages as $message ) {
563 if ( $message['role'] == $systemRole ) {
564 if ( $lastRole == $systemRole ) {
565 $concatenatedText .= "\n" . $getContent( $message );
566 }
567 else {
568 if ( $concatenatedText !== '' ) {
569 $newMessage = [ 'role' => $systemRole ];
570 $setContent( $newMessage, $concatenatedText );
571 $processedMessages[] = $newMessage;
572 }
573 $concatenatedText = $getContent( $message );
574 }
575 }
576 else {
577 if ( $lastRole == $systemRole ) {
578 $newMessage = [ 'role' => $systemRole ];
579 $setContent( $newMessage, $concatenatedText );
580 $processedMessages[] = $newMessage;
581 $concatenatedText = '';
582 }
583 $processedMessages[] = $message;
584 }
585 $lastRole = $message['role'];
586 }
587 if ( $lastRole == $systemRole && $concatenatedText !== '' ) {
588 $newMessage = [ 'role' => $systemRole ];
589 $setContent( $newMessage, $concatenatedText );
590 $processedMessages[] = $newMessage;
591 }
592
593 // Make sure the last message is a user message, if not, throw an exception
594 if ( end( $processedMessages )['role'] !== 'user' ) {
595 throw new Exception( __( 'The last message must be a user message.', 'ai-engine' ) );
596 }
597
598 // Make sure the first message is a user message, if not, add an empty user message
599 if ( $processedMessages[0]['role'] !== 'user' ) {
600 $newMessage = [ 'role' => 'user' ];
601 $setContent( $newMessage, '' );
602 array_unshift( $processedMessages, $newMessage );
603 }
604
605 return $processedMessages;
606 }
607
608 // Check for a JSON-formatted error in the data, and throw an exception if it's the case.
609 public function stream_error_check( $data ) {
610 if ( strpos( $data, 'error' ) === false ) {
611 return;
612 }
613
614 $data = trim( $data );
615 $jsonPart = $data;
616 if ( strpos( $jsonPart, 'data:' ) === 0 ) {
617 $jsonPart = trim( substr( $jsonPart, strlen( 'data:' ) ) );
618 }
619
620 $json = json_decode( $jsonPart, true );
621 if ( json_last_error() !== JSON_ERROR_NONE ) {
622 return; // not valid JSON, nothing to do
623 }
624 // 1. OpenAI style: { error: {...} }
625 $error = null;
626 if ( isset( $json['error'] ) ) {
627 $error = $json['error'];
628 }
629 // 2. Google style: [ { error: {...} } ]
630 else if ( is_array( $json ) ) {
631 foreach ( $json as $item ) {
632 if ( isset( $item['error'] ) ) {
633 $error = $item['error'];
634 break;
635 }
636 }
637 }
638 // 3. Some APIs return { type: "error", message: ... }
639 else if ( isset( $json['type'] ) && $json['type'] === 'error' ) {
640 $error = $json;
641 }
642
643 if ( is_null( $error ) ) {
644 return;
645 }
646
647 $message = $error['message'] ?? ( is_string( $error ) ? $error : null );
648 $code = $error['code'] ?? null;
649 // Google uses "status" instead of "type" – accept both
650 $type = $error['type'] ?? ( $error['status'] ?? null );
651 if ( is_null( $message ) ) {
652 throw new Exception( 'Unknown error (stream_error_check).' );
653 }
654
655 $errorMessage = "Error: $message";
656 if ( !is_null( $code ) ) {
657 $errorMessage .= " ($code)";
658 }
659 if ( !is_null( $type ) ) {
660 $errorMessage .= " ($type)";
661 }
662
663 throw new Exception( $errorMessage );
664 }
665
666 protected function init_debug_mode( $query ) {
667 // Check if server debug mode or event logs are enabled in settings.
668 // The Workspace always gets rich stream events: it renders a live
669 // activity trail (function/MCP calls) from them.
670 $this->currentDebugMode = ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'server_debug_mode' ) ) ||
671 $this->core->get_option( 'event_logs' ) ||
672 ( isset( $query->scope ) && $query->scope === 'workspace' );
673 $this->currentQuery = $query;
674 }
675
676 public function stream_handler( $handle, $args, $url ) {
677 // Streaming requests carry the provider API key in the Authorization header,
678 // so they must verify certificates by default, exactly like the non-streaming
679 // path. This used to disable verification unconditionally, which silently
680 // overrode the 'sslverify' => MWAI_SSL_VERIFY the callers already pass.
681 // Only relax it when the site has explicitly opted out via MWAI_SSL_VERIFY.
682 if ( !MWAI_SSL_VERIFY ) {
683 curl_setopt( $handle, CURLOPT_SSL_VERIFYPEER, false );
684 curl_setopt( $handle, CURLOPT_SSL_VERIFYHOST, false );
685 }
686
687 curl_setopt( $handle, CURLOPT_WRITEFUNCTION, function ( $curl, $data ) use ( $url ) {
688 $length = strlen( $data );
689
690 // Log streaming data if queries debug is enabled
691 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
692 static $logged_url = false;
693 if ( $queries_debug && !$logged_url ) {
694 error_log( '[AI Engine Queries] Streaming from: ' . $url );
695 $logged_url = true;
696 }
697
698 // Bufferize the unfinished stream (if it's the case)
699 $this->streamTemporaryBuffer .= $data;
700 $this->streamBuffer .= $data;
701
702 // Error Management
703 $this->stream_error_check( $this->streamBuffer );
704
705 $lines = explode( "\n", $this->streamTemporaryBuffer );
706 if ( substr( $this->streamTemporaryBuffer, -1 ) !== "\n" ) {
707 $this->streamTemporaryBuffer = array_pop( $lines );
708 }
709 else {
710 $this->streamTemporaryBuffer = '';
711 }
712
713 foreach ( $lines as $line ) {
714 if ( $line === '' ) {
715 continue;
716 }
717 if ( strpos( $line, 'data:' ) === 0 ) {
718 $line = trim( substr( $line, 5 ) );
719 $json = json_decode( trim( $line ), true );
720
721 if ( json_last_error() === JSON_ERROR_NONE ) {
722 // Log individual streaming event if queries debug is enabled
723 static $event_count = 0;
724 if ( $queries_debug && $event_count < 10 ) {
725 // Log only the event type and key data, not the entire response
726 $event_log = [
727 'type' => $json['type'] ?? 'unknown'
728 ];
729
730 // Add specific details based on event type
731 if ( isset( $json['type'] ) ) {
732 if ( $json['type'] === 'response.output_item.added' && isset( $json['item'] ) ) {
733 $event_log['item_type'] = $json['item']['type'] ?? 'unknown';
734 $event_log['name'] = $json['item']['name'] ?? null;
735 $event_log['call_id'] = $json['item']['call_id'] ?? null;
736 }
737 elseif ( strpos( $json['type'], 'response.function_call' ) === 0 ) {
738 $event_log['call_id'] = $json['call_id'] ?? $json['item_id'] ?? null;
739 }
740 elseif ( $json['type'] === 'response.output_item.done' && isset( $json['item'] ) ) {
741 $event_log['item_type'] = $json['item']['type'] ?? 'unknown';
742 if ( isset( $json['item']['call_id'] ) ) {
743 $event_log['call_id'] = $json['item']['call_id'];
744 }
745 }
746 }
747
748 error_log( '[AI Engine Queries] Event: ' . json_encode( $event_log ) );
749 $event_count++;
750 }
751
752 $content = $this->stream_data_handler( $json );
753 if ( !is_null( $content ) ) {
754
755 // Check if content is an Event object
756 if ( is_object( $content ) && $content instanceof Meow_MWAI_Event ) {
757 // For Event objects, pass the object directly to callback
758 // Don't accumulate in streamContent as it's not regular text
759 call_user_func( $this->streamCallback, $content );
760 }
761 else if ( !empty( $content ) || $content === '0' ) {
762 // For regular string content - only process non-empty strings (but allow '0')
763
764 // TO CHECK: Not sure why we need to do this to make sure there is a line return in the chatbot
765 // If we don't do this, HuggingFace streams "\n" as a token without anything else, and the
766 // chatbot doesn't display it.
767 if ( $content === "\n" ) {
768 $content = " \n";
769 }
770
771 $this->streamContent .= $content;
772 call_user_func( $this->streamCallback, $content );
773 }
774 }
775 }
776 else if ( $line !== '[DONE]' && !empty( $line ) ) {
777 $this->streamTemporaryBuffer .= $line . "\n";
778 }
779 }
780 }
781 return $length;
782 } );
783 }
784
785 protected function stream_header_handler( $header ) {
786
787 }
788
789 protected function stream_data_handler( $json ) {
790 throw new Exception( 'Not implemented.' );
791 }
792
793 public function get_models() {
794 throw new Exception( 'Not implemented.' );
795 }
796
797 public function retrieve_models() {
798 throw new Exception( 'Not implemented.' );
799 }
800
801 public function run_completion_query( Meow_MWAI_Query_Base $query, $streamCallback = null ): Meow_MWAI_Reply {
802 throw new Exception( 'Not implemented.' );
803 }
804
805 public function run_assistant_query( Meow_MWAI_Query_Assistant $query, $streamCallback = null ): Meow_MWAI_Reply {
806 throw new Exception( 'Not implemented, or not supported in this version of AI Engine.' );
807 }
808
809 public function run_embedding_query( Meow_MWAI_Query_Base $query ) {
810 throw new Exception( 'Not implemented.' );
811 }
812
813 public function run_image_query( Meow_MWAI_Query_Base $query, $streamCallback = null ) {
814 throw new Exception( 'Not implemented.' );
815 }
816
817 public function run_editimage_query( Meow_MWAI_Query_Base $query ) {
818 throw new Exception( 'Not implemented.' );
819 }
820
821 public function run_transcribe_query( Meow_MWAI_Query_Base $query ) {
822 throw new Exception( 'Not implemented.' );
823 }
824
825 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
826 throw new Exception( 'Not implemented.' );
827 }
828
829 /**
830 * Check the connection to the AI service.
831 * This should be a minimal, cost-free API call to verify credentials and connectivity.
832 *
833 * @return array {
834 * @type bool $success Whether the connection test was successful
835 * @type string $service The service name (e.g., 'OpenAI', 'Anthropic')
836 * @type string $message A human-readable message about the test result
837 * @type array $details Additional service-specific details
838 * @type string $error Error message if the test failed
839 * }
840 */
841 public function connection_check() {
842 throw new Exception( 'Connection check not implemented for this service.' );
843 }
844 }
845