PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.6
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 weeks ago chatml.php 1 week ago core.php 1 month ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 1 week ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
google-interactions.php
621 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() {
50 return [
51 'Content-Type' => 'application/json',
52 'x-goog-api-key' => $this->apiKey,
53 ];
54 }
55
56 #region Request building
57
58 /**
59 * Build the "input" for the request.
60 * - Feedback queries carry function results back as function_result items.
61 * - When we have a previous interaction id, the server holds the history so we
62 * only send the new user turn.
63 * - Otherwise we send the conversation as Content[].
64 */
65 protected function user_step( $query ) {
66 $content = [];
67
68 // Attached images / files (vision).
69 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
70 foreach ( $attachments as $file ) {
71 $mime = $file->get_mimeType() ? $file->get_mimeType() : 'image/jpeg';
72 // Image content parts are flat: { type, data (base64), mime_type }.
73 $content[] = [
74 'type' => 'image',
75 'data' => $file->get_base64(),
76 'mime_type' => $mime,
77 ];
78 }
79
80 if ( $query->message !== '' || empty( $content ) ) {
81 $content[] = [ 'type' => 'text', 'text' => $query->message ];
82 }
83
84 return [ 'type' => 'user_input', 'content' => $content ];
85 }
86
87 /**
88 * The Interactions API is steps-based: input must be a step_list, not a
89 * role-based turn_list. So we emit user_input / model_output / function_result
90 * steps (the same step types the API returns).
91 */
92 protected function build_input( $query ) {
93 // Feedback: carry the function results back as function_result steps.
94 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
95 $steps = [];
96 foreach ( $query->blocks as $block ) {
97 foreach ( ( $block['feedbacks'] ?? [] ) as $feedback ) {
98 $value = $feedback['reply']['value'] ?? '';
99 if ( !is_string( $value ) ) {
100 $value = wp_json_encode( $value );
101 }
102 // The Interactions API expects `call_id` (matching the function_call
103 // step's id) and `result` as an array of content blocks, not a string.
104 $steps[] = [
105 'type' => 'function_result',
106 'call_id' => $feedback['request']['toolId'] ?? null,
107 'name' => $feedback['request']['name'] ?? '',
108 'result' => [ [ 'type' => 'text', 'text' => $value ] ],
109 ];
110 }
111 }
112 return $steps;
113 }
114
115 // The API is stateful: prior turns live server-side and are referenced via
116 // previous_interaction_id. Input steps are only NEW user/function turns, so
117 // we send a single user_input step. (Replaying past model_output as input is
118 // rejected with "value at top-level must be a list".)
119 return [ $this->user_step( $query ) ];
120 }
121
122 protected function build_tools( $query ) {
123 $tools = [];
124
125 // Custom WordPress / Code Engine functions -> function declarations.
126 // Each tool is flat: { type:function, name, description, parameters }.
127 if ( !empty( $query->functions ) ) {
128 foreach ( $query->functions as $function ) {
129 $decl = $function->serializeForOpenAI();
130 // The Interactions API validates `parameters` as a JSON Schema and is
131 // strict about it: a no-arg function's empty
132 // { type:object, properties:{}, required:[] } is rejected with the
133 // misleading "value at top-level must be a list". Send a bare object
134 // schema when there are no parameters, and drop an empty `required`
135 // list otherwise.
136 if ( empty( $function->parameters ) ) {
137 $decl['parameters'] = [ 'type' => 'object' ];
138 }
139 elseif ( empty( $decl['parameters']['required'] ) ) {
140 unset( $decl['parameters']['required'] );
141 }
142 $tools[] = array_merge( [ 'type' => 'function' ], $decl );
143 }
144 }
145
146 // Provider built-in tools. AI Engine uses the generic 'web_search' name
147 // across providers; for Gemini it maps to Google Search grounding (the
148 // classic generateContent engine maps it the same way). Google Maps
149 // grounding is named-places only for now (no lat/long context), so "near me"
150 // queries won't resolve but explicit places do.
151 //
152 // Google Search and Google Maps are mutually exclusive in one Interactions
153 // request (the API rejects "cannot be combined in the same request"). When a
154 // chatbot has both enabled, prefer Maps (the more specific opt-in tool).
155 if ( in_array( 'google_maps', $query->tools, true ) ) {
156 $tools[] = [ 'type' => 'google_maps' ];
157 }
158 elseif ( in_array( 'web_search', $query->tools, true ) ) {
159 $tools[] = [ 'type' => 'google_search' ];
160 }
161
162 return $tools;
163 }
164
165 protected function build_interaction_body( $query, $streamCallback = null ) {
166 $body = [
167 'model' => $query->model,
168 'input' => $this->build_input( $query ),
169 'store' => true,
170 ];
171
172 if ( !is_null( $streamCallback ) ) {
173 $body['stream'] = true;
174 }
175
176 if ( $this->has_previous_interaction( $query ) ) {
177 $body['previous_interaction_id'] = $query->previousResponseId;
178 }
179
180 if ( !empty( $query->instructions ) ) {
181 $body['system_instruction'] = $query->instructions;
182 }
183
184 $tools = $this->build_tools( $query );
185 if ( !empty( $tools ) ) {
186 $body['tools'] = $tools;
187 }
188
189 $generationConfig = [];
190 if ( $query->temperature !== null ) {
191 $generationConfig['temperature'] = $query->temperature;
192 }
193 if ( !empty( $query->maxTokens ) ) {
194 $generationConfig['max_output_tokens'] = $query->maxTokens;
195 }
196 if ( !empty( $generationConfig ) ) {
197 $body['generation_config'] = $generationConfig;
198 }
199
200 return apply_filters( 'mwai_google_interactions_body', $body, $query );
201 }
202
203 #endregion
204
205 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
206 $debug = $this->core->get_option( 'queries_debug_mode' );
207 $isStreaming = !is_null( $streamCallback );
208 $this->init_debug_mode( $query );
209
210 // Reset per-request streaming state (Core buffers + our accumulators).
211 $this->streamContent = '';
212 $this->streamTemporaryBuffer = '';
213 $this->streamBuffer = '';
214 $this->reset_stream();
215
216 if ( $isStreaming ) {
217 // The Core stream_handler hooks the curl WRITEFUNCTION and dispatches each
218 // SSE "data:" line to our stream_data_handler().
219 $this->streamCallback = $streamCallback;
220 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
221 }
222
223 $body = $this->build_interaction_body( $query, $streamCallback );
224 $url = trailingslashit( $this->endpoint ) . 'interactions';
225
226 if ( $debug ) {
227 error_log( '[AI Engine Queries] --> (Gemini Interactions) ' . $url );
228 error_log( wp_json_encode( $body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) );
229 }
230
231 $args = [
232 'headers' => $this->build_headers(),
233 'body' => wp_json_encode( $body ),
234 'timeout' => apply_filters( 'mwai_request_timeout', 120, $query ),
235 'sslverify' => MWAI_SSL_VERIFY,
236 ];
237 $tmpFile = null;
238 if ( $isStreaming ) {
239 // WordPress streams the response into this file; on error it holds the body.
240 $tmpFile = tempnam( sys_get_temp_dir(), 'mwai-gemini-stream-' );
241 $args['stream'] = true;
242 $args['filename'] = $tmpFile;
243 }
244
245 try {
246 $res = wp_remote_post( $url, $args );
247
248 if ( is_wp_error( $res ) ) {
249 throw new Exception( 'AI Engine (Gemini Interactions): ' . $res->get_error_message() );
250 }
251
252 $code = wp_remote_retrieve_response_code( $res );
253
254 if ( $isStreaming ) {
255 if ( $code < 200 || $code >= 300 ) {
256 // The error body went to the temp file rather than the response.
257 $errBody = ( $tmpFile && file_exists( $tmpFile ) )
258 ? file_get_contents( $tmpFile ) : wp_remote_retrieve_body( $res );
259 $errData = json_decode( $errBody, true );
260 $detail = $errData['error']['message'] ?? $errBody;
261 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail );
262 }
263 return $this->build_streaming_reply( $query );
264 }
265
266 $rawBody = wp_remote_retrieve_body( $res );
267 $data = json_decode( $rawBody, true );
268
269 if ( $debug ) {
270 error_log( '[AI Engine Queries] <-- (Gemini Interactions) HTTP ' . $code );
271 error_log( substr( $rawBody, 0, 4000 ) );
272 }
273
274 if ( $code < 200 || $code >= 300 ) {
275 $detail = $data['error']['message'] ?? $rawBody;
276 throw new Exception( 'AI Engine (Gemini Interactions) HTTP ' . $code . ': ' . $detail );
277 }
278
279 return $this->build_reply_from_interaction( $query, $data, $streamCallback );
280 }
281 finally {
282 if ( $isStreaming ) {
283 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
284 }
285 if ( $tmpFile && file_exists( $tmpFile ) ) {
286 unlink( $tmpFile );
287 }
288 }
289 }
290
291 public function reset_stream() {
292 $this->streamId = null;
293 $this->streamInTokens = null;
294 $this->streamOutTokens = null;
295 $this->streamToolCalls = [];
296 $this->streamImages = [];
297 }
298
299 /**
300 * Handle one parsed SSE "data:" payload from the Interactions stream. Returns a
301 * text string for text deltas (the Core accumulates it into streamContent and
302 * forwards to the chatbot), a Meow_MWAI_Event for thoughts, or null for
303 * bookkeeping events (function-call assembly, completion).
304 *
305 * The Core's stream_handler only forwards "data:" lines and drops the "event:"
306 * line, so we dispatch on the event_type carried inside the JSON payload.
307 */
308 protected function stream_data_handler( $json ) {
309 $type = $json['event_type'] ?? '';
310
311 if ( $type === 'step.start' ) {
312 $step = $json['step'] ?? [];
313 // A function call arrives as a single step.start with the complete call
314 // (id, name, arguments). Larger argument payloads may also stream as
315 // arguments_delta events, which we accumulate below.
316 if ( ( $step['type'] ?? '' ) === 'function_call' ) {
317 $index = $json['index'] ?? count( $this->streamToolCalls );
318 $args = $step['arguments'] ?? '';
319 $argsJson = is_string( $args ) ? $args : wp_json_encode( $args );
320 $this->streamToolCalls[$index] = [
321 'id' => $step['id'] ?? null,
322 'name' => $step['name'] ?? '',
323 'arguments' => $argsJson,
324 ];
325 // Surface the call in the event-log (gated like the function_result
326 // event Core emits, so nothing streams when Event Logs is off).
327 if ( $this->currentDebugMode && $this->streamCallback ) {
328 return Meow_MWAI_Event::function_calling( $step['name'] ?? '', $argsJson );
329 }
330 }
331 return null;
332 }
333
334 if ( $type === 'step.delta' ) {
335 $delta = $json['delta'] ?? [];
336 $dtype = $delta['type'] ?? '';
337 if ( $dtype === 'text' ) {
338 return $delta['text'] ?? '';
339 }
340 if ( $dtype === 'arguments_delta' ) {
341 $index = $json['index'] ?? array_key_last( $this->streamToolCalls );
342 if ( $index !== null && isset( $this->streamToolCalls[$index] ) ) {
343 $this->streamToolCalls[$index]['arguments'] .= $delta['arguments'] ?? '';
344 }
345 return null;
346 }
347 // Generated image (e.g. Gemini Flash Image): the delta carries mime_type +
348 // base64 data and has no "type" field. The base64 may stream in chunks, so
349 // accumulate per step index.
350 if ( isset( $delta['mime_type'], $delta['data'] ) ) {
351 $index = $json['index'] ?? count( $this->streamImages );
352 $isFirst = !isset( $this->streamImages[$index] );
353 if ( $isFirst ) {
354 $this->streamImages[$index] = [ 'mime_type' => $delta['mime_type'], 'data' => '' ];
355 }
356 $this->streamImages[$index]['data'] .= $delta['data'];
357 if ( $isFirst && $this->currentDebugMode && $this->streamCallback ) {
358 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['IMAGE_GEN'] );
359 $event->set_content( 'Image generated' );
360 return $event;
361 }
362 return null;
363 }
364 // Thought deltas carry an encrypted thought_signature, not readable text,
365 // so there is nothing to surface to the user; they are intentionally skipped.
366 return null;
367 }
368
369 if ( $type === 'interaction.completed' ) {
370 $interaction = $json['interaction'] ?? [];
371 if ( !empty( $interaction['id'] ) ) {
372 $this->streamId = $interaction['id'];
373 }
374 $usage = $interaction['usage'] ?? [];
375 if ( isset( $usage['total_input_tokens'] ) ) {
376 $this->streamInTokens = (int) $usage['total_input_tokens'];
377 }
378 if ( isset( $usage['total_output_tokens'] ) ) {
379 $this->streamOutTokens = (int) $usage['total_output_tokens'];
380 }
381 return null;
382 }
383
384 return null;
385 }
386
387 /**
388 * Build the Reply from the streaming accumulators once the SSE stream is done.
389 * Mirrors build_reply_from_interaction() but sources from the streamed state.
390 */
391 protected function build_streaming_reply( $query ) {
392 $reply = new Meow_MWAI_Reply( $query );
393 $choices = [];
394
395 foreach ( $this->streamToolCalls as $tc ) {
396 $args = $tc['arguments'];
397 $decoded = ( $args === '' || $args === null ) ? [] : json_decode( $args, true );
398 if ( json_last_error() !== JSON_ERROR_NONE ) {
399 $decoded = [];
400 }
401 $choices[] = [
402 'message' => [
403 'content' => null,
404 'tool_calls' => [ [
405 'id' => $tc['id'],
406 'type' => 'function',
407 'function' => [ 'name' => $tc['name'], 'arguments' => $decoded ],
408 ] ],
409 ],
410 ];
411 }
412
413 if ( $this->streamContent !== '' ) {
414 $choices[] = [ 'role' => 'assistant', 'text' => $this->streamContent ];
415 }
416
417 // Generated images become b64_json choices; set_choices() saves each one and
418 // appends the image markdown to the reply so the chatbot renders it.
419 foreach ( $this->streamImages as $img ) {
420 if ( !empty( $img['data'] ) ) {
421 $choices[] = [ 'b64_json' => $img['data'] ];
422 }
423 }
424
425 // Nothing to show (no text, image, or function call): surface a short
426 // fallback instead of a blank bubble. This happens e.g. when a text-only
427 // model is asked to edit an image, or the model declines a request.
428 if ( empty( $choices ) ) {
429 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
430 }
431
432 $reply->set_choices( $choices );
433
434 if ( !empty( $this->streamId ) ) {
435 $reply->set_id( $this->streamId );
436 }
437
438 if ( $this->streamInTokens !== null || $this->streamOutTokens !== null ) {
439 $recorded = $this->core->record_tokens_usage(
440 $query->model,
441 (int) ( $this->streamInTokens ?? 0 ),
442 (int) ( $this->streamOutTokens ?? 0 )
443 );
444 $reply->set_usage( $recorded );
445 }
446
447 return $reply;
448 }
449
450 /**
451 * Message shown when the model returns nothing renderable (no text, image, or
452 * function call), so the chatbot never displays a blank bubble. Filterable.
453 */
454 protected function empty_reply_fallback( $query ) {
455 return apply_filters(
456 'mwai_google_interactions_empty_reply',
457 __( "Sorry, I couldn't produce a response for that. Please try rephrasing your request.", 'ai-engine' ),
458 $query
459 );
460 }
461
462 /**
463 * Turn an Interaction resource (id, status, steps[], usage) into a Reply.
464 * Emits step events (thoughts, function calls) to the stream callback so the
465 * chatbot's event-log UI shows what the model did.
466 */
467 protected function build_reply_from_interaction( $query, $data, $streamCallback = null ) {
468 $reply = new Meow_MWAI_Reply( $query );
469
470 $choices = [];
471 $text = '';
472 foreach ( ( $data['steps'] ?? [] ) as $step ) {
473 $type = $step['type'] ?? '';
474
475 if ( $type === 'model_output' ) {
476 foreach ( ( $step['content'] ?? [] ) as $part ) {
477 if ( ( $part['type'] ?? '' ) === 'text' ) {
478 $text .= $part['text'] ?? '';
479 }
480 // Generated image part (mime_type + base64 data) -> b64_json choice.
481 elseif ( isset( $part['mime_type'], $part['data'] ) ) {
482 $choices[] = [ 'b64_json' => $part['data'] ];
483 }
484 }
485 }
486 elseif ( $type === 'thought' ) {
487 if ( !empty( $streamCallback ) ) {
488 $thought = '';
489 foreach ( ( $step['content'] ?? [] ) as $part ) {
490 $thought .= $part['text'] ?? '';
491 }
492 if ( $thought !== '' ) {
493 $event = new Meow_MWAI_Event( 'live', 'thinking' );
494 $event->set_content( $thought );
495 call_user_func( $streamCallback, $event );
496 }
497 }
498 }
499 elseif ( $type === 'function_call' ) {
500 $name = $step['name'] ?? '';
501 $args = $step['arguments'] ?? [];
502 if ( !empty( $streamCallback ) ) {
503 $event = Meow_MWAI_Event::function_calling( $name, is_string( $args ) ? $args : wp_json_encode( $args ) );
504 call_user_func( $streamCallback, $event );
505 }
506 // Map to a tool_calls choice so set_choices() builds the needFeedback,
507 // preserving the id (required for the function_result follow-up).
508 $choices[] = [
509 'message' => [
510 'content' => null,
511 'tool_calls' => [ [
512 'id' => $step['id'] ?? null,
513 'type' => 'function',
514 'function' => [ 'name' => $name, 'arguments' => $args ],
515 ] ],
516 ],
517 '_rawMessage' => $step,
518 ];
519 }
520 }
521
522 if ( $text !== '' ) {
523 $choices[] = [ 'role' => 'assistant', 'text' => $text ];
524 }
525
526 // Surface a short fallback rather than a blank reply when there is nothing
527 // to show (no text, image, or function call).
528 if ( empty( $choices ) ) {
529 $choices[] = [ 'role' => 'assistant', 'text' => $this->empty_reply_fallback( $query ) ];
530 }
531
532 $reply->set_choices( $choices );
533
534 // Stateful handle for the next turn.
535 if ( !empty( $data['id'] ) ) {
536 $reply->set_id( $data['id'] );
537 }
538
539 // Usage.
540 $usage = $data['usage'] ?? [];
541 if ( isset( $usage['total_input_tokens'] ) || isset( $usage['total_output_tokens'] ) ) {
542 $recorded = $this->core->record_tokens_usage(
543 $query->model,
544 (int) ( $usage['total_input_tokens'] ?? 0 ),
545 (int) ( $usage['total_output_tokens'] ?? 0 )
546 );
547 $reply->set_usage( $recorded );
548 }
549
550 return $reply;
551 }
552
553 /**
554 * Interaction ids look like "v1_Chd..." (the classic generateContent API is
555 * stateless and has no id). Used to decide whether to reuse server-side state.
556 */
557 protected function has_previous_interaction( $query ) {
558 return !empty( $query->previousResponseId )
559 && is_string( $query->previousResponseId )
560 && strpos( $query->previousResponseId, 'v1_' ) === 0;
561 }
562
563 /**
564 * Dynamic model list is shared with the classic Google engine (same /models
565 * endpoint and same API key). get_models() is also called during final_checks()
566 * to validate the requested model, so it must be implemented.
567 */
568 public function get_models() {
569 return $this->core->get_engine_models( 'google' );
570 }
571
572 /**
573 * Called by the framework to price a reply. The classic engine already prices
574 * every Google model and query type correctly (text, token-based Flash Image,
575 * and per-image Imagen), so reuse it rather than keep a second partial copy.
576 * Interaction usage carries prompt_tokens/completion_tokens/total_tokens, which
577 * is exactly what the classic pricing expects.
578 */
579 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
580 return $this->classic_engine()->get_price( $query, $reply );
581 }
582
583 /**
584 * The Interactions API only covers chat completions. Everything else still
585 * runs against the classic Google endpoints (same API key and env), so we lazily
586 * build the classic engine and delegate those operations to it.
587 */
588 protected function classic_engine() {
589 if ( $this->classicEngine === null ) {
590 $this->classicEngine = Meow_MWAI_Engines_Google::create( $this->core, $this->env );
591 }
592 return $this->classicEngine;
593 }
594
595 /**
596 * Fetching + tagging the model list (the "Refresh Models" action) is identical
597 * to the classic Google engine: same /models endpoint, same API key, same tag
598 * derivation (vision, functions, web_search...). Delegate to it rather than
599 * duplicate that logic, so refreshed Gemini models carry the right tools.
600 */
601 public function retrieve_models() {
602 return $this->classic_engine()->retrieve_models();
603 }
604
605 public function run_embedding_query( Meow_MWAI_Query_Base $query ) {
606 return $this->classic_engine()->run_embedding_query( $query );
607 }
608
609 public function run_image_query( Meow_MWAI_Query_Base $query, $streamCallback = null ) {
610 return $this->classic_engine()->run_image_query( $query, $streamCallback );
611 }
612
613 public function run_editimage_query( Meow_MWAI_Query_Base $query ) {
614 return $this->classic_engine()->run_editimage_query( $query );
615 }
616
617 public function run_transcribe_query( Meow_MWAI_Query_Base $query ) {
618 return $this->classic_engine()->run_transcribe_query( $query );
619 }
620 }
621