PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.2
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.2
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 months ago chatml.php 1 month ago core.php 1 month ago custom.php 1 month ago factory.php 1 month ago google.php 3 months ago mistral.php 5 months ago open-router.php 5 months ago openai.php 1 month ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
core.php
768 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.
136 $limits = $this->core->get_option( 'limits' );
137 $allowed = apply_filters( 'mwai_ai_allowed', true, $query, $limits );
138 if ( $allowed !== true ) {
139 $message = is_string( $allowed ) ? $allowed : 'Unauthorized query.';
140 throw new Exception( $message );
141 }
142
143 // Important as it makes sure everything is consolidated in the query and the engine.
144 $this->final_checks( $query );
145
146 // Run the query
147 $reply = null;
148 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
149 $reply = $this->run_completion_query( $query, $streamCallback );
150 }
151 else if ( $query instanceof Meow_MWAI_Query_Assistant || $query instanceof Meow_MWAI_Query_AssistFeedback ) {
152 $reply = $this->run_assistant_query( $query, $streamCallback );
153 if ( $reply === null ) {
154 throw new Exception( 'Assistants are not supported in this version of AI Engine.' );
155 }
156 }
157 else if ( $query instanceof Meow_MWAI_Query_Embed ) {
158 $reply = $this->run_embedding_query( $query );
159 }
160 else if ( $query instanceof Meow_MWAI_Query_EditImage ) {
161 $reply = $this->run_editimage_query( $query );
162 }
163 else if ( $query instanceof Meow_MWAI_Query_Image ) {
164 $reply = $this->run_image_query( $query, $streamCallback );
165 }
166 else if ( $query instanceof Meow_MWAI_Query_Transcribe ) {
167 $reply = $this->run_transcribe_query( $query );
168 }
169 else {
170 throw new Exception( 'Unknown query type.' );
171 }
172
173 // Allow to modify the reply before it is sent.
174 $reply = apply_filters( 'mwai_ai_reply', $reply, $query );
175
176 // Log query completion if debug is enabled
177 if ( $queries_debug && empty( $reply->needFeedbacks ) ) {
178 // For embedding queries, just log the dimensions count
179 if ( $query instanceof Meow_MWAI_Query_Embed && !empty( $reply->result ) && is_array( $reply->result ) ) {
180 error_log( '[AI Engine Queries] Embedding completed with ' . count( $reply->result ) . ' dimensions' );
181 }
182 else {
183 error_log( '[AI Engine Queries] Query completed' );
184 }
185 error_log( '[AI Engine Queries] ========================================' );
186 }
187
188 // Function Call Handling - This is where the magic happens!
189 // When the AI model requests function calls, we execute them and send results back
190 if ( !empty( $reply->needFeedbacks ) ) {
191
192 // Debug: Log how many needFeedbacks we have
193 if ( $queries_debug ) {
194 error_log( '[AI Engine Queries] Core: Processing ' . count( $reply->needFeedbacks ) . ' needFeedbacks' );
195 foreach ( $reply->needFeedbacks as $idx => $feedback ) {
196 error_log( '[AI Engine Queries] Core: needFeedback[' . $idx . ']: name=' . $feedback['name'] . ', toolId=' . ( $feedback['toolId'] ?? 'none' ) );
197 }
198 }
199
200 // Prevent infinite loops - each function call reduces maxDepth by 1
201 if ( $maxDepth <= 0 ) {
202 // Build call stack for better debugging
203 $callStack = [];
204 foreach ( $reply->needFeedbacks as $feedback ) {
205 $callStack[] = $feedback['name'] ?? 'unknown';
206 }
207
208 throw Meow_MWAI_FunctionCallException::loop_detected(
209 $query->_maxDepthConfigured ?? 5, // Use configured max depth
210 $callStack
211 );
212 }
213
214 // Create a feedback query if we're not already in one
215 // This wraps the original query with function execution results
216 if ( !( $query instanceof Meow_MWAI_Query_AssistFeedback ) && !( $query instanceof Meow_MWAI_Query_Feedback ) ) {
217 $queryClass = $query instanceof Meow_MWAI_Query_Assistant ?
218 Meow_MWAI_Query_AssistFeedback::class : Meow_MWAI_Query_Feedback::class;
219 // Note: $reply->query contains the original query that produced this reply
220 $query = new $queryClass( $reply, $reply->query );
221 }
222 else {
223 // Already inside a feedback chain (recursion depth >= 2). Each new reply produces a
224 // fresh response_id; the next request must reference THAT, not the original turn's id.
225 // Otherwise OpenAI's Responses API rejects with "No tool output found" because, from
226 // the original response's perspective, the new function_call_output answers a call
227 // that doesn't exist in its chain. Refresh previousResponseId from the latest reply
228 // so each recursion targets the immediately preceding turn.
229 if ( !empty( $reply->id ) ) {
230 $query->previousResponseId = $reply->id;
231 }
232 // Reset the per-turn feedback blocks; they'll be repopulated below from the new reply.
233 if ( method_exists( $query, 'clear_feedback_blocks' ) ) {
234 $query->clear_feedback_blocks();
235 }
236 }
237
238 // Determine whether every function call in this turn is "static" (no AI
239 // feedback wanted). If so, we still execute the functions below but
240 // skip the recursive AI round-trip — the AI's reply stands as-is.
241 // Mixed turns degrade to dynamic for all calls, since OpenAI/Anthropic
242 // tool-call protocols require a result for every requested call in the
243 // same message.
244 $all_static = true;
245 foreach ( $reply->needFeedbacks as $needFeedback ) {
246 $fn = $needFeedback['function'] ?? null;
247 if ( !$fn || !isset( $fn->behavior ) || $fn->behavior !== 'static' ) {
248 $all_static = false;
249 break;
250 }
251 }
252
253 // OpenAI's Responses API is stateful: when the next turn references previous_response_id,
254 // every function_call from the prior response must have a matching function_call_output,
255 // or the API rejects with "No tool output found for function call call_xxx". Skipping the
256 // round-trip on all-static turns satisfies the local user-facing reply but leaves the
257 // server-side conversation in a half-answered state, breaking any follow-up. So we
258 // disable the static-skip optimization for Responses API replies — the round-trip stays
259 // mandatory there. Chat Completions and Anthropic are stateless on tool calls and remain
260 // safe to skip.
261 if ( $all_static && ! empty( $reply->id ) && $this->core->responseIdManager->is_responses_api_id( $reply->id ) ) {
262 $all_static = false;
263 }
264
265 // Validate that all function calls have proper function definitions
266 foreach ( $reply->needFeedbacks as $needFeedback ) {
267 if ( !isset( $needFeedback['function'] ) ) {
268 $functionName = $needFeedback['name'] ?? 'unknown';
269 $availableFunctions = array_map( function ( $f ) { return $f->name; }, $query->functions );
270
271 throw new Exception( sprintf(
272 "Function '%s' not found in query functions. Available functions: %s",
273 $functionName,
274 implode( ', ', $availableFunctions )
275 ) );
276 }
277 }
278
279 // Group function calls by their source message to maintain proper context
280 // This ensures related function calls are processed together
281 $feedback_blocks = [];
282
283 // Special handling for Responses API - group all function calls together
284 // Check if we're using Responses API by looking at the query's previous response ID or reply ID
285 $isResponsesApi = false;
286
287 // Method 1: Check if query has a previous response ID from Responses API
288 if ( !empty( $query->previousResponseId ) && $this->core->responseIdManager->is_responses_api_id( $query->previousResponseId ) ) {
289 $isResponsesApi = true;
290 }
291
292 // Method 2: Check if the reply has a Responses API response ID
293 if ( !$isResponsesApi && !empty( $reply->id ) && $this->core->responseIdManager->is_responses_api_id( $reply->id ) ) {
294 $isResponsesApi = true;
295 }
296
297 // Method 3: Check the model tags for 'responses' tag
298 if ( !$isResponsesApi && !empty( $query->model ) ) {
299 $modelInfo = $this->retrieve_model_info( $query->model );
300 if ( $modelInfo && !empty( $modelInfo['tags'] ) && in_array( 'responses', $modelInfo['tags'] ) ) {
301 $isResponsesApi = true;
302 }
303 }
304
305 // Method 4: For OpenAI engine, check if we're already using Responses API
306 // This is important for models that use Responses API but don't have the tag
307 if ( !$isResponsesApi && method_exists( $this, 'should_use_responses_api' ) ) {
308 // This is an OpenAI engine, check if it should use Responses API
309 $isResponsesApi = $this->should_use_responses_api( $query->model );
310 }
311
312 // Debug: Log grouping information
313 if ( $queries_debug ) {
314 error_log( '[AI Engine Queries] Grouping ' . count( $reply->needFeedbacks ) . ' function calls' );
315 error_log( '[AI Engine Queries] Is Responses API: ' . ( $isResponsesApi ? 'yes' : 'no' ) );
316 error_log( '[AI Engine Queries] Detection methods:' );
317 error_log( '[AI Engine Queries] - previousResponseId: ' . ( $query->previousResponseId ?? 'null' ) );
318 error_log( '[AI Engine Queries] - reply->id: ' . ( $reply->id ?? 'null' ) );
319 error_log( '[AI Engine Queries] - model: ' . ( $query->model ?? 'null' ) );
320 error_log( '[AI Engine Queries] - method_exists should_use_responses_api: ' . ( method_exists( $this, 'should_use_responses_api' ) ? 'yes' : 'no' ) );
321 error_log( '[AI Engine Queries] - engine class: ' . get_class( $this ) );
322 if ( $isResponsesApi ) {
323 error_log( '[AI Engine Queries] All function calls will be grouped together for Responses API' );
324 }
325 }
326
327 foreach ( $reply->needFeedbacks as $idx => $needFeedback ) {
328 // For Responses API, use a single key to group all function calls together
329 $rawMessageKey = md5( serialize( $needFeedback['rawMessage'] ) );
330
331 if ( $queries_debug ) {
332 error_log( '[AI Engine Queries] Function call ' . $idx . ': ' . $needFeedback['name'] . ' (key: ' . substr( $rawMessageKey, 0, 8 ) . ')' );
333 }
334
335 // Initialize the feedback block for this rawMessage if it hasn't been initialized yet
336 if ( !isset( $feedback_blocks[$rawMessageKey] ) ) {
337 $feedback_blocks[$rawMessageKey] = [
338 'rawMessage' => $needFeedback['rawMessage'],
339 'feedbacks' => []
340 ];
341 }
342
343 // Allow modifying function call arguments before execution
344 $needFeedback['arguments'] = apply_filters(
345 'mwai_function_call_params',
346 $needFeedback['arguments'],
347 $needFeedback,
348 $reply
349 );
350
351 // Get the value related to this feedback (usually, a function call)
352 $value = apply_filters( 'mwai_ai_feedback', null, $needFeedback, $reply );
353
354 if ( $value === null ) {
355 // Check if the function handler exists
356 if ( !has_filter( 'mwai_ai_feedback' ) ) {
357 Meow_MWAI_Logging::error(
358 Meow_MWAI_FunctionCallException::missing_function_handler(
359 $needFeedback['name']
360 )->getMessage()
361 );
362 }
363 else {
364 Meow_MWAI_Logging::warn( "The returned value for '{$needFeedback['name']}' was null." );
365 }
366 $value = '[NO VALUE RETURNED - DO NOT SHOW THIS]';
367 }
368
369 // Emit "Got result" event and log for debugging
370 if ( $this->currentDebugMode ) {
371 // Format the result preview
372 $resultPreview = is_array( $value ) ? json_encode( $value ) : (string) $value;
373 if ( strlen( $resultPreview ) > 100 ) {
374 $resultPreview = substr( $resultPreview, 0, 100 ) . '...';
375 }
376
377 // Log the function result for debugging
378 Meow_MWAI_Logging::log( "Function '{$needFeedback['name']}' returned: " . $resultPreview );
379
380 // Emit function result event if we have a callback
381 if ( !empty( $streamCallback ) ) {
382 // Load event helper if not already loaded
383 if ( !class_exists( 'Meow_MWAI_Event' ) ) {
384 require_once MWAI_PATH . '/classes/event.php';
385 }
386
387 $functionName = $needFeedback['name'];
388
389 $event = Meow_MWAI_Event::function_result( $functionName )
390 ->set_metadata( 'result', $resultPreview )
391 ->set_metadata( 'tool_id', $needFeedback['toolId'] ?? null );
392 call_user_func( $streamCallback, $event );
393 }
394 }
395
396 // Add the feedback information to the appropriate feedback block
397 $feedback_blocks[$rawMessageKey]['feedbacks'][] = [
398 'request' => $needFeedback,
399 'reply' => [ 'value' => $value ]
400 ];
401 }
402
403 $query->clear_feedback_blocks();
404 foreach ( $feedback_blocks as $feedback_block ) {
405 $query->add_feedback_block( $feedback_block );
406 }
407
408 // Log feedback query if debug is enabled
409 if ( $queries_debug ) {
410 error_log( '[AI Engine Queries] Created ' . count( $feedback_blocks ) . ' feedback blocks from ' . count( $reply->needFeedbacks ) . ' function calls' );
411 foreach ( $feedback_blocks as $key => $block ) {
412 error_log( '[AI Engine Queries] Block ' . substr( $key, 0, 8 ) . ' has ' . count( $block['feedbacks'] ) . ' feedbacks' );
413 }
414 }
415
416 // Static-only turn: functions executed above for their side effects;
417 // skip the recursive AI feedback so the original reply stands.
418 if ( $all_static ) {
419 if ( $queries_debug ) {
420 error_log( '[AI Engine Queries] All function calls are static. Skipping AI feedback round-trip.' );
421 }
422 return $reply;
423 }
424
425 // Run the feedback query
426 $reply = $this->run( $query, $streamCallback, $maxDepth - 1 );
427 }
428
429 return $reply;
430 }
431
432 public function retrieve_model_info( $model ) {
433 $models = $this->get_models();
434 foreach ( $models as $currentModel ) {
435 if ( $currentModel['model'] === $model ) {
436 return $currentModel;
437 }
438 }
439 return false;
440 }
441
442 public function final_checks( Meow_MWAI_Query_Base $query ) {
443 $query->final_checks();
444 //$found = false;
445
446 // Check if the model is available, except if it's an assistant
447 if ( !( $query instanceof Meow_MWAI_Query_Assistant ) ) {
448 // 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.
449 if ( substr( $query->model, 0, 3 ) === 'ft:' ) {
450 return;
451 }
452 $model_info = $this->retrieve_model_info( $query->model );
453 if ( $model_info === false ) {
454 // Provide a more helpful error message for embeddings queries without a configured environment
455 if ( $query instanceof Meow_MWAI_Query_Embed && empty( $query->envId ) ) {
456 throw new Exception( __( 'No embeddings environment is configured. Please go to Settings > Default Environments for AI > Embeddings and select an environment.', 'ai-engine' ) );
457 }
458 throw new Exception( sprintf( __( "The model '%s' is not available.", 'ai-engine' ), $query->model ) );
459 }
460 if ( isset( $model_info['mode'] ) ) {
461 $query->mode = $model_info['mode'];
462 }
463 }
464 }
465
466 // Streamline the messages:
467 // - Concatenate consecutive model messages into a single message for the model role
468 // - Make sure the first message is a user message
469 // - Make sure the last message is a user message
470 protected function streamline_messages( $messages, $systemRole = 'assistant', $messageType = 'content' ) {
471 $processedMessages = [];
472 $lastRole = '';
473 $concatenatedText = '';
474
475 // Determine the way to access message content based on messageType
476 $getContent = function ( $message ) use ( $messageType ) {
477 if ( $messageType == 'parts' ) {
478 return $message['parts'][0]['text'];
479 }
480 else { // Default to 'content'
481 return $message['content'];
482 }
483 };
484
485 // Set content to a message depending on the messageType
486 $setContent = function ( &$message, $content ) use ( $messageType ) {
487 if ( $messageType == 'parts' ) {
488 $message['parts'] = [['text' => $content]];
489 }
490 else { // Default to 'content'
491 $message['content'] = $content;
492 }
493 };
494
495 // Concatenate consecutive model messages into a single message for the model role
496 foreach ( $messages as $message ) {
497 if ( $message['role'] == $systemRole ) {
498 if ( $lastRole == $systemRole ) {
499 $concatenatedText .= "\n" . $getContent( $message );
500 }
501 else {
502 if ( $concatenatedText !== '' ) {
503 $newMessage = [ 'role' => $systemRole ];
504 $setContent( $newMessage, $concatenatedText );
505 $processedMessages[] = $newMessage;
506 }
507 $concatenatedText = $getContent( $message );
508 }
509 }
510 else {
511 if ( $lastRole == $systemRole ) {
512 $newMessage = [ 'role' => $systemRole ];
513 $setContent( $newMessage, $concatenatedText );
514 $processedMessages[] = $newMessage;
515 $concatenatedText = '';
516 }
517 $processedMessages[] = $message;
518 }
519 $lastRole = $message['role'];
520 }
521 if ( $lastRole == $systemRole && $concatenatedText !== '' ) {
522 $newMessage = [ 'role' => $systemRole ];
523 $setContent( $newMessage, $concatenatedText );
524 $processedMessages[] = $newMessage;
525 }
526
527 // Make sure the last message is a user message, if not, throw an exception
528 if ( end( $processedMessages )['role'] !== 'user' ) {
529 throw new Exception( __( 'The last message must be a user message.', 'ai-engine' ) );
530 }
531
532 // Make sure the first message is a user message, if not, add an empty user message
533 if ( $processedMessages[0]['role'] !== 'user' ) {
534 $newMessage = [ 'role' => 'user' ];
535 $setContent( $newMessage, '' );
536 array_unshift( $processedMessages, $newMessage );
537 }
538
539 return $processedMessages;
540 }
541
542 // Check for a JSON-formatted error in the data, and throw an exception if it's the case.
543 public function stream_error_check( $data ) {
544 if ( strpos( $data, 'error' ) === false ) {
545 return;
546 }
547
548 $data = trim( $data );
549 $jsonPart = $data;
550 if ( strpos( $jsonPart, 'data:' ) === 0 ) {
551 $jsonPart = trim( substr( $jsonPart, strlen( 'data:' ) ) );
552 }
553
554 $json = json_decode( $jsonPart, true );
555 if ( json_last_error() !== JSON_ERROR_NONE ) {
556 return; // not valid JSON, nothing to do
557 }
558 // 1. OpenAI style: { error: {...} }
559 $error = null;
560 if ( isset( $json['error'] ) ) {
561 $error = $json['error'];
562 }
563 // 2. Google style: [ { error: {...} } ]
564 else if ( is_array( $json ) ) {
565 foreach ( $json as $item ) {
566 if ( isset( $item['error'] ) ) {
567 $error = $item['error'];
568 break;
569 }
570 }
571 }
572 // 3. Some APIs return { type: "error", message: ... }
573 else if ( isset( $json['type'] ) && $json['type'] === 'error' ) {
574 $error = $json;
575 }
576
577 if ( is_null( $error ) ) {
578 return;
579 }
580
581 $message = $error['message'] ?? ( is_string( $error ) ? $error : null );
582 $code = $error['code'] ?? null;
583 // Google uses "status" instead of "type" – accept both
584 $type = $error['type'] ?? ( $error['status'] ?? null );
585 if ( is_null( $message ) ) {
586 throw new Exception( 'Unknown error (stream_error_check).' );
587 }
588
589 $errorMessage = "Error: $message";
590 if ( !is_null( $code ) ) {
591 $errorMessage .= " ($code)";
592 }
593 if ( !is_null( $type ) ) {
594 $errorMessage .= " ($type)";
595 }
596
597 throw new Exception( $errorMessage );
598 }
599
600 protected function init_debug_mode( $query ) {
601 // Check if server debug mode or event logs are enabled in settings
602 $this->currentDebugMode = ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'server_debug_mode' ) ) || $this->core->get_option( 'event_logs' );
603 $this->currentQuery = $query;
604 }
605
606 public function stream_handler( $handle, $args, $url ) {
607 curl_setopt( $handle, CURLOPT_SSL_VERIFYPEER, false );
608 curl_setopt( $handle, CURLOPT_SSL_VERIFYHOST, false );
609
610 curl_setopt( $handle, CURLOPT_WRITEFUNCTION, function ( $curl, $data ) use ( $url ) {
611 $length = strlen( $data );
612
613 // Log streaming data if queries debug is enabled
614 $queries_debug = $this->core->get_option( 'queries_debug_mode' );
615 static $logged_url = false;
616 if ( $queries_debug && !$logged_url ) {
617 error_log( '[AI Engine Queries] Streaming from: ' . $url );
618 $logged_url = true;
619 }
620
621 // Bufferize the unfinished stream (if it's the case)
622 $this->streamTemporaryBuffer .= $data;
623 $this->streamBuffer .= $data;
624
625 // Error Management
626 $this->stream_error_check( $this->streamBuffer );
627
628 $lines = explode( "\n", $this->streamTemporaryBuffer );
629 if ( substr( $this->streamTemporaryBuffer, -1 ) !== "\n" ) {
630 $this->streamTemporaryBuffer = array_pop( $lines );
631 }
632 else {
633 $this->streamTemporaryBuffer = '';
634 }
635
636 foreach ( $lines as $line ) {
637 if ( $line === '' ) {
638 continue;
639 }
640 if ( strpos( $line, 'data:' ) === 0 ) {
641 $line = trim( substr( $line, 5 ) );
642 $json = json_decode( trim( $line ), true );
643
644 if ( json_last_error() === JSON_ERROR_NONE ) {
645 // Log individual streaming event if queries debug is enabled
646 static $event_count = 0;
647 if ( $queries_debug && $event_count < 10 ) {
648 // Log only the event type and key data, not the entire response
649 $event_log = [
650 'type' => $json['type'] ?? 'unknown'
651 ];
652
653 // Add specific details based on event type
654 if ( isset( $json['type'] ) ) {
655 if ( $json['type'] === 'response.output_item.added' && isset( $json['item'] ) ) {
656 $event_log['item_type'] = $json['item']['type'] ?? 'unknown';
657 $event_log['name'] = $json['item']['name'] ?? null;
658 $event_log['call_id'] = $json['item']['call_id'] ?? null;
659 }
660 elseif ( strpos( $json['type'], 'response.function_call' ) === 0 ) {
661 $event_log['call_id'] = $json['call_id'] ?? $json['item_id'] ?? null;
662 }
663 elseif ( $json['type'] === 'response.output_item.done' && isset( $json['item'] ) ) {
664 $event_log['item_type'] = $json['item']['type'] ?? 'unknown';
665 if ( isset( $json['item']['call_id'] ) ) {
666 $event_log['call_id'] = $json['item']['call_id'];
667 }
668 }
669 }
670
671 error_log( '[AI Engine Queries] Event: ' . json_encode( $event_log ) );
672 $event_count++;
673 }
674
675 $content = $this->stream_data_handler( $json );
676 if ( !is_null( $content ) ) {
677
678 // Check if content is an Event object
679 if ( is_object( $content ) && $content instanceof Meow_MWAI_Event ) {
680 // For Event objects, pass the object directly to callback
681 // Don't accumulate in streamContent as it's not regular text
682 call_user_func( $this->streamCallback, $content );
683 }
684 else if ( !empty( $content ) || $content === '0' ) {
685 // For regular string content - only process non-empty strings (but allow '0')
686
687 // TO CHECK: Not sure why we need to do this to make sure there is a line return in the chatbot
688 // If we don't do this, HuggingFace streams "\n" as a token without anything else, and the
689 // chatbot doesn't display it.
690 if ( $content === "\n" ) {
691 $content = " \n";
692 }
693
694 $this->streamContent .= $content;
695 call_user_func( $this->streamCallback, $content );
696 }
697 }
698 }
699 else if ( $line !== '[DONE]' && !empty( $line ) ) {
700 $this->streamTemporaryBuffer .= $line . "\n";
701 }
702 }
703 }
704 return $length;
705 } );
706 }
707
708 protected function stream_header_handler( $header ) {
709
710 }
711
712 protected function stream_data_handler( $json ) {
713 throw new Exception( 'Not implemented.' );
714 }
715
716 public function get_models() {
717 throw new Exception( 'Not implemented.' );
718 }
719
720 public function retrieve_models() {
721 throw new Exception( 'Not implemented.' );
722 }
723
724 public function run_completion_query( Meow_MWAI_Query_Base $query, $streamCallback = null ): Meow_MWAI_Reply {
725 throw new Exception( 'Not implemented.' );
726 }
727
728 public function run_assistant_query( Meow_MWAI_Query_Assistant $query, $streamCallback = null ): Meow_MWAI_Reply {
729 throw new Exception( 'Not implemented, or not supported in this version of AI Engine.' );
730 }
731
732 public function run_embedding_query( Meow_MWAI_Query_Base $query ) {
733 throw new Exception( 'Not implemented.' );
734 }
735
736 public function run_image_query( Meow_MWAI_Query_Base $query, $streamCallback = null ) {
737 throw new Exception( 'Not implemented.' );
738 }
739
740 public function run_editimage_query( Meow_MWAI_Query_Base $query ) {
741 throw new Exception( 'Not implemented.' );
742 }
743
744 public function run_transcribe_query( Meow_MWAI_Query_Base $query ) {
745 throw new Exception( 'Not implemented.' );
746 }
747
748 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
749 throw new Exception( 'Not implemented.' );
750 }
751
752 /**
753 * Check the connection to the AI service.
754 * This should be a minimal, cost-free API call to verify credentials and connectivity.
755 *
756 * @return array {
757 * @type bool $success Whether the connection test was successful
758 * @type string $service The service name (e.g., 'OpenAI', 'Anthropic')
759 * @type string $message A human-readable message about the test result
760 * @type array $details Additional service-specific details
761 * @type string $error Error message if the test failed
762 * }
763 */
764 public function connection_check() {
765 throw new Exception( 'Connection check not implemented for this service.' );
766 }
767 }
768