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 / google-interactions.php
ai-engine / classes / engines Last commit date
anthropic.php 2 days ago chatml.php 2 days ago core.php 23 hours 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
google-interactions.php
777 lines
1 <?php
2
3 /**
4 * Google Gemini — Interactions API engine.
5 *
6 * As of June 2026 the Interactions API is GA and Google's recommended interface
7 * (https://ai.google.dev/gemini-api/docs/interactions-overview). It is stateful
8 * (server-side history via previous_interaction_id), exposes Google's built-in
9 * tools (Google Search, Google Maps, URL context, code execution), and returns a
10 * chronological list of execution "steps" (model_output / function_call /
11 * function_result / thoughts).
12 *
13 * This is the DEFAULT engine for Google environments. The classic
14 * generateContent engine (Meow_MWAI_Engines_Google) remains the fallback,
15 * selected when "Use Standard API" (google_use_standard_api) is enabled in
16 * Settings > AI > General. The request/response field names are mapped from
17 * Google's docs and confirmed against the live API.
18 */
19 class Meow_MWAI_Engines_GoogleInteractions extends Meow_MWAI_Engines_Core {
20 protected $apiKey = null;
21 protected $endpoint = null;
22
23 // Streaming accumulators (collected from SSE events, used to build the Reply).
24 protected $streamId = null;
25 protected $streamInTokens = null;
26 protected $streamOutTokens = null;
27 protected $streamToolCalls = [];
28 protected $streamImages = [];
29
30 // The classic Google engine, used for everything the Interactions API does not
31 // cover (embeddings, image generation, transcription) and for model fetching.
32 protected $classicEngine = null;
33
34 public function __construct( $core, $env ) {
35 parent::__construct( $core, $env );
36 $this->set_environment();
37 }
38
39 protected function set_environment() {
40 $env = $this->env;
41 $this->apiKey = $env['apikey'];
42 $this->endpoint = apply_filters(
43 'mwai_google_endpoint',
44 'https://generativelanguage.googleapis.com/v1beta',
45 $this->env
46 );
47 }
48
49 protected function build_headers( $query = null ) {
50 // A query may carry a per-request API key override (set via inject_params);
51 // fall back to the environment key otherwise.
52 $apiKey = ( $query !== null && !empty( $query->apiKey ) ) ? $query->apiKey : $this->apiKey;
53 return [
54 'Content-Type' => 'application/json',
55 'x-goog-api-key' => $apiKey,
56 ];
57 }
58
59 #region Request building
60
61 /**
62 * Build the "input" for the request.
63 * - Feedback queries carry function results back as function_result items.
64 * - When we have a previous interaction id, the server holds the history so we
65 * only send the new user turn.
66 * - Otherwise we send the conversation as Content[].
67 */
68 protected function user_step( $query ) {
69 $content = [];
70
71 // Attached images / files (vision).
72 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
73 foreach ( $attachments as $file ) {
74 $mime = $file->get_mimeType() ? $file->get_mimeType() : 'image/jpeg';
75 // Image content parts are flat: { type, data (base64), mime_type }.
76 $content[] = [
77 'type' => 'image',
78 'data' => $file->get_base64(),
79 'mime_type' => $mime,
80 ];
81 }
82
83 if ( $query->message !== '' || empty( $content ) ) {
84 $content[] = [ 'type' => 'text', 'text' => $query->message ];
85 }
86
87 return [ 'type' => 'user_input', 'content' => $content ];
88 }
89
90 /**
91 * When a Gemini call fails in a way that usually means the chosen model is
92 * retired, gated to existing users, or otherwise unavailable, append a short
93 * actionable hint. Google keeps listing such models in /models (so they show
94 * up in the dropdown) but rejects them at use time, and the Interactions
95 * endpoint often reports them as an opaque "Internal error", leaving the user
96 * with no clue the model is simply dead. Only fires on model-shaped failures
97 * so it does not add noise to genuine bad requests.
98 */
99 private function model_unavailable_hint( $code, $detail, $model ) {
100 if ( empty( $model ) ) {
101 return '';
102 }
103 $looksModelRelated = $code == 404
104 || ( $code == 500 && stripos( (string) $detail, 'internal error' ) !== false )
105 || preg_match( '/no longer available|not found|is not (available|supported)|does not exist|unsupported model/i', (string) $detail );
106 if ( !$looksModelRelated ) {
107 return '';
108 }
109 return ' (The model "' . $model . '" may have been retired by Google or is not available on your plan. Try a newer Gemini model, such as Gemini 2.5 Flash.)';
110 }
111
112 /**
113 * The Interactions API is steps-based: input must be a step_list, not a
114 * role-based turn_list. So we emit user_input / model_output / function_result
115 * steps (the same step types the API returns).
116 */
117 protected function build_input( $query ) {
118 // Feedback: carry the function results back as function_result steps.
119 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
120 $steps = [];
121 foreach ( $query->blocks as $block ) {
122 foreach ( ( $block['feedbacks'] ?? [] ) as $feedback ) {
123 $value = $feedback['reply']['value'] ?? '';
124 if ( !is_string( $value ) ) {
125 $value = wp_json_encode( $value );
126 }
127 // `call_id` must match the function_call step's id. `result` is sent as
128 // a plain string (the value is already stringified above). The
129 // content-block-array form ([{type:text,text:...}]) is rejected by
130 // gemini-2.5 and 2.0 as "Multimodal function responses are not
131 // supported for this model", which silently broke function calling on
132 // those (common) models via this default engine; a plain string works
133 // on 2.5, 3 and the -latest models alike (verified live).
134 $steps[] = [
135 'type' => 'function_result',
136 'call_id' => $feedback['request']['toolId'] ?? null,
137 'name' => $feedback['request']['name'] ?? '',
138 'result' => $value,
139 ];
140 }
141 }
142 return $steps;
143 }
144
145 $steps = [];
146
147 // When continuing an interaction, prior turns live server-side (referenced by
148 // previous_interaction_id) so we only send the new user turn. On the FIRST
149 // turn the server has no state, so we must replay the conversation history
150 // ($query->messages) as user_input / model_output steps. Without this,
151 // restored discussions and any caller-supplied history are lost. The API
152 // accepts replayed model_output steps (verified live).
153 if ( !$this->has_previous_interaction( $query ) && !empty( $query->messages ) ) {
154 foreach ( $query->messages as $message ) {
155 $text = $message['content'] ?? '';
156 if ( !is_string( $text ) || $text === '' ) {
157 continue;
158 }
159 $role = $message['role'] ?? 'user';
160 $type = ( $role === 'assistant' || $role === 'model' ) ? 'model_output' : 'user_input';
161 $steps[] = [ 'type' => $type, 'content' => [ [ 'type' => 'text', 'text' => $text ] ] ];
162 }
163 }
164
165 $steps[] = $this->user_step( $query );
166 return $steps;
167 }
168
169 protected function build_tools( $query ) {
170 $tools = [];
171
172 // Custom WordPress / Code Engine functions -> function declarations.
173 // Each tool is flat: { type:function, name, description, parameters }.
174 if ( !empty( $query->functions ) ) {
175 foreach ( $query->functions as $function ) {
176 $decl = $function->serializeForOpenAI();
177 // The Interactions API validates `parameters` as a JSON Schema and is
178 // strict about it: a no-arg function's empty
179 // { type:object, properties:{}, required:[] } is rejected with the
180 // misleading "value at top-level must be a list". Send a bare object
181 // schema when there are no parameters, and drop an empty `required`
182 // list otherwise.
183 if ( !empty( $function->rawSchema ) ) {
184 // Raw-schema functions (MCP bridge) are already normalized; only
185 // collapse a propertyless schema to the bare object form above.
186 if ( $decl['parameters']['properties'] instanceof stdClass ) {
187 $decl['parameters'] = [ 'type' => 'object' ];
188 }
189 }
190 elseif ( empty( $function->parameters ) ) {
191 $decl['parameters'] = [ 'type' => 'object' ];
192 }
193 elseif ( empty( $decl['parameters']['required'] ) ) {
194 unset( $decl['parameters']['required'] );
195 }
196 $tools[] = array_merge( [ 'type' => 'function' ], $decl );
197 }
198 }
199
200 // Provider built-in tools. AI Engine uses the generic 'web_search' name
201 // across providers; for Gemini it maps to Google Search grounding (the
202 // classic generateContent engine maps it the same way). Google Maps
203 // grounding is named-places only for now (no lat/long context), so "near me"
204 // queries won't resolve but explicit places do.
205 //
206 // Google Search and Google Maps are mutually exclusive in one Interactions
207 // request (the API rejects "cannot be combined in the same request"). When a
208 // chatbot has both enabled, prefer Maps (the more specific opt-in tool).
209 if ( in_array( 'google_maps', $query->tools, true ) ) {
210 $tools[] = [ 'type' => 'google_maps' ];
211 }
212 elseif ( in_array( 'web_search', $query->tools, true ) ) {
213 $tools[] = [ 'type' => 'google_search' ];
214 }
215
216 return $tools;
217 }
218
219 protected function build_interaction_body( $query, $streamCallback = null ) {
220 $body = [
221 'model' => $query->model,
222 'input' => $this->build_input( $query ),
223 'store' => true,
224 ];
225
226 if ( !is_null( $streamCallback ) ) {
227 $body['stream'] = true;
228 }
229
230 if ( $this->has_previous_interaction( $query ) ) {
231 $body['previous_interaction_id'] = $query->previousResponseId;
232 }
233
234 // System instructions, plus any per-query context (RAG / content-aware /
235 // Smart Search). Context is re-retrieved each turn and is not part of the
236 // server-side history, so it must be sent on every request, not just the
237 // first one. Without this, embeddings and content-aware context are silently
238 // dropped on Gemini.
239 $instructions = !empty( $query->instructions ) ? $query->instructions : '';
240 if ( !empty( $query->context ) ) {
241 $framedContext = $this->core->frame_context( $query->context );
242 $instructions = trim( $instructions . "\n\n" . $framedContext );
243 }
244 if ( $instructions !== '' ) {
245 $body['system_instruction'] = $instructions;
246 }
247
248 $tools = $this->build_tools( $query );
249 if ( !empty( $tools ) ) {
250 $body['tools'] = $tools;
251 }
252
253 $generationConfig = [];
254 if ( $query->temperature !== null ) {
255 $generationConfig['temperature'] = $query->temperature;
256 }
257 if ( !empty( $query->maxTokens ) ) {
258 $generationConfig['max_output_tokens'] = $query->maxTokens;
259 }
260 if ( !empty( $generationConfig ) ) {
261 $body['generation_config'] = $generationConfig;
262 }
263
264 // Image-capable Gemini models (Flash Image) only return images when the
265 // request opts into image output via the top-level response_format; without
266 // it they answer in text ("I can't output an image in this chat"). The
267 // dedicated image-query path is unaffected (it runs on the classic engine).
268 if ( $this->model_supports_image_generation( $query->model ) ) {
269 $responseFormat = [ 'type' => 'image' ];
270 if ( !empty( $query->resolution ) ) {
271 $responseFormat['aspect_ratio'] = $query->resolution;
272 }
273 $body['response_format'] = $responseFormat;
274 }
275
276 return apply_filters( 'mwai_google_interactions_body', $body, $query );
277 }
278
279 /**
280 * Whether the model can emit images in a chat turn (the 'image-generation'
281 * feature, e.g. Gemini Flash Image). Used to opt the request into image
282 * output via response_format.
283 */
284 protected function model_supports_image_generation( $modelId ) {
285 $model = $this->core->find_model_data( $modelId, $this->env['id'] ?? null );
286 return !empty( $model['features'] )
287 && in_array( 'image-generation', $model['features'], true );
288 }
289
290 #endregion
291
292 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
293 // Non-image attachments (PDFs, documents) need the Gemini Files API upload
294 // handled by the classic engine's prepare_query() / build_messages() (Pro).
295 // The Interactions input format only carries inline image parts, so delegate
296 // those queries to the classic engine to preserve file handling.
297 if ( $this->has_non_image_attachment( $query ) ) {
298 return $this->classic_engine()->run_completion_query( $query, $streamCallback );
299 }
300
301 $debug = $this->core->get_option( 'queries_debug_mode' );
302 $isStreaming = !is_null( $streamCallback );
303 $this->init_debug_mode( $query );
304
305 // Reset per-request streaming state (Core buffers + our accumulators).
306 $this->streamContent = '';
307 $this->streamTemporaryBuffer = '';
308 $this->streamBuffer = '';
309 $this->reset_stream();
310
311 if ( $isStreaming ) {
312 // The Core stream_handler hooks the curl WRITEFUNCTION and dispatches each
313 // SSE "data:" line to our stream_data_handler().
314 $this->streamCallback = $streamCallback;
315 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
316 }
317
318 $body = $this->build_interaction_body( $query, $streamCallback );
319 $url = trailingslashit( $this->endpoint ) . 'interactions';
320
321 if ( $debug ) {
322 error_log( '[AI Engine Queries] --> (Gemini Interactions) ' . $url );
323 error_log( wp_json_encode( $body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
324 }
325
326 $args = [
327 'headers' => $this->build_headers( $query ),
328 'body' => wp_json_encode( $body ),
329 'timeout' => apply_filters( 'mwai_request_timeout', 120, $query ),
330 'sslverify' => MWAI_SSL_VERIFY,
331 ];
332 $tmpFile = null;
333 if ( $isStreaming ) {
334 // WordPress streams the response into this file; on error it holds the body.
335 $tmpFile = tempnam( sys_get_temp_dir(), 'mwai-gemini-stream-' );
336 $args['stream'] = true;
337 $args['filename'] = $tmpFile;
338 }
339
340 try {
341 $res = wp_remote_post( $url, $args );
342
343 if ( is_wp_error( $res ) ) {
344 throw new Exception( 'AI Engine (Gemini Interactions): ' . $res->get_error_message() );
345 }
346
347 $code = wp_remote_retrieve_response_code( $res );
348
349 if ( $isStreaming ) {
350 if ( $code < 200 || $code >= 300 ) {
351 // The error body went to the temp file rather than the response.
352 $errBody = ( $tmpFile && file_exists( $tmpFile ) )
353 ? file_get_contents( $tmpFile ) : wp_remote_retrieve_body( $res );
354 $errData = json_decode( $errBody, true );
355 $detail = $errData['error']['message'] ?? $errBody;
356 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail
357 . $this->model_unavailable_hint( $code, $detail, $query->model ) );
358 }
359 return $this->build_streaming_reply( $query );
360 }
361
362 $rawBody = wp_remote_retrieve_body( $res );
363 $data = json_decode( $rawBody, true );
364
365 if ( $debug ) {
366 error_log( '[AI Engine Queries] <-- (Gemini Interactions) HTTP ' . $code );
367 error_log( substr( $rawBody, 0, 4000 ) );
368 }
369
370 if ( $code < 200 || $code >= 300 ) {
371 $detail = $data['error']['message'] ?? $rawBody;
372 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail
373 . $this->model_unavailable_hint( $code, $detail, $query->model ) );
374 }
375
376 return $this->build_reply_from_interaction( $query, $data, $streamCallback );
377 }
378 finally {
379 if ( $isStreaming ) {
380 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
381 }
382 if ( $tmpFile && file_exists( $tmpFile ) ) {
383 unlink( $tmpFile );
384 }
385 }
386 }
387
388 public function reset_stream() {
389 $this->streamId = null;
390 $this->streamInTokens = null;
391 $this->streamOutTokens = null;
392 $this->streamToolCalls = [];
393 $this->streamImages = [];
394 }
395
396 /**
397 * Handle one parsed SSE "data:" payload from the Interactions stream. Returns a
398 * text string for text deltas (the Core accumulates it into streamContent and
399 * forwards to the chatbot), a Meow_MWAI_Event for thoughts, null for
400 * bookkeeping events (function-call assembly, completion), or throws on a
401 * provider error event.
402 *
403 * The Core's stream_handler only forwards "data:" lines and drops the "event:"
404 * line, so we dispatch on the event_type carried inside the JSON payload.
405 */
406 protected function stream_data_handler( $json ) {
407 $type = $json['event_type'] ?? '';
408
409 if ( isset( $json['error'] ) || $type === 'error' || $type === 'interaction.failed' ) {
410 $this->throw_stream_error( $json );
411 }
412
413 if ( $type === 'step.start' ) {
414 $step = $json['step'] ?? [];
415 // A function call arrives as a single step.start with the complete call
416 // (id, name, arguments). Larger argument payloads may also stream as
417 // arguments_delta events, which we accumulate below.
418 if ( ( $step['type'] ?? '' ) === 'function_call' ) {
419 $index = $json['index'] ?? count( $this->streamToolCalls );
420 $args = $step['arguments'] ?? '';
421 $argsJson = is_string( $args ) ? $args : wp_json_encode( $args );
422 $this->streamToolCalls[$index] = [
423 'id' => $step['id'] ?? null,
424 'name' => $step['name'] ?? '',
425 'arguments' => $argsJson,
426 ];
427 // Surface the call in the event-log (gated like the function_result
428 // event Core emits, so nothing streams when Event Logs is off).
429 if ( $this->currentDebugMode && $this->streamCallback ) {
430 return Meow_MWAI_Event::function_calling( $step['name'] ?? '', $argsJson );
431 }
432 }
433 return null;
434 }
435
436 if ( $type === 'step.delta' ) {
437 $delta = $json['delta'] ?? [];
438 $dtype = $delta['type'] ?? '';
439 if ( $dtype === 'text' ) {
440 return $delta['text'] ?? '';
441 }
442 if ( $dtype === 'arguments_delta' ) {
443 $index = $json['index'] ?? array_key_last( $this->streamToolCalls );
444 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
445 $this->streamToolCalls[$index]['arguments'] .= $delta['arguments'] ?? '';
446 }
447 return null;
448 }
449 // Generated image (e.g. Gemini Flash Image): the delta carries mime_type +
450 // base64 data and has no "type" field. The base64 may stream in chunks, so
451 // accumulate per step index.
452 if ( isset( $delta['mime_type'], $delta['data'] ) ) {
453 $index = $json['index'] ?? count( $this->streamImages );
454 $isFirst = !isset( $this->streamImages[$index] );
455 if ( $isFirst ) {
456 $this->streamImages[$index] = [ 'mime_type' => $delta['mime_type'], 'data' => '' ];
457 }
458 $this->streamImages[$index]['data'] .= $delta['data'];
459 if ( $isFirst && $this->currentDebugMode && $this->streamCallback ) {
460 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['IMAGE_GEN'] );
461 $event->set_content( 'Image generated' );
462 return $event;
463 }
464 return null;
465 }
466 // Thought deltas carry an encrypted thought_signature, not readable text,
467 // so there is nothing to surface to the user; they are intentionally skipped.
468 return null;
469 }
470
471 if ( $type === 'interaction.completed' ) {
472 $interaction = $json['interaction'] ?? [];
473 if ( !empty( $interaction['id'] ) ) {
474 $this->streamId = $interaction['id'];
475 }
476 $usage = $interaction['usage'] ?? [];
477 if ( isset( $usage['total_input_tokens'] ) ) {
478 $this->streamInTokens = (int) $usage['total_input_tokens'];
479 }
480 if ( isset( $usage['total_output_tokens'] ) ) {
481 $this->streamOutTokens = (int) $usage['total_output_tokens'];
482 }
483 return null;
484 }
485
486 return null;
487 }
488
489 protected function throw_stream_error( $json ) {
490 $error = $json['error'] ?? ( $json['interaction']['error'] ?? null );
491 $message = null;
492 $code = null;
493 $status = null;
494
495 if ( is_array( $error ) ) {
496 $message = $error['message'] ?? ( $error['reason'] ?? null );
497 $code = $error['code'] ?? null;
498 $status = $error['status'] ?? ( $error['type'] ?? null );
499 }
500 elseif ( is_string( $error ) ) {
501 $message = $error;
502 }
503
504 if ( empty( $message ) ) {
505 $message = $json['message'] ?? ( $json['error_message'] ?? 'Unknown stream error.' );
506 }
507
508 $detail = $message;
509 if ( !empty( $code ) ) {
510 $detail .= ' (' . $code . ')';
511 }
512 if ( !empty( $status ) ) {
513 $detail .= ' (' . $status . ')';
514 }
515
516 throw new Exception( 'AI Engine (Gemini Interactions) stream error: ' . $detail );
517 }
518
519 /**
520 * Build the Reply from the streaming accumulators once the SSE stream is done.
521 * Mirrors build_reply_from_interaction() but sources from the streamed state.
522 */
523 protected function build_streaming_reply( $query ) {
524 $reply = new Meow_MWAI_Reply( $query );
525 $choices = [];
526
527 foreach ( $this->streamToolCalls as $tc ) {
528 $args = $tc['arguments'];
529 $decoded = ( $args === '' || $args === null ) ? [] : json_decode( $args, true );
530 if ( json_last_error() !== JSON_ERROR_NONE ) {
531 $decoded = [];
532 }
533 $choices[] = [
534 'message' => [
535 'content' => null,
536 'tool_calls' => [ [
537 'id' => $tc['id'],
538 'type' => 'function',
539 'function' => [ 'name' => $tc['name'], 'arguments' => $decoded ],
540 ] ],
541 ],
542 ];
543 }
544
545 if ( $this->streamContent !== '' ) {
546 $choices[] = [ 'role' => 'assistant', 'text' => $this->streamContent ];
547 }
548
549 // Generated images become b64_json choices; set_choices() saves each one and
550 // appends the image markdown to the reply so the chatbot renders it.
551 foreach ( $this->streamImages as $img ) {
552 if ( !empty( $img['data'] ) ) {
553 $choices[] = [ 'b64_json' => $img['data'] ];
554 }
555 }
556
557 // Nothing to show (no text, image, or function call): surface a short
558 // fallback instead of a blank bubble. This happens e.g. when a text-only
559 // model is asked to edit an image, or the model declines a request.
560 if ( empty( $choices ) ) {
561 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
562 }
563
564 $reply->set_choices( $choices );
565
566 if ( !empty( $this->streamId ) ) {
567 $reply->set_id( $this->streamId );
568 }
569
570 if ( $this->streamInTokens !== null || $this->streamOutTokens !== null ) {
571 $recorded = $this->core->record_tokens_usage(
572 $query->model,
573 (int) ( $this->streamInTokens ?? 0 ),
574 (int) ( $this->streamOutTokens ?? 0 )
575 );
576 $reply->set_usage( $recorded );
577 }
578
579 return $reply;
580 }
581
582 /**
583 * Message shown when the model returns nothing renderable (no text, image, or
584 * function call), so the chatbot never displays a blank bubble. Filterable.
585 */
586 protected function empty_reply_fallback( $query ) {
587 return apply_filters(
588 'mwai_google_interactions_empty_reply',
589 __( "Sorry, I couldn't produce a response for that. Please try rephrasing your request.", 'ai-engine' ),
590 $query
591 );
592 }
593
594 /**
595 * Turn an Interaction resource (id, status, steps[], usage) into a Reply.
596 * Emits step events (thoughts, function calls) to the stream callback so the
597 * chatbot's event-log UI shows what the model did.
598 */
599 protected function build_reply_from_interaction( $query, $data, $streamCallback = null ) {
600 $reply = new Meow_MWAI_Reply( $query );
601
602 $choices = [];
603 $text = '';
604 foreach ( ( $data['steps'] ?? [] ) as $step ) {
605 $type = $step['type'] ?? '';
606
607 if ( $type === 'model_output' ) {
608 foreach ( ( $step['content'] ?? [] ) as $part ) {
609 if ( ( $part['type'] ?? '' ) === 'text' ) {
610 $text .= $part['text'] ?? '';
611 }
612 // Generated image part (mime_type + base64 data) -> b64_json choice.
613 elseif ( isset( $part['mime_type'], $part['data'] ) ) {
614 $choices[] = [ 'b64_json' => $part['data'] ];
615 }
616 }
617 }
618 elseif ( $type === 'thought' ) {
619 if ( !empty( $streamCallback ) ) {
620 $thought = '';
621 foreach ( ( $step['content'] ?? [] ) as $part ) {
622 $thought .= $part['text'] ?? '';
623 }
624 if ( $thought !== '' ) {
625 $event = new Meow_MWAI_Event( 'live', 'thinking' );
626 $event->set_content( $thought );
627 call_user_func( $streamCallback, $event );
628 }
629 }
630 }
631 elseif ( $type === 'function_call' ) {
632 $name = $step['name'] ?? '';
633 $args = $step['arguments'] ?? [];
634 if ( !empty( $streamCallback ) ) {
635 $event = Meow_MWAI_Event::function_calling( $name, is_string( $args ) ? $args : wp_json_encode( $args ) );
636 call_user_func( $streamCallback, $event );
637 }
638 // Map to a tool_calls choice so set_choices() builds the needFeedback,
639 // preserving the id (required for the function_result follow-up).
640 $choices[] = [
641 'message' => [
642 'content' => null,
643 'tool_calls' => [ [
644 'id' => $step['id'] ?? null,
645 'type' => 'function',
646 'function' => [ 'name' => $name, 'arguments' => $args ],
647 ] ],
648 ],
649 '_rawMessage' => $step,
650 ];
651 }
652 }
653
654 if ( $text !== '' ) {
655 $choices[] = [ 'role' => 'assistant', 'text' => $text ];
656 }
657
658 // Surface a short fallback rather than a blank reply when there is nothing
659 // to show (no text, image, or function call).
660 if ( empty( $choices ) ) {
661 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
662 }
663
664 $reply->set_choices( $choices );
665
666 // Stateful handle for the next turn.
667 if ( !empty( $data['id'] ) ) {
668 $reply->set_id( $data['id'] );
669 }
670
671 // Usage.
672 $usage = $data['usage'] ?? [];
673 if ( isset( $usage['total_input_tokens'] ) || isset( $usage['total_output_tokens'] ) ) {
674 $recorded = $this->core->record_tokens_usage(
675 $query->model,
676 (int) ( $usage['total_input_tokens'] ?? 0 ),
677 (int) ( $usage['total_output_tokens'] ?? 0 )
678 );
679 $reply->set_usage( $recorded );
680 }
681
682 return $reply;
683 }
684
685 /**
686 * Interaction ids look like "v1_Chd..." (the classic generateContent API is
687 * stateless and has no id). Used to decide whether to reuse server-side state.
688 */
689 protected function has_previous_interaction( $query ) {
690 return !empty( $query->previousResponseId )
691 && is_string( $query->previousResponseId )
692 && strpos( $query->previousResponseId, 'v1_' ) === 0;
693 }
694
695 /**
696 * Dynamic model list is shared with the classic Google engine (same /models
697 * endpoint and same API key). get_models() is also called during final_checks()
698 * to validate the requested model, so it must be implemented.
699 */
700 public function get_models() {
701 return $this->core->get_engine_models( 'google' );
702 }
703
704 /**
705 * Called by the framework to price a reply. The classic engine already prices
706 * every Google model and query type correctly (text, token-based Flash Image,
707 * and per-image Imagen), so reuse it rather than keep a second partial copy.
708 * Interaction usage carries prompt_tokens/completion_tokens/total_tokens, which
709 * is exactly what the classic pricing expects.
710 */
711 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
712 return $this->classic_engine()->get_price( $query, $reply );
713 }
714
715 /**
716 * The Interactions API only covers chat completions. Everything else still
717 * runs against the classic Google endpoints (same API key and env), so we lazily
718 * build the classic engine and delegate those operations to it.
719 */
720 protected function classic_engine() {
721 if ( $this->classicEngine === null ) {
722 $this->classicEngine = Meow_MWAI_Engines_Google::create( $this->core, $this->env );
723 }
724 return $this->classicEngine;
725 }
726
727 /**
728 * Whether the query carries an attachment that is not an inline image (e.g. a
729 * PDF or document). Those need the classic engine's Files API handling.
730 */
731 protected function has_non_image_attachment( $query ) {
732 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
733 foreach ( $attachments as $file ) {
734 $mime = $file->get_mimeType();
735 if ( !empty( $mime ) && strpos( $mime, 'image/' ) !== 0 ) {
736 return true;
737 }
738 }
739 return false;
740 }
741
742 /**
743 * The base engine's connection_check() throws "not implemented"; the test runs
744 * through the factory which now returns this engine for Google envs. The check
745 * (same /models endpoint + key) is identical to the classic engine, so delegate.
746 */
747 public function connection_check() {
748 return $this->classic_engine()->connection_check();
749 }
750
751 /**
752 * Fetching + tagging the model list (the "Refresh Models" action) is identical
753 * to the classic Google engine: same /models endpoint, same API key, same tag
754 * derivation (vision, functions, web_search...). Delegate to it rather than
755 * duplicate that logic, so refreshed Gemini models carry the right tools.
756 */
757 public function retrieve_models() {
758 return $this->classic_engine()->retrieve_models();
759 }
760
761 public function run_embedding_query( Meow_MWAI_Query_Base $query ) {
762 return $this->classic_engine()->run_embedding_query( $query );
763 }
764
765 public function run_image_query( Meow_MWAI_Query_Base $query, $streamCallback = null ) {
766 return $this->classic_engine()->run_image_query( $query, $streamCallback );
767 }
768
769 public function run_editimage_query( Meow_MWAI_Query_Base $query ) {
770 return $this->classic_engine()->run_editimage_query( $query );
771 }
772
773 public function run_transcribe_query( Meow_MWAI_Query_Base $query ) {
774 return $this->classic_engine()->run_transcribe_query( $query );
775 }
776 }
777