PluginProbe
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.6
3.7.7 3.7.6 3.7.5 3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 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 All 527 releases
ai-engine / classes / engines / google.php
google.php
1,913 lines 70.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 class Meow_MWAI_Engines_Google extends Meow_MWAI_Engines_Core {
4 // Base (Google).
5 protected $apiKey = null;
6 protected $endpoint = null;
7
8 // Response.
9 protected $inModel = null;
10 protected $inId = null;
11
12 // Static
13 private static $creating = false;
14
15 public static function create( $core, $env ) {
16 self::$creating = true;
17 if ( class_exists( 'MeowPro_MWAI_Google' ) ) {
18 $instance = new MeowPro_MWAI_Google( $core, $env );
19 }
20 else {
21 $instance = new self( $core, $env );
22 }
23 self::$creating = false;
24 return $instance;
25 }
26
27 /** Constructor. */
28 public function __construct( $core, $env ) {
29 $isOwnClass = get_class( $this ) === 'Meow_MWAI_Engines_Google';
30 if ( $isOwnClass && !self::$creating ) {
31 throw new Exception( 'Please use the create() method to instantiate the Meow_MWAI_Engines_Google class.' );
32 }
33 parent::__construct( $core, $env );
34 $this->set_environment();
35 }
36
37 /**
38 * Set environment variables based on $this->envType.
39 *
40 * @throws Exception If environment type is unknown.
41 */
42 protected function set_environment() {
43 $env = $this->env;
44 $this->apiKey = $env['apikey'];
45 if ( $this->envType === 'google' ) {
46 $this->endpoint = apply_filters(
47 'mwai_google_endpoint',
48 'https://generativelanguage.googleapis.com/v1beta',
49 $this->env
50 );
51 }
52 else {
53 throw new Exception( 'Unknown environment type: ' . $this->envType );
54 }
55 }
56
57 /**
58 * Check for a JSON-formatted error in the data, and throw an exception if present.
59 *
60 * @param string $data
61 * @throws Exception
62 */
63 public function check_for_error( $data ) {
64 if ( strpos( $data, 'error' ) === false ) {
65 return;
66 }
67 $jsonPart = ( strpos( $data, 'data: ' ) === 0 ) ? substr( $data, strlen( 'data: ' ) ) : $data;
68 $json = json_decode( $jsonPart, true );
69 if ( json_last_error() === JSON_ERROR_NONE && isset( $json['error'] ) ) {
70 $error = $json['error'];
71 $code = $error['code'];
72 $message = $error['message'];
73 throw new Exception( "Error $code: $message" );
74 }
75 }
76
77 /**
78 * Format function response for Google API
79 * Google expects the response to be an object, not a primitive value
80 */
81 protected function format_function_response( $value ) {
82 // If it's already an array or object, return as-is
83 if ( is_array( $value ) || is_object( $value ) ) {
84 return $value;
85 }
86
87 // For primitive values (string, number, boolean), wrap in an object
88 // This matches Google's expected format
89 return [ 'result' => (string) $value ];
90 }
91
92 /**
93 * Format a function call for internal usage.
94 *
95 * @param array $rawMessage
96 * @return array
97 */
98 protected function format_function_call( $rawMessage ) {
99 // If the message already has Google's format with role and parts
100 if ( isset( $rawMessage['role'] ) && isset( $rawMessage['parts'] ) &&
101 !isset( $rawMessage['content'] ) && !isset( $rawMessage['tool_calls'] ) && !isset( $rawMessage['function_call'] ) ) {
102 // Clean up any empty args arrays in functionCall parts
103 // IMPORTANT: Preserve thought_signature exactly as Google returns it (Gemini 3 requirement)
104 $cleanedMessage = $rawMessage;
105 if ( isset( $cleanedMessage['parts'] ) ) {
106 foreach ( $cleanedMessage['parts'] as &$part ) {
107 if ( isset( $part['functionCall'] ) && isset( $part['functionCall']['args'] ) ) {
108 // Remove empty args arrays - Google doesn't accept them
109 if ( empty( $part['functionCall']['args'] ) ) {
110 unset( $part['functionCall']['args'] );
111 }
112 }
113 // Note: thought_signature is preserved as-is (don't normalize to camelCase)
114 // Gemini 3 requires the exact format it returns
115 }
116 }
117 return $cleanedMessage;
118 }
119
120 $parts = [];
121
122 // Handle OpenAI-style tool_calls
123 if ( isset( $rawMessage['tool_calls'] ) ) {
124 foreach ( $rawMessage['tool_calls'] as $tool_call ) {
125 if ( $tool_call['type'] === 'function' ) {
126 $functionCall = [ 'name' => $tool_call['function']['name'] ];
127 $args = $tool_call['function']['arguments'];
128 if ( !empty( $args ) ) {
129 // If args is a JSON string, decode it
130 if ( is_string( $args ) ) {
131 $args = json_decode( $args, true );
132 }
133 if ( !empty( $args ) ) {
134 $functionCall['args'] = $args;
135 }
136 }
137 $parts[] = [ 'functionCall' => $functionCall ];
138 }
139 }
140 }
141 // Handle single function_call
142 elseif ( isset( $rawMessage['function_call'] ) ) {
143 $functionCall = [ 'name' => $rawMessage['function_call']['name'] ];
144 if ( isset( $rawMessage['function_call']['args'] ) ) {
145 // Handle args - could be array, object, or empty
146 $args = $rawMessage['function_call']['args'];
147 if ( !empty( $args ) ) {
148 $functionCall['args'] = $args;
149 }
150 // Don't include args field if it's empty
151 }
152 $parts[] = [ 'functionCall' => $functionCall ];
153 }
154
155 // Add text content if present
156 if ( isset( $rawMessage['content'] ) && !empty( $rawMessage['content'] ) ) {
157 $parts[] = [ 'text' => $rawMessage['content'] ];
158 }
159
160 // Return the original message if no function calls found, but ensure it's in Google format
161 if ( empty( $parts ) ) {
162 // Create a minimal valid Google format message
163 return [ 'role' => 'model', 'parts' => [ [ 'text' => '' ] ] ];
164 }
165
166 return [ 'role' => 'model', 'parts' => $parts ];
167 }
168
169 /**
170 * Build the messages for the Google API payload.
171 *
172 * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
173 * @return array
174 */
175 protected function build_messages( $query ) {
176 $messages = [];
177
178 // 1. Instructions (if any).
179 if ( !empty( $query->instructions ) ) {
180 $messages[] = [
181 'role' => 'model',
182 'parts' => [
183 [ 'text' => $query->instructions ]
184 ]
185 ];
186 }
187
188 // 2. Existing messages (already partially formatted).
189 foreach ( $query->messages as $message ) {
190
191 // Convert roles: 'assistant' => 'model', 'user' => 'user'.
192 $newMessage = [ 'role' => $message['role'], 'parts' => [] ];
193 if ( isset( $message['content'] ) ) {
194 $newMessage['parts'][] = [ 'text' => $message['content'] ];
195 }
196 if ( $newMessage['role'] === 'assistant' ) {
197 $newMessage['role'] = 'model';
198 }
199 $messages[] = $newMessage;
200 }
201
202 // 3. Context (if any).
203 if ( !empty( $query->context ) ) {
204 $framedContext = $this->core->frame_context( $query->context );
205 $messages[] = [
206 'role' => 'model',
207 'parts' => [
208 [ 'text' => $framedContext ]
209 ]
210 ];
211 }
212
213 // 4. The final user message (simple text only in free version).
214 // NOTE: Vision and file upload support is available in Pro version only.
215 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
216 if ( !empty( $attachments ) ) {
217 // Get first attachment (Gemini free version supports single file)
218 $file = $attachments[0];
219 $data = $file->get_base64();
220 $parts = [
221 [ 'inlineData' => [ 'mimeType' => 'image/jpeg', 'data' => $data ] ]
222 ];
223 // Gemini rejects empty text parts, so only add one when there is a message.
224 $message = $query->get_message();
225 if ( $message !== null && $message !== '' ) {
226 $parts[] = [ 'text' => $message ];
227 }
228 $messages[] = [
229 'role' => 'user',
230 'parts' => $parts
231 ];
232 // Gemini doesn't support multi-turn chat with Vision.
233 $messages = array_slice( $messages, -1 );
234 }
235 else {
236 $messages[] = [
237 'role' => 'user',
238 'parts' => [
239 [ 'text' => $query->get_message() ]
240 ]
241 ];
242 }
243
244 // 5. Streamline messages.
245 $messages = $this->streamline_messages( $messages, 'model', 'parts' );
246
247 // Debug: Log message count before feedback
248 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
249 error_log( '[AI Engine Queries] Messages before feedback: ' . count( $messages ) );
250 }
251
252 // 6. Feedback data for Meow_MWAI_Query_Feedback.
253 if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
254 foreach ( $query->blocks as $feedback_block ) {
255 // Debug logging of raw message
256 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
257 error_log( '[AI Engine Queries] Raw message before formatting: ' . json_encode( $feedback_block['rawMessage'] ) );
258 }
259
260 $formattedMessage = $this->format_function_call( $feedback_block['rawMessage'] );
261
262 // Debug logging of formatted message
263 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
264 error_log( '[AI Engine Queries] Formatted function call message: ' . json_encode( $formattedMessage ) );
265 }
266
267 // Check if Google returned multiple function calls but we only have one response
268 $functionCallCount = 0;
269 if ( isset( $formattedMessage['parts'] ) ) {
270 foreach ( $formattedMessage['parts'] as $part ) {
271 if ( isset( $part['functionCall'] ) ) {
272 $functionCallCount++;
273 }
274 }
275 }
276
277 if ( $functionCallCount > 1 && count( $feedback_block['feedbacks'] ) != $functionCallCount ) {
278 // Mismatch between function calls and responses
279 // Google requires exact matching of function calls to responses
280 $errorMsg = sprintf(
281 'Function call/response mismatch: Google returned %d function calls but we have %d response(s). ' .
282 'Google requires all function responses to be provided together.',
283 $functionCallCount,
284 count( $feedback_block['feedbacks'] )
285 );
286
287 // Log the error for debugging
288 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
289 error_log( '[AI Engine Queries] ERROR: ' . $errorMsg );
290
291 // Log which functions were called vs which were responded to
292 $calledFunctions = [];
293 foreach ( $formattedMessage['parts'] as $part ) {
294 if ( isset( $part['functionCall'] ) ) {
295 $calledFunctions[] = $part['functionCall']['name'] ?? 'unknown';
296 }
297 }
298 $respondedFunctions = array_map( function ( $fb ) {
299 return $fb['request']['name'] ?? 'unknown';
300 }, $feedback_block['feedbacks'] );
301
302 error_log( '[AI Engine Queries] Called functions: ' . implode( ', ', $calledFunctions ) );
303 error_log( '[AI Engine Queries] Responded functions: ' . implode( ', ', $respondedFunctions ) );
304 }
305
306 throw new Exception( $errorMsg );
307 }
308
309 $messages[] = $formattedMessage;
310 foreach ( $feedback_block['feedbacks'] as $feedback ) {
311 $functionResponseMessage = [
312 'role' => 'user',
313 'parts' => [
314 [
315 'functionResponse' => [
316 'name' => $feedback['request']['name'],
317 'response' => $this->format_function_response( $feedback['reply']['value'] )
318 ]
319 ]
320 ]
321 ];
322
323 // Debug logging of function response
324 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
325 error_log( '[AI Engine Queries] Function response: ' . json_encode( $functionResponseMessage ) );
326 }
327
328 $messages[] = $functionResponseMessage;
329 }
330 }
331 }
332
333 // Debug logging of all messages
334 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
335 error_log( '[AI Engine Queries] Total messages to Google: ' . count( $messages ) );
336 foreach ( $messages as $index => $message ) {
337 $role = $message['role'] ?? 'unknown';
338 $preview = $role;
339 if ( isset( $message['parts'][0] ) ) {
340 if ( isset( $message['parts'][0]['text'] ) ) {
341 $text = substr( $message['parts'][0]['text'], 0, 50 );
342 $preview .= ' (text: "' . $text . '...")';
343 }
344 elseif ( isset( $message['parts'][0]['functionCall'] ) ) {
345 $preview .= ' (functionCall: ' . $message['parts'][0]['functionCall']['name'] . ')';
346 }
347 elseif ( isset( $message['parts'][0]['functionResponse'] ) ) {
348 $preview .= ' (functionResponse: ' . $message['parts'][0]['functionResponse']['name'] . ')';
349 }
350 }
351 error_log( '[AI Engine Queries] Message[' . $index . ']: ' . $preview );
352 }
353 }
354
355 return $messages;
356 }
357
358 /**
359 * Build the body for the Google API request.
360 *
361 * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
362 * @param callable $streamCallback
363 * @return array
364 */
365 protected function build_body( $query, $streamCallback = null ) {
366 $body = [];
367
368 // Gemini 3 models don't support multiple candidates
369 $candidateCount = $query->maxResults;
370 if ( preg_match( '/gemini-3/', $query->model ) && $candidateCount > 1 ) {
371 $candidateCount = 1;
372 }
373
374 // Build generation config
375 $body['generationConfig'] = [
376 'candidateCount' => $candidateCount,
377 'maxOutputTokens' => $query->maxTokens,
378 'temperature' => $query->temperature,
379 'stopSequences' => []
380 ];
381
382 // Add tools if available
383 $hasTools = false;
384
385 // Check for functions
386 if ( !empty( $query->functions ) ) {
387 if ( !isset( $body['tools'] ) ) {
388 $body['tools'] = [];
389 }
390 $body['tools'][] = [ 'function_declarations' => [] ];
391 foreach ( $query->functions as $function ) {
392 $body['tools'][0]['function_declarations'][] = $function->serializeForOpenAI();
393 }
394 $body['tool_config'] = [
395 'function_calling_config' => [ 'mode' => 'AUTO' ]
396 ];
397 $hasTools = true;
398 }
399
400 // Check for web_search tool
401 if ( !empty( $query->tools ) && in_array( 'web_search', $query->tools ) ) {
402 if ( !isset( $body['tools'] ) ) {
403 $body['tools'] = [];
404 }
405 $body['tools'][] = [ 'google_search' => (object) [] ];
406 $hasTools = true;
407 }
408
409 // Check for thinking tool (Gemini 2.5+ models)
410 if ( !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
411 if ( !isset( $body['generationConfig']['thinkingConfig'] ) ) {
412 $body['generationConfig']['thinkingConfig'] = [];
413 }
414 // Use dynamic thinking by default (-1 lets the model decide)
415 $body['generationConfig']['thinkingConfig']['thinkingBudget'] = -1;
416
417 // Always include thought summaries when thinking is enabled
418 // This allows us to see thinking events in the UI
419 $body['generationConfig']['thinkingConfig']['includeThoughts'] = true;
420
421 // Log that thinking is enabled
422 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
423 error_log( '[AI Engine] Thinking tool enabled for Gemini with dynamic budget' );
424 }
425 }
426
427 // Build messages
428 $body['contents'] = $this->build_messages( $query );
429
430 // Note: Function result events are now emitted centrally in core.php
431 // when the function is actually executed
432
433 return $body;
434 }
435
436 /**
437 * Build headers for the request.
438 *
439 * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
440 * @throws Exception If no API Key is provided.
441 * @return array
442 */
443 protected function build_headers( $query ) {
444 if ( $query->apiKey ) {
445 $this->apiKey = $query->apiKey;
446 }
447 if ( empty( $this->apiKey ) ) {
448 throw new Exception( 'No API Key provided. Please visit the Settings. (Google Engine)' );
449 }
450 return [ 'Content-Type' => 'application/json' ];
451 }
452
453 /**
454 * Build WP remote request options.
455 *
456 * @param array $headers
457 * @param array $json
458 * @param array $forms
459 * @param string $method
460 * @throws Exception If form-data requests are used (unsupported).
461 * @return array
462 */
463 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
464 $body = null;
465 if ( !empty( $forms ) ) {
466 throw new Exception( 'No support for form-data requests yet.' );
467 }
468 else if ( !empty( $json ) ) {
469 $body = $this->safe_json_encode( $json, 'request body' );
470 }
471 return [
472 'headers' => $headers,
473 'method' => $method,
474 'timeout' => MWAI_TIMEOUT,
475 'body' => $body,
476 'sslverify' => MWAI_SSL_VERIFY
477 ];
478 }
479
480 /**
481 * Run the query against the Google endpoint.
482 *
483 * @param string $url
484 * @param array $options
485 * @throws Exception
486 * @return array
487 */
488 public function run_query( $url, $options ) {
489
490 try {
491 $res = wp_remote_get( $url, $options );
492 if ( is_wp_error( $res ) ) {
493 throw new Exception( $res->get_error_message() );
494 }
495 $response = wp_remote_retrieve_body( $res );
496 $headersRes = wp_remote_retrieve_headers( $res );
497 $headers = $headersRes->getAll();
498 $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
499 $resContentType = $normalizedHeaders['content-type'] ?? '';
500 if (
501 strpos( $resContentType, 'multipart/form-data' ) !== false ||
502 strpos( $resContentType, 'text/plain' ) !== false
503 ) {
504 return [
505 'headers' => $headers,
506 'data' => $response
507 ];
508 }
509 $data = json_decode( $response, true );
510 $this->handle_response_errors( $data );
511 return [ 'headers' => $headers, 'data' => $data ];
512 }
513 catch ( Exception $e ) {
514 Meow_MWAI_Logging::error( '(Google) ' . $e->getMessage() );
515 throw $e;
516 }
517 }
518
519 /**
520 * Run a completion query on the Google endpoint.
521 *
522 * @param Meow_MWAI_Query_Completion $query
523 * @throws Exception
524 * @return Meow_MWAI_Reply
525 */
526 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
527 // Reset request-specific state to prevent leakage between requests
528 $this->reset_request_state();
529
530 // Initialize debug mode
531 $this->init_debug_mode( $query );
532
533 // Build body using the new method which handles event emission
534 $body = $this->build_body( $query, $streamCallback );
535
536 $url = $this->endpoint . '/models/' . $query->model . ':generateContent';
537 if ( strpos( $url, '?' ) === false ) {
538 $url .= '?key=' . $this->apiKey;
539 }
540 else {
541 $url .= '&key=' . $this->apiKey;
542 }
543
544 $headers = $this->build_headers( $query );
545 $options = $this->build_options( $headers, $body );
546
547 // Emit "Request sent" event for feedback queries
548 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
549 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
550 $event = Meow_MWAI_Event::request_sent()
551 ->set_metadata( 'is_feedback', true )
552 ->set_metadata( 'feedback_count', count( $query->blocks ) );
553 call_user_func( $streamCallback, $event );
554 }
555
556 try {
557 $res = $this->run_query( $url, $options );
558
559 $reply = new Meow_MWAI_Reply( $query );
560
561 $data = $res['data'];
562 if ( empty( $data ) ) {
563 throw new Exception( 'No content received (res is null).' );
564 }
565
566 $returned_choices = [];
567 if ( isset( $data['candidates'] ) ) {
568 // Debug: Log if we're using thinking
569 if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
570 error_log( '[AI Engine] Processing response with thinking enabled' );
571 if ( isset( $data['candidates'][0] ) ) {
572 error_log( '[AI Engine] Full candidate structure: ' . json_encode( $data['candidates'][0] ) );
573 }
574 }
575
576 foreach ( $data['candidates'] as $candidate ) {
577 $content = $candidate['content'];
578
579 // Check if there are any parts with function calls
580 $functionCalls = [];
581 $textContent = '';
582 $hasGeneratedImage = false;
583
584 if ( isset( $content['parts'] ) ) {
585 // Debug: Log the parts structure when debug mode is enabled and there are function calls
586 $hasFunctionCalls = false;
587 foreach ( $content['parts'] as $checkPart ) {
588 if ( isset( $checkPart['functionCall'] ) ) {
589 $hasFunctionCalls = true;
590 break;
591 }
592 }
593 if ( $this->core->get_option( 'queries_debug_mode' ) && $hasFunctionCalls ) {
594 error_log( '[AI Engine Queries] Google response parts with function calls: ' . json_encode( $content['parts'] ) );
595 // Check for thoughtSignature in parts
596 foreach ( $content['parts'] as $debugPart ) {
597 if ( isset( $debugPart['thoughtSignature'] ) || isset( $debugPart['thought_signature'] ) ) {
598 error_log( '[AI Engine Queries] Found thoughtSignature in response' );
599 }
600 }
601 }
602
603 foreach ( $content['parts'] as $part ) {
604 if ( isset( $part['functionCall'] ) ) {
605 $functionCalls[] = $part['functionCall'];
606
607 // Emit function calling event if debug mode is enabled
608 if ( $this->currentDebugMode && !empty( $streamCallback ) ) {
609 $functionName = $part['functionCall']['name'] ?? 'unknown';
610 $functionArgs = isset( $part['functionCall']['args'] ) ? json_encode( $part['functionCall']['args'] ) : '';
611
612 $event = Meow_MWAI_Event::function_calling( $functionName, $functionArgs );
613 call_user_func( $streamCallback, $event );
614 }
615 }
616 elseif ( ( isset( $part['inline_data'] ) && isset( $part['inline_data']['data'] ) ) ||
617 ( isset( $part['inlineData'] ) && isset( $part['inlineData']['data'] ) ) ) {
618 // Handle both snake_case and camelCase
619 $imageData = isset( $part['inline_data'] ) ? $part['inline_data'] : $part['inlineData'];
620
621 // Detected an inline image in the response - emit image generation event
622 if ( !$hasGeneratedImage && !empty( $streamCallback ) ) {
623 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['IMAGE_GEN'] );
624 $event->set_content( 'Image generated' );
625 call_user_func( $streamCallback, $event );
626 $hasGeneratedImage = true;
627 }
628
629 // Store the image data in the reply
630 $base64Data = $imageData['data'];
631 $mimeType = $imageData['mimeType'] ?? 'image/png';
632 $dataUrl = 'data:' . $mimeType . ';base64,' . $base64Data;
633
634 // Add to extra data for potential processing
635 if ( !isset( $reply->extraData['images'] ) ) {
636 $reply->extraData['images'] = [];
637 }
638 $reply->extraData['images'][] = $dataUrl;
639 }
640 elseif ( isset( $part['text'] ) ) {
641 // Check if this is a thought part (Gemini thinking)
642 if ( isset( $part['thought'] ) && $part['thought'] === true ) {
643 // Emit thought event if streaming is available
644 if ( !empty( $streamCallback ) ) {
645 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['THINKING'] );
646 $event->set_content( $part['text'] );
647 call_user_func( $streamCallback, $event );
648 }
649 // Store thought summaries in reply metadata
650 if ( !isset( $reply->extraData['thoughts'] ) ) {
651 $reply->extraData['thoughts'] = [];
652 }
653 $reply->extraData['thoughts'][] = $part['text'];
654 }
655 else {
656 // Regular text content
657 $textContent .= $part['text'];
658 }
659 }
660 }
661 }
662
663 // If we have function calls, return them in Google's expected format
664 if ( !empty( $functionCalls ) ) {
665 // Debug: Log when we find multiple function calls
666 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
667 error_log( '[AI Engine Queries] Google returned ' . count( $functionCalls ) . ' function calls in one response' );
668 foreach ( $functionCalls as $idx => $fc ) {
669 error_log( '[AI Engine Queries] Function call[' . $idx . ']: ' . $fc['name'] );
670 }
671 }
672
673 // Google can return multiple function calls that need to be executed together
674 // When this happens, we create separate choices but they share the same rawMessage
675 $sharedRawMessage = $content; // The original Google response
676
677 foreach ( $functionCalls as $function_call ) {
678 $returned_choices[] = [
679 'message' => [
680 'content' => null,
681 'function_call' => $function_call
682 ],
683 '_rawMessage' => $sharedRawMessage // Store for later use
684 ];
685 }
686 }
687
688 // Add text content if present (separate from function calls)
689 if ( !empty( $textContent ) ) {
690 $returned_choices[] = [ 'role' => 'assistant', 'text' => $textContent ];
691 }
692 }
693 }
694
695 // Create a proper Google-formatted rawMessage for the function calls
696 $googleRawMessage = null;
697 if ( isset( $data['candidates'][0]['content'] ) ) {
698 $googleRawMessage = $data['candidates'][0]['content'];
699 }
700
701 // Add images from extraData to choices if present (for compatibility with image handling)
702 if ( !empty( $reply->extraData['images'] ) ) {
703 foreach ( $reply->extraData['images'] as $imageDataUrl ) {
704 // Extract base64 data from data URL if needed
705 if ( strpos( $imageDataUrl, 'data:' ) === 0 ) {
706 // Extract base64 portion from data URL
707 $base64Part = substr( $imageDataUrl, strpos( $imageDataUrl, ',' ) + 1 );
708 $returned_choices[] = [ 'b64_json' => $base64Part ];
709 }
710 else {
711 // Already in base64 format
712 $returned_choices[] = [ 'b64_json' => $imageDataUrl ];
713 }
714 }
715 }
716
717 $reply->set_choices( $returned_choices, $googleRawMessage );
718
719 // Handle grounding metadata if present (from web search)
720 if ( isset( $data['candidates'][0]['groundingMetadata'] ) ) {
721 $groundingMetadata = $data['candidates'][0]['groundingMetadata'];
722
723 // Add grounding metadata to the reply for potential use
724 $reply->extraData['groundingMetadata'] = $groundingMetadata;
725
726 // If debug mode is enabled and we have a stream callback, emit web search events
727 if ( $this->currentDebugMode && !empty( $streamCallback ) && isset( $groundingMetadata['searchQueries'] ) ) {
728 foreach ( $groundingMetadata['searchQueries'] as $searchQuery ) {
729 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['WEB_SEARCH'] );
730 $event->set_content( 'Searching: ' . $searchQuery );
731 call_user_func( $streamCallback, $event );
732 }
733 }
734 }
735
736 // Debug: Check how many feedbacks were created
737 if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $reply->needFeedbacks ) ) {
738 error_log( '[AI Engine Queries] Google reply has ' . count( $reply->needFeedbacks ) . ' needFeedbacks' );
739 foreach ( $reply->needFeedbacks as $idx => $feedback ) {
740 error_log( '[AI Engine Queries] Feedback[' . $idx . ']: ' . $feedback['name'] );
741 }
742 }
743
744 // Handle usage metadata including thinking tokens if present
745 if ( isset( $data['usageMetadata'] ) ) {
746 $usageMetadata = $data['usageMetadata'];
747
748 // Extract thinking tokens if available
749 if ( isset( $usageMetadata['thoughtsTokenCount'] ) ) {
750 $reply->extraData['thoughtsTokenCount'] = $usageMetadata['thoughtsTokenCount'];
751
752 // Log thinking tokens in debug mode
753 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
754 error_log( '[AI Engine Queries] Thinking tokens used: ' . $usageMetadata['thoughtsTokenCount'] );
755 }
756 }
757
758 // Pass token counts if available
759 $inTokens = isset( $usageMetadata['promptTokenCount'] ) ? $usageMetadata['promptTokenCount'] : null;
760 $outTokens = isset( $usageMetadata['candidatesTokenCount'] ) ? $usageMetadata['candidatesTokenCount'] : null;
761 $this->handle_tokens_usage( $reply, $query, $query->model, $inTokens, $outTokens );
762 }
763 else {
764 $this->handle_tokens_usage( $reply, $query, $query->model, null, null );
765 }
766
767 return $reply;
768 }
769 catch ( Exception $e ) {
770 // Add more context for common Google errors
771 $errorMessage = $e->getMessage();
772
773 if ( strpos( $errorMessage, 'number of function response parts is equal to the number of function call parts' ) !== false ) {
774 $errorMessage = 'Google requires all function responses to match the number of function calls. ' .
775 'This error typically occurs when there is a mismatch between the number of ' .
776 'function calls made by the AI and the number of responses provided.';
777 }
778
779 Meow_MWAI_Logging::error( '(Google) ' . $errorMessage );
780 throw new Exception( 'From Google: ' . $errorMessage );
781 }
782 }
783
784 /**
785 * Handle usage tokens.
786 */
787 public function handle_tokens_usage( $reply, $query, $returned_model, $returned_in_tokens, $returned_out_tokens ) {
788 $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens : $reply->get_in_tokens( $query );
789 $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens : $reply->get_out_tokens();
790 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
791 $reply->set_usage( $usage );
792
793 // Set accuracy based on data availability
794 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
795 // Google provides token counts from API = tokens accuracy
796 $reply->set_usage_accuracy( 'tokens' );
797 }
798 else {
799 // Fallback to estimated
800 $reply->set_usage_accuracy( 'estimated' );
801 }
802 }
803
804 /**
805 * Check if there are errors in the response from Google, and throw an exception if so.
806 *
807 * @param array $data
808 * @throws Exception
809 */
810 public function handle_response_errors( $data ) {
811 if ( isset( $data['error'] ) ) {
812 $message = $data['error']['message'];
813 if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
814 $message = str_replace( $matches[1], '', $message );
815 }
816 throw new Exception( $message );
817 }
818 }
819
820 /**
821 * Get models via the core method.
822 *
823 * @return array
824 */
825 public function get_models() {
826 return $this->core->get_engine_models( 'google' );
827 }
828
829 /**
830 * Retrieve models from Google's generative language endpoint.
831 *
832 * @throws Exception
833 * @return array
834 */
835 private function format_model_name( $model_id ) {
836 // Special cases for specific models that need manual handling
837 $special_names = [
838 'nano-banana-pro-preview' => 'Nano Banana Pro',
839 'gemini-3.1-pro-preview-customtools' => 'Gemini 3.1 Pro (Custom Tools)',
840 'gemini-live-2.5-flash-preview' => 'Gemini 2.5 Flash Live',
841 'gemini-2.0-flash-live-001' => 'Gemini 2.0 Flash Live',
842 'gemini-2.5-flash-native-audio-preview-12-2025' => 'Gemini 2.5 Flash Audio (12-2025)',
843 'gemini-2.5-flash-native-audio-preview-09-2025' => 'Gemini 2.5 Flash Audio (09-2025)',
844 ];
845
846 if ( isset( $special_names[$model_id] ) ) {
847 return $special_names[$model_id];
848 }
849
850 // Store original for differentiating similar models
851 $original_id = $model_id;
852
853 // Remove common suffixes but keep track if we need to differentiate
854 $cleaned = $model_id;
855
856 // Extract date suffix if present (like -preview-03-25)
857 $date_suffix = '';
858 if ( preg_match( '/-preview-(\d{2}-\d{2})(?:-thinking)?$/', $cleaned, $matches ) ) {
859 $date_suffix = $matches[1];
860 $cleaned = preg_replace( '/-preview-\d{2}-\d{2}(?:-thinking)?$/', '', $cleaned );
861 }
862
863 // Check if it's a thinking model
864 $is_thinking = strpos( $original_id, '-thinking' ) !== false;
865 if ( $is_thinking ) {
866 $cleaned = str_replace( '-thinking', '', $cleaned );
867 }
868
869 // Check if it's a TTS preview model
870 $is_preview_tts = strpos( $original_id, 'preview-tts' ) !== false;
871
872 // Keep version suffixes (like -001, -002) if they help distinguish models
873 $has_version_suffix = preg_match( '/-\d{3}$/', $cleaned );
874 $version_suffix = '';
875 if ( $has_version_suffix ) {
876 preg_match( '/(-\d{3})$/', $cleaned, $matches );
877 $version_suffix = $matches[1];
878 $cleaned = preg_replace( '/-\d{3}$/', '', $cleaned );
879 }
880
881 // Track if it's a preview model
882 $is_preview = strpos( $cleaned, '-preview' ) !== false || !empty( $date_suffix );
883 $cleaned = preg_replace( '/-preview$/', '', $cleaned );
884
885 // Track if it's experimental
886 $is_experimental = strpos( $original_id, '-exp' ) !== false;
887 $cleaned = preg_replace( '/-exp$/', '', $cleaned );
888 $cleaned = preg_replace( '/-generate$/', '', $cleaned );
889
890 // Don't remove -latest suffix here, we'll handle it separately
891 $has_latest = strpos( $cleaned, '-latest' ) !== false;
892 $cleaned = preg_replace( '/-latest$/', '', $cleaned );
893
894 // Handle specific feature names
895 if ( strpos( $cleaned, 'preview-native-audio-dialog' ) !== false ) {
896 $cleaned = str_replace( 'preview-native-audio-dialog', 'Native Audio', $cleaned );
897 }
898 else if ( strpos( $cleaned, 'exp-native-audio-thinking-dialog' ) !== false ) {
899 $cleaned = str_replace( 'exp-native-audio-thinking-dialog', 'Native Audio', $cleaned );
900 }
901 else if ( strpos( $cleaned, 'preview-image-generation' ) !== false ) {
902 $cleaned = str_replace( 'preview-image-generation', 'Preview Image Generation', $cleaned );
903 }
904 else if ( strpos( $cleaned, 'preview-tts' ) !== false ) {
905 $cleaned = str_replace( 'preview-tts', '', $cleaned );
906 // We'll add (Preview TTS) as a suffix later
907 }
908
909 // Parse components
910 $parts = explode( '-', $cleaned );
911 $formatted_parts = [];
912
913 // Process each part
914 foreach ( $parts as $part ) {
915 if ( $part === 'gemini' ) {
916 $formatted_parts[] = 'Gemini';
917 }
918 else if ( $part === 'imagen' ) {
919 $formatted_parts[] = 'Imagen';
920 }
921 else if ( $part === 'veo' ) {
922 $formatted_parts[] = 'Veo';
923 }
924 else if ( $part === 'pro' ) {
925 $formatted_parts[] = 'Pro';
926 }
927 else if ( $part === 'flash' ) {
928 $formatted_parts[] = 'Flash';
929 }
930 else if ( $part === 'lite' ) {
931 // Check if previous part was Flash to create Flash-Lite
932 if ( !empty( $formatted_parts ) && $formatted_parts[count( $formatted_parts ) - 1] === 'Flash' ) {
933 $formatted_parts[count( $formatted_parts ) - 1] = 'Flash-Lite';
934 }
935 else {
936 $formatted_parts[] = 'Lite';
937 }
938 }
939 else if ( $part === 'ultra' ) {
940 $formatted_parts[] = 'Ultra';
941 }
942 else if ( $part === 'tts' || $part === 'TTS' ) {
943 $formatted_parts[] = 'TTS';
944 }
945 else if ( preg_match( '/^\d+\.\d+$/', $part ) ) {
946 // Version numbers
947 $formatted_parts[] = $part;
948 }
949 else if ( preg_match( '/^(\d+)B$/i', $part, $matches ) ) {
950 // Model sizes like 8B - be consistent with capitalization
951 $formatted_parts[] = '-' . $matches[1] . 'B';
952 }
953 else if ( $part === 'latest' ) {
954 // Don't include 'latest' here as it's handled separately
955 continue;
956 }
957 else if ( !in_array( $part, ['generate', 'preview', 'exp'] ) ) {
958 // Keep other parts unless they're common suffixes
959 $formatted_parts[] = ucfirst( $part );
960 }
961 }
962
963 // Join with appropriate spacing
964 $name = implode( ' ', $formatted_parts );
965
966 // Clean up double spaces and fix specific patterns
967 $name = preg_replace( '/\s+/', ' ', $name );
968 $name = str_replace( ' -', '-', $name );
969
970 // Special formatting for Imagen and Veo versions
971 if ( strpos( $name, 'Imagen 4.0' ) === 0 ) {
972 $name = str_replace( 'Imagen 4.0', 'Imagen 4', $name );
973 }
974 else if ( strpos( $name, 'Veo 2.0' ) === 0 ) {
975 $name = str_replace( 'Veo 2.0', 'Veo 2', $name );
976 }
977
978 // Remove date pattern "xx xx" where x are numbers (like "03 07") from the name
979 if ( preg_match( '/\s(\d{2})\s(\d{2})$/', $name, $matches ) ) {
980 $name = preg_replace( '/\s\d{2}\s\d{2}$/', '', $name );
981 }
982
983 // Add suffixes to distinguish similar models
984 $suffixes = [];
985
986 // Don't add date suffixes - we want clean model names
987 // Don't add Preview suffix - we already have a preview tag
988
989 // Add version suffix for numbered models (like -001, -002)
990 // Special handling: if base model exists (without -001), then -001 should be marked
991 if ( !empty( $version_suffix ) ) {
992 // Extract just the number without the dash
993 $version_num = str_replace( '-', '', $version_suffix );
994 $version_int = intval( $version_num );
995
996 // Always add version suffix for -001 if it's not the only version
997 // This helps distinguish when both base and -001 exist
998 if ( $version_int === 1 ) {
999 // Check if this looks like a model that might have a base version
1000 // (e.g., gemini-2.0-flash vs gemini-2.0-flash-001)
1001 if ( strpos( $original_id, 'flash-8b-001' ) !== false ||
1002 strpos( $original_id, 'flash-001' ) !== false ||
1003 strpos( $original_id, 'flash-lite-001' ) !== false ) {
1004 $suffixes[] = 'v1';
1005 }
1006 }
1007 else {
1008 // For -002 and higher, always add version
1009 $suffixes[] = 'v' . ltrim( $version_num, '0' );
1010 }
1011 }
1012
1013 // Don't add "Latest" suffix in name - we use the 'latest' tag instead
1014 // This avoids duplicate "LATEST" information in the UI
1015
1016 // Handle thinking models
1017 if ( $is_thinking && strpos( $name, 'Thinking' ) === false ) {
1018 $suffixes[] = 'Thinking';
1019 }
1020
1021 // Handle TTS preview models
1022 if ( $is_preview_tts ) {
1023 $suffixes[] = 'Preview TTS';
1024 }
1025
1026 // Append all suffixes with parentheses
1027 if ( !empty( $suffixes ) ) {
1028 $name .= ' (' . implode( ', ', $suffixes ) . ')';
1029 }
1030
1031 return trim( $name );
1032 }
1033
1034 public function retrieve_models() {
1035 $url = $this->endpoint . '/models?key=' . $this->apiKey;
1036 $response = wp_remote_get( $url );
1037 if ( is_wp_error( $response ) ) {
1038 throw new Exception( 'AI Engine: ' . $response->get_error_message() );
1039 }
1040 $body = json_decode( $response['body'], true );
1041 $models = [];
1042
1043 if ( empty( $body['models'] ) || !is_array( $body['models'] ) ) {
1044 error_log( '[AI Engine] Google Models Retrieval - No models found in response' );
1045 return [];
1046 }
1047
1048 error_log( '[AI Engine] Google Models Retrieval - Starting to process ' . count( $body['models'] ) . ' models' );
1049
1050 foreach ( $body['models'] as $model ) {
1051 $model_id = preg_replace( '/^models\//', '', $model['name'] );
1052
1053 error_log( '[AI Engine] Processing model: ' . $model_id );
1054
1055 // Skip date-specific preview models (e.g., gemini-2.5-flash-preview-09-2025)
1056 if ( preg_match( '/-preview-\d{2}-\d{4}/', $model_id ) ) {
1057 error_log( '[AI Engine] -> Skipping (date-specific preview YYYY): ' . $model_id );
1058 continue;
1059 }
1060
1061 // Skip preview models with MM-DD dates (e.g., preview-03-25, preview-06-17)
1062 if ( preg_match( '/-preview-\d{2}-\d{2}/', $model_id ) || preg_match( '/preview-\d{2}-\d{2}$/', $model_id ) ) {
1063 error_log( '[AI Engine] -> Skipping (date-specific preview MM-DD): ' . $model_id );
1064 continue;
1065 }
1066
1067 // Skip models with date patterns like -YYYYMMDD (e.g., gemini-1.5-flash-8b-20241206)
1068 if ( preg_match( '/-\d{8}$/', $model_id ) ) {
1069 error_log( '[AI Engine] -> Skipping (YYYYMMDD date): ' . $model_id );
1070 continue;
1071 }
1072
1073 // Skip models with date patterns like exp-MMDD (e.g., gemini-1.5-flash-8b-exp-0924)
1074 if ( preg_match( '/-exp-\d{4}$/', $model_id ) ) {
1075 error_log( '[AI Engine] -> Skipping (exp-MMDD date): ' . $model_id );
1076 continue;
1077 }
1078
1079 // Skip experimental models with date patterns like exp-MM-DD (e.g., gemini-2.0-flash-thinking-exp-01-21)
1080 if ( preg_match( '/-exp-\d{2}-\d{2}/', $model_id ) ) {
1081 error_log( '[AI Engine] -> Skipping (exp-MM-DD date): ' . $model_id );
1082 continue;
1083 }
1084
1085 // Skip embedding models with date patterns (e.g., gemini-embedding-exp-03-07)
1086 if ( preg_match( '/embedding-exp-\d{2}-\d{2}/', $model_id ) ) {
1087 error_log( '[AI Engine] -> Skipping (embedding exp date): ' . $model_id );
1088 continue;
1089 }
1090
1091 // Skip imagen/veo models with date patterns (e.g., imagen-4.0-generate-preview-06-06)
1092 if ( preg_match( '/(imagen|veo).*-\d{2}-\d{2}/', $model_id ) ) {
1093 error_log( '[AI Engine] -> Skipping (imagen/veo date): ' . $model_id );
1094 continue;
1095 }
1096
1097 // Skip robotics models
1098 if ( strpos( $model_id, 'robotics' ) !== false ) {
1099 error_log( '[AI Engine] -> Skipping (robotics): ' . $model_id );
1100 continue;
1101 }
1102
1103 // Skip TTS models (not for chatbot use)
1104 if ( strpos( $model_id, '-tts' ) !== false || strpos( $model_id, 'text-to-speech' ) !== false ) {
1105 error_log( '[AI Engine] -> Skipping (TTS model): ' . $model_id );
1106 continue;
1107 }
1108
1109 // Determine model family
1110 $family = 'gemini';
1111 if ( strpos( $model['name'], 'imagen' ) !== false ) {
1112 $family = 'imagen';
1113 }
1114 else if ( strpos( $model['name'], 'veo' ) !== false ) {
1115 $family = 'veo';
1116 }
1117 else if ( strpos( $model['name'], 'nano-banana' ) !== false ) {
1118 // Google ships its image models under the "Nano Banana" codename too
1119 // (nano-banana-pro-preview). The id carries no "gemini", so the check below
1120 // used to drop them silently even though they are first-class image models.
1121 $family = 'gemini';
1122 }
1123 else if ( strpos( $model['name'], 'gemini' ) === false ) {
1124 // Skip models that aren't gemini, imagen, or veo
1125 continue;
1126 }
1127
1128 $maxCompletionTokens = $model['outputTokenLimit'];
1129 $maxContextualTokens = $model['inputTokenLimit'];
1130 $priceIn = 0;
1131 $priceOut = 0;
1132
1133 // If Model Name contains "Experimental", skip it (except for embedding models)
1134 if ( strpos( $model['name'], '-exp' ) !== false && strpos( $model['name'], 'embedding' ) === false ) {
1135 continue;
1136 }
1137
1138 // Set tags based on model family and features
1139 $tags = [ 'core' ];
1140 $features = [ 'completion' ];
1141 $tools = [];
1142
1143 if ( $family === 'imagen' ) {
1144 // Skip Imagen models - they don't work currently
1145 continue;
1146 }
1147 else if ( $family === 'veo' ) {
1148 $tags[] = 'video-generation';
1149 $features = [ 'video-generation' ];
1150 }
1151 else {
1152 // Gemini models - all support function calling according to documentation
1153 $tags[] = 'chat';
1154 $tags[] = 'functions';
1155 $tools[] = 'function_calling';
1156
1157 // Check if it's a preview/beta model
1158 if ( preg_match( '/\((beta|alpha|preview)\)/i', $model['name'] ) ||
1159 preg_match( '/-preview/', $model_id ) ) {
1160 $tags[] = 'preview';
1161 $model['name'] = preg_replace( '/\((beta|alpha|preview)\)/i', '', $model['name'] );
1162 }
1163
1164 // Vision capabilities - all 3.x, 2.5, 2.0, and 1.5 models support vision and files
1165 if ( preg_match( '/gemini-(3|2\.5|2\.0|1\.5|omni)|nano-banana/', $model_id ) ) {
1166 $tags[] = 'vision';
1167 $tags[] = 'files'; // All vision models support PDFs/documents
1168 $features[] = 'vision';
1169 }
1170
1171 // Web search capabilities - Gemini 3.x, 2.5, and 1.5 Pro models
1172 if ( preg_match( '/gemini-(3|2\.5|1\.5-pro|omni)/', $model_id ) ) {
1173 $tools[] = 'web_search';
1174 }
1175
1176 // Google Maps grounding - Gemini 3.x and 2.5 models
1177 if ( preg_match( '/gemini-(3|2\.5|omni)/', $model_id ) ) {
1178 $tools[] = 'google_maps';
1179 }
1180
1181 // Image generation - Gemini image models. Match both the -preview ids
1182 // (e.g. gemini-3-pro-image-preview, "Nano Banana Pro") and their GA
1183 // siblings without the suffix (e.g. gemini-3-pro-image), plus the older
1184 // *-image-generation ids. A narrow flash-image|image-preview match misses
1185 // gemini-3-pro-image and would break whenever Google drops -preview.
1186 if ( preg_match( '/-image(-preview)?$|image-(preview|generation)|nano-banana/', $model_id ) ) {
1187 $tags[] = 'image';
1188 $tags[] = 'image-generation';
1189 $features[] = 'image-generation';
1190 $tools[] = 'image_generation';
1191 }
1192
1193 // Audio capabilities for native audio models
1194 if ( preg_match( '/native-audio/', $model_id ) ) {
1195 $tags[] = 'audio';
1196 $features[] = 'audio';
1197 }
1198
1199 // Realtime (Live API) capabilities
1200 if ( strpos( $model_id, 'live' ) !== false || strpos( $model_id, 'native-audio' ) !== false ) {
1201 $tags[] = 'realtime';
1202 $features[] = 'realtime';
1203 }
1204
1205 // TTS capabilities
1206 if ( preg_match( '/(tts|text-to-speech)/', $model_id ) ) {
1207 $tags[] = 'tts';
1208 $features = [ 'text-to-speech' ];
1209 }
1210
1211 // Embedding models
1212 if ( preg_match( '/embedding/', $model_id ) ) {
1213 $tags = [ 'core', 'embedding', 'matryoshka' ]; // Reset tags for embedding
1214 $features = [ 'embedding' ];
1215 $tools = []; // Embedding models don't have tools
1216 // Gemini Embedding 2+ supports multimodal (images, etc.)
1217 if ( preg_match( '/embedding-2/', $model_id ) ) {
1218 $tags[] = 'image';
1219 }
1220 // Check if it's experimental
1221 if ( strpos( $model_id, '-exp' ) !== false ) {
1222 $tags[] = 'experimental';
1223 }
1224 }
1225
1226 // Thinking capabilities for Gemini 2.5 models
1227 if ( preg_match( '/gemini-(2\.5|3)/', $model_id ) && !in_array( 'embedding', $tags ) ) {
1228 $tools[] = 'thinking';
1229 $tags[] = 'thinking';
1230 }
1231
1232 // Tag only alias models that point to the latest version (end with -latest)
1233 // Examples: gemini-flash-latest, gemini-pro-latest, gemini-flash-lite-latest
1234 // Do NOT tag specific versions like gemini-2.5-flash, gemini-2.0-flash
1235 if ( preg_match( '/-latest$/', $model_id ) &&
1236 !in_array( 'embedding', $tags ) &&
1237 !in_array( 'experimental', $tags ) ) {
1238 $tags[] = 'latest';
1239 }
1240 }
1241
1242 $nice_name = $this->format_model_name( $model_id );
1243
1244 $model = [
1245 'model' => $model_id,
1246 'name' => $nice_name,
1247 'family' => $family,
1248 'features' => $features,
1249 'type' => 'token',
1250 'unit' => 1 / 1000,
1251 'maxCompletionTokens' => $maxCompletionTokens,
1252 'maxContextualTokens' => $maxContextualTokens,
1253 'tags' => $tags,
1254 'tools' => $tools
1255 ];
1256
1257 // Add resolutions and pricing for image generation models
1258 // See: https://ai.google.dev/gemini-api/docs/pricing
1259 if ( in_array( 'image-generation', $tags ) ) {
1260 $model['resolutions'] = [
1261 // Landscape
1262 [ 'name' => '21:9', 'label' => '21:9' ],
1263 [ 'name' => '16:9', 'label' => '16:9' ],
1264 [ 'name' => '4:3', 'label' => '4:3' ],
1265 [ 'name' => '3:2', 'label' => '3:2' ],
1266 // Square
1267 [ 'name' => '1:1', 'label' => '1:1' ],
1268 // Portrait
1269 [ 'name' => '2:3', 'label' => '2:3' ],
1270 [ 'name' => '3:4', 'label' => '3:4' ],
1271 [ 'name' => '9:16', 'label' => '9:16' ],
1272 // Flexible
1273 [ 'name' => '5:4', 'label' => '5:4' ],
1274 [ 'name' => '4:5', 'label' => '4:5' ]
1275 ];
1276
1277 // Set pricing for image generation models
1278 if ( $family === 'imagen' ) {
1279 // Imagen models: per-image pricing
1280 // Imagen 3: $0.03 per image
1281 // Imagen 4 Fast: $0.02 per image
1282 // Imagen 4 Standard: $0.04 per image
1283 // Imagen 4 Ultra: $0.06 per image
1284 $model['type'] = 'image';
1285 $model['unit'] = 1; // Per image
1286 $model['mode'] = 'image';
1287
1288 if ( strpos( $model_id, 'imagen-4.0-fast' ) !== false ) {
1289 $priceIn = 0;
1290 $priceOut = 0.02; // $0.02 per image
1291 }
1292 else if ( strpos( $model_id, 'imagen-4.0-ultra' ) !== false ) {
1293 $priceIn = 0;
1294 $priceOut = 0.06; // $0.06 per image
1295 }
1296 else if ( strpos( $model_id, 'imagen-4.0' ) !== false ) {
1297 $priceIn = 0;
1298 $priceOut = 0.04; // $0.04 per image (standard)
1299 }
1300 else if ( strpos( $model_id, 'imagen-3.0' ) !== false ) {
1301 $priceIn = 0;
1302 $priceOut = 0.03; // $0.03 per image
1303 }
1304 }
1305 else if ( preg_match( '/flash-image/', $model_id ) ) {
1306 // Gemini Flash Image: token-based pricing
1307 // Input: $0.30 per 1M tokens (text/image)
1308 // Output: $0.039 per image ($30 per 1M tokens, ~1290 tokens per image)
1309 $model['unit'] = 1 / 1000000; // Per 1M tokens (same as OpenAI gpt-image models)
1310 $model['mode'] = 'image';
1311 $priceIn = 0.30;
1312 $priceOut = 30.00; // Output is $30 per 1M tokens
1313 }
1314 else if ( preg_match( '/gemini-3.*image/', $model_id ) ) {
1315 // Gemini 3 Pro Image: token-based pricing (like Flash Image)
1316 // Pricing not yet officially announced, using estimate based on ~1500 tokens/image
1317 // Target: ~$0.04 per image → $26.67 per 1M output tokens
1318 $model['unit'] = 1 / 1000000; // Per 1M tokens
1319 $model['mode'] = 'image';
1320 $priceIn = 0.30; // Estimate similar to Flash Image
1321 $priceOut = 26.67; // ~$0.04 per image at ~1500 tokens
1322 }
1323 }
1324
1325 // Add dimensions for embedding models
1326 if ( in_array( 'embedding', $tags ) ) {
1327 // Gemini embedding models have 768 dimensions (text-embedding-004) or 3072 (experimental)
1328 if ( strpos( $model_id, 'text-embedding-004' ) !== false ) {
1329 $model['dimensions'] = [ 768 ];
1330 }
1331 else {
1332 $model['dimensions'] = [ 3072 ];
1333 }
1334 }
1335 // Set price if either input or output has a cost (image models often have $0 input)
1336 if ( $priceIn > 0 || $priceOut > 0 ) {
1337 $model['price'] = [ 'in' => $priceIn, 'out' => $priceOut ];
1338 }
1339
1340 $tagStr = implode( ', ', array_diff( $tags, ['core'] ) ); // Exclude 'core' as it's always there
1341 error_log( '[AI Engine] -> Including: ' . $model_id . ' → "' . $nice_name . '" [' . $tagStr . ']' );
1342 $models[] = $model;
1343 }
1344
1345 // Append hardcoded Gemini Live API models (not returned by /models endpoint)
1346 $live_models = [
1347 [
1348 'model' => 'gemini-2.5-flash-native-audio-preview-12-2025',
1349 'name' => $this->format_model_name( 'gemini-2.5-flash-native-audio-preview-12-2025' ),
1350 'family' => 'gemini',
1351 'features' => [ 'completion', 'realtime' ],
1352 'type' => 'token',
1353 'unit' => 1 / 1000,
1354 'maxCompletionTokens' => 8192,
1355 'maxContextualTokens' => 128000,
1356 'tags' => [ 'core', 'chat', 'functions', 'realtime', 'audio', 'preview' ],
1357 'tools' => [ 'function_calling' ],
1358 ],
1359 [
1360 'model' => 'gemini-2.5-flash-native-audio-preview-09-2025',
1361 'name' => $this->format_model_name( 'gemini-2.5-flash-native-audio-preview-09-2025' ),
1362 'family' => 'gemini',
1363 'features' => [ 'completion', 'realtime' ],
1364 'type' => 'token',
1365 'unit' => 1 / 1000,
1366 'maxCompletionTokens' => 8192,
1367 'maxContextualTokens' => 128000,
1368 'tags' => [ 'core', 'chat', 'functions', 'realtime', 'audio', 'preview' ],
1369 'tools' => [ 'function_calling' ],
1370 ],
1371 ];
1372 foreach ( $live_models as $lm ) {
1373 // Only add if not already present (in case Google starts listing them)
1374 $exists = false;
1375 foreach ( $models as $existing ) {
1376 if ( $existing['model'] === $lm['model'] ) {
1377 $exists = true;
1378 break;
1379 }
1380 }
1381 if ( !$exists ) {
1382 error_log( '[AI Engine] -> Including (hardcoded): ' . $lm['model'] . ' → "' . $lm['name'] . '"' );
1383 $models[] = $lm;
1384 }
1385 }
1386
1387 // Second pass: Copy tags/features from versioned models to their -latest aliases
1388 foreach ( $models as &$model ) {
1389 if ( in_array( 'latest', $model['tags'] ?? [] ) ) {
1390 // This is a -latest alias, find the corresponding versioned model
1391 // e.g., gemini-flash-latest should copy from gemini-2.5-flash (highest version)
1392
1393 $alias_base = str_replace( '-latest', '', $model['model'] );
1394 // Match patterns like: gemini-flash-latest → gemini-X.X-flash
1395 $pattern = '/^' . preg_quote( str_replace( 'gemini-', '', $alias_base ), '/' ) . '$/';
1396
1397 // Find all matching versioned models and pick the highest version
1398 $versioned_models = array_filter( $models, function ( $m ) use ( $alias_base ) {
1399 // Match models like gemini-2.5-flash for alias gemini-flash-latest
1400 $model_id = $m['model'];
1401
1402 // Extract base (e.g., "flash", "pro", "flash-lite")
1403 $alias_type = str_replace( 'gemini-', '', str_replace( '-latest', '', $alias_base ) );
1404
1405 // Check if this is a versioned model of the same type
1406 // Pattern: gemini-X.X-{type} or gemini-X.X-{type}-XXX
1407 return preg_match( '/^gemini-\d+\.\d+-' . preg_quote( $alias_type, '/' ) . '(-\d{3})?$/', $model_id );
1408 } );
1409
1410 if ( !empty( $versioned_models ) ) {
1411 // Sort by version number (descending) to get the latest
1412 usort( $versioned_models, function ( $a, $b ) {
1413 preg_match( '/gemini-(\d+\.\d+)/', $a['model'], $matches_a );
1414 preg_match( '/gemini-(\d+\.\d+)/', $b['model'], $matches_b );
1415 $version_a = isset( $matches_a[1] ) ? floatval( $matches_a[1] ) : 0;
1416 $version_b = isset( $matches_b[1] ) ? floatval( $matches_b[1] ) : 0;
1417 return $version_b <=> $version_a;
1418 } );
1419
1420 $source_model = $versioned_models[0];
1421
1422 // Copy tags (except 'latest' which the alias already has)
1423 $tags_to_copy = array_diff( $source_model['tags'] ?? [], ['latest'] );
1424 $current_tags = $model['tags'] ?? [];
1425 $model['tags'] = array_values( array_unique( array_merge( $current_tags, $tags_to_copy ) ) );
1426
1427 // Copy features
1428 if ( !empty( $source_model['features'] ) ) {
1429 $model['features'] = array_values( $source_model['features'] );
1430 }
1431
1432 // Copy tools
1433 if ( !empty( $source_model['tools'] ) ) {
1434 $model['tools'] = array_values( $source_model['tools'] );
1435 }
1436
1437 error_log( '[AI Engine] Copied tags/features from ' . $source_model['model'] . ' to ' . $model['model'] );
1438 }
1439 }
1440 }
1441 unset( $model ); // Break reference
1442
1443 // Summary logging
1444 $totalModels = count( $models );
1445 $latestModels = array_filter( $models, function ( $m ) { return in_array( 'latest', $m['tags'] ?? [] ); } );
1446 $visionModels = array_filter( $models, function ( $m ) { return in_array( 'vision', $m['tags'] ?? [] ); } );
1447 $embeddingModels = array_filter( $models, function ( $m ) { return in_array( 'embedding', $m['tags'] ?? [] ); } );
1448
1449 error_log( '[AI Engine] ========================================' );
1450 error_log( '[AI Engine] Google Models Retrieval - Summary:' );
1451 error_log( '[AI Engine] Total models: ' . $totalModels );
1452 error_log( '[AI Engine] Latest/Stable: ' . count( $latestModels ) );
1453 error_log( '[AI Engine] Vision models: ' . count( $visionModels ) );
1454 error_log( '[AI Engine] Embedding models: ' . count( $embeddingModels ) );
1455 error_log( '[AI Engine] ========================================' );
1456
1457 // Sort models to put most recent versions first
1458 usort( $models, function ( $a, $b ) {
1459 // First, sort by family (gemini, imagen, veo)
1460 $family_order = [ 'gemini' => 1, 'imagen' => 2, 'veo' => 3 ];
1461 $family_a = $family_order[$a['family']] ?? 999;
1462 $family_b = $family_order[$b['family']] ?? 999;
1463
1464 if ( $family_a !== $family_b ) {
1465 return $family_a - $family_b;
1466 }
1467
1468 // Within the same family, extract version numbers and sort descending
1469 $model_a = $a['model'];
1470 $model_b = $b['model'];
1471
1472 // Extract version numbers (e.g., 2.5, 2.0, 1.5, 1.0)
1473 preg_match( '/(\d+\.\d+)/', $model_a, $matches_a );
1474 preg_match( '/(\d+\.\d+)/', $model_b, $matches_b );
1475
1476 $version_a = isset( $matches_a[1] ) ? floatval( $matches_a[1] ) : 0;
1477 $version_b = isset( $matches_b[1] ) ? floatval( $matches_b[1] ) : 0;
1478
1479 // Sort by version descending (newer first)
1480 if ( $version_a !== $version_b ) {
1481 return $version_b <=> $version_a;
1482 }
1483
1484 // For same version, sort by model variant
1485 // Priority: pro > flash > flash-8b > flash-lite
1486 $variant_order = [
1487 'pro' => 1,
1488 'flash' => 2,
1489 'flash-8b' => 3,
1490 'flash-lite' => 4,
1491 ];
1492
1493 // Determine variant
1494 $variant_a = 'other';
1495 $variant_b = 'other';
1496
1497 if ( strpos( $model_a, 'pro' ) !== false ) {
1498 $variant_a = 'pro';
1499 }
1500 elseif ( strpos( $model_a, 'flash-lite' ) !== false ) {
1501 $variant_a = 'flash-lite';
1502 }
1503 elseif ( strpos( $model_a, 'flash-8b' ) !== false ) {
1504 $variant_a = 'flash-8b';
1505 }
1506 elseif ( strpos( $model_a, 'flash' ) !== false ) {
1507 $variant_a = 'flash';
1508 }
1509
1510 if ( strpos( $model_b, 'pro' ) !== false ) {
1511 $variant_b = 'pro';
1512 }
1513 elseif ( strpos( $model_b, 'flash-lite' ) !== false ) {
1514 $variant_b = 'flash-lite';
1515 }
1516 elseif ( strpos( $model_b, 'flash-8b' ) !== false ) {
1517 $variant_b = 'flash-8b';
1518 }
1519 elseif ( strpos( $model_b, 'flash' ) !== false ) {
1520 $variant_b = 'flash';
1521 }
1522
1523 $order_a = $variant_order[$variant_a] ?? 999;
1524 $order_b = $variant_order[$variant_b] ?? 999;
1525
1526 if ( $order_a !== $order_b ) {
1527 return $order_a - $order_b;
1528 }
1529
1530 // For same variant, sort by specific suffixes
1531 // Base model > latest > dated previews > numbered versions
1532 $is_base_a = !preg_match( '/-(?:latest|preview|\d{3})/', $model_a );
1533 $is_base_b = !preg_match( '/-(?:latest|preview|\d{3})/', $model_b );
1534
1535 if ( $is_base_a && !$is_base_b ) {
1536 return -1;
1537 }
1538 if ( !$is_base_a && $is_base_b ) {
1539 return 1;
1540 }
1541
1542 // Latest comes after base
1543 $is_latest_a = strpos( $model_a, '-latest' ) !== false;
1544 $is_latest_b = strpos( $model_b, '-latest' ) !== false;
1545
1546 if ( $is_latest_a && !$is_latest_b ) {
1547 return -1;
1548 }
1549 if ( !$is_latest_a && $is_latest_b ) {
1550 return 1;
1551 }
1552
1553 // Then preview models (sorted by date descending)
1554 preg_match( '/-preview-(\d{2})-(\d{2})/', $model_a, $date_a );
1555 preg_match( '/-preview-(\d{2})-(\d{2})/', $model_b, $date_b );
1556
1557 if ( !empty( $date_a ) && !empty( $date_b ) ) {
1558 // Compare dates (month then day)
1559 $month_a = intval( $date_a[1] );
1560 $month_b = intval( $date_b[1] );
1561 if ( $month_a !== $month_b ) {
1562 return $month_b - $month_a; // Descending
1563 }
1564 $day_a = intval( $date_a[2] );
1565 $day_b = intval( $date_b[2] );
1566 return $day_b - $day_a; // Descending
1567 }
1568
1569 if ( !empty( $date_a ) && empty( $date_b ) ) {
1570 return -1;
1571 }
1572 if ( empty( $date_a ) && !empty( $date_b ) ) {
1573 return 1;
1574 }
1575
1576 // Finally, numbered versions (descending)
1577 preg_match( '/-(\d{3})$/', $model_a, $num_a );
1578 preg_match( '/-(\d{3})$/', $model_b, $num_b );
1579
1580 if ( !empty( $num_a ) && !empty( $num_b ) ) {
1581 return intval( $num_b[1] ) - intval( $num_a[1] );
1582 }
1583
1584 // Fallback to string comparison
1585 return strcasecmp( $model_a, $model_b );
1586 } );
1587
1588 // Google ships preview and GA variants of the same model (gemini-3-pro-image and
1589 // gemini-3-pro-image-preview), and format_model_name() strips the suffix from both.
1590 // That left several identical entries in the model dropdown with no way to tell
1591 // which was which, so disambiguate the preview one after the fact.
1592 $name_counts = [];
1593 foreach ( $models as $model ) {
1594 $name = $model['name'];
1595 $name_counts[$name] = ( $name_counts[$name] ?? 0 ) + 1;
1596 }
1597 foreach ( $models as &$model ) {
1598 if ( ( $name_counts[$model['name']] ?? 0 ) > 1 && strpos( $model['model'], '-preview' ) !== false ) {
1599 $model['name'] .= ' (Preview)';
1600 }
1601 }
1602 unset( $model );
1603
1604 return $models;
1605 }
1606
1607 /**
1608 * Handle image generation queries for Gemini Flash Image models.
1609 * Google's image generation models use the same generateContent endpoint,
1610 * so we directly call it and extract the image data.
1611 *
1612 * @param Meow_MWAI_Query_Image $query
1613 * @param callable $streamCallback Optional callback for streaming events
1614 * @return Meow_MWAI_Reply
1615 */
1616 public function run_image_query( $query, $streamCallback = null ) {
1617 // Check if the model supports image generation
1618 $modelInfo = $this->core->get_engine_models( 'google' );
1619 $supportsImageGen = false;
1620
1621 foreach ( $modelInfo as $model ) {
1622 if ( $model['model'] === $query->model &&
1623 isset( $model['features'] ) &&
1624 in_array( 'image-generation', $model['features'] ) ) {
1625 $supportsImageGen = true;
1626 break;
1627 }
1628 }
1629
1630 if ( !$supportsImageGen ) {
1631 throw new Exception( 'The model ' . $query->model . ' does not support image generation.' );
1632 }
1633
1634 // Initialize debug mode
1635 $this->init_debug_mode( $query );
1636
1637 // Emit image generation event if streaming is enabled
1638 if ( $this->currentDebugMode && !empty( $streamCallback ) ) {
1639 $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['IMAGE_GEN'] );
1640 $event->set_content( 'Generating image...' );
1641 call_user_func( $streamCallback, $event );
1642 }
1643
1644 // Gemini 3 models don't support multiple candidates
1645 $candidateCount = $query->maxResults;
1646 if ( preg_match( '/gemini-3/', $query->model ) && $candidateCount > 1 ) {
1647 $candidateCount = 1;
1648 }
1649
1650 // Build the request for image generation
1651 $body = [
1652 'contents' => [
1653 [
1654 'parts' => [
1655 [ 'text' => $query->get_message() ]
1656 ]
1657 ]
1658 ],
1659 'generationConfig' => [
1660 'candidateCount' => $candidateCount
1661 ]
1662 ];
1663
1664 // Add aspect ratio if provided (e.g., "1:1", "3:4", "16:9")
1665 // Must be nested inside imageConfig object
1666 if ( !empty( $query->resolution ) ) {
1667 $body['generationConfig']['imageConfig'] = [
1668 'aspectRatio' => $query->resolution
1669 ];
1670 }
1671
1672 // Build URL and headers
1673 $url = $this->endpoint . '/models/' . $query->model . ':generateContent';
1674 if ( strpos( $url, '?' ) === false ) {
1675 $url .= '?key=' . $this->apiKey;
1676 }
1677 else {
1678 $url .= '&key=' . $this->apiKey;
1679 }
1680
1681 $headers = $this->build_headers( $query );
1682 $options = $this->build_options( $headers, $body );
1683
1684 try {
1685 $res = $this->run_query( $url, $options );
1686 $data = $res['data'];
1687
1688 if ( empty( $data ) || !isset( $data['candidates'] ) ) {
1689 throw new Exception( 'No image generated in response.' );
1690 }
1691
1692 $reply = new Meow_MWAI_Reply( $query );
1693 $reply->set_type( 'images' );
1694 $images = [];
1695
1696 // Extract base64 images from the response
1697 foreach ( $data['candidates'] as $candidate ) {
1698 if ( isset( $candidate['content']['parts'] ) ) {
1699 foreach ( $candidate['content']['parts'] as $part ) {
1700 // Check for both camelCase (inlineData) and snake_case (inline_data)
1701 $inlineData = null;
1702 if ( isset( $part['inlineData'] ) && isset( $part['inlineData']['data'] ) ) {
1703 $inlineData = $part['inlineData'];
1704 }
1705 else if ( isset( $part['inline_data'] ) && isset( $part['inline_data']['data'] ) ) {
1706 $inlineData = $part['inline_data'];
1707 }
1708
1709 if ( $inlineData ) {
1710 // Found an inline image
1711 $base64Data = $inlineData['data'];
1712 $mimeType = $inlineData['mimeType'] ?? 'image/png';
1713
1714 // Convert to data URL format for consistency with other engines
1715 $dataUrl = 'data:' . $mimeType . ';base64,' . $base64Data;
1716
1717 // Handle local download if requested
1718 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
1719 // Generate a proper filename based on mime type
1720 $extension = 'png'; // default
1721 if ( strpos( $mimeType, 'jpeg' ) !== false || strpos( $mimeType, 'jpg' ) !== false ) {
1722 $extension = 'jpg';
1723 }
1724 else if ( strpos( $mimeType, 'webp' ) !== false ) {
1725 $extension = 'webp';
1726 }
1727 $filename = 'generated-' . time() . '-' . uniqid() . '.' . $extension;
1728
1729 // Decode base64 and create a temp file
1730 $binary = base64_decode( $base64Data );
1731 $tmp_path = wp_tempnam( 'mwai-image' );
1732 file_put_contents( $tmp_path, $binary );
1733
1734 $fileId = $this->core->files->upload_file( $tmp_path, $filename, 'generated', [
1735 'query_envId' => $query->envId,
1736 'query_session' => $query->session,
1737 'query_model' => $query->model,
1738 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
1739
1740 // Clean up temp file if uploaded to library
1741 if ( $query->localDownload === 'library' && file_exists( $tmp_path ) ) {
1742 @unlink( $tmp_path );
1743 }
1744
1745 $fileUrl = $this->core->files->get_url( $fileId );
1746 $images[] = $fileUrl;
1747 }
1748 else {
1749 $images[] = $dataUrl;
1750 }
1751 }
1752 }
1753 }
1754 }
1755
1756 if ( empty( $images ) ) {
1757 throw new Exception( 'No images found in the response.' );
1758 }
1759
1760 $reply->results = $images;
1761 $reply->result = $images[0]; // Set the first image as the main result
1762
1763 // Handle usage for image generation
1764 // Check if API returned token usage data (for Flash Image models)
1765 if ( isset( $data['usageMetadata'] ) ) {
1766 $usageMetadata = $data['usageMetadata'];
1767 $promptTokens = $usageMetadata['promptTokenCount'] ?? 0;
1768 $completionTokens = $usageMetadata['candidatesTokenCount'] ?? 0;
1769 $totalTokens = $usageMetadata['totalTokenCount'] ?? ( $promptTokens + $completionTokens );
1770
1771 if ( $totalTokens > 0 ) {
1772 // Token-based pricing (Flash Image models)
1773 $this->core->record_tokens_usage( $query->model, $promptTokens, $completionTokens );
1774 $usage = [
1775 'prompt_tokens' => $promptTokens,
1776 'completion_tokens' => $completionTokens,
1777 'total_tokens' => $totalTokens,
1778 'queries' => 1,
1779 'accuracy' => 'tokens'
1780 ];
1781 $reply->set_usage( $usage );
1782 $reply->set_usage_accuracy( 'tokens' );
1783 }
1784 else {
1785 // Fallback to per-image pricing
1786 $resolution = '1024x1024'; // Default resolution
1787 $usage = $this->core->record_images_usage( $query->model, $resolution, count( $images ) );
1788 $reply->set_usage( $usage );
1789 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1790 }
1791 }
1792 else {
1793 // No usage metadata - per-image pricing (Imagen models)
1794 $resolution = '1024x1024'; // Default resolution
1795 $usage = $this->core->record_images_usage( $query->model, $resolution, count( $images ) );
1796 $reply->set_usage( $usage );
1797 $reply->set_usage_accuracy( isset( $usage['accuracy'] ) ? $usage['accuracy'] : 'estimated' );
1798 }
1799
1800 return $reply;
1801 }
1802 catch ( Exception $e ) {
1803 Meow_MWAI_Logging::error( '(Google) ' . $e->getMessage() );
1804 throw new Exception( 'From Google: ' . $e->getMessage() );
1805 }
1806 }
1807
1808 /**
1809 * Calculate the price for a Google API query based on the model and usage.
1810 * See: https://ai.google.dev/gemini-api/docs/pricing
1811 *
1812 * @param Meow_MWAI_Query_Base $query
1813 * @param Meow_MWAI_Reply $reply
1814 * @return float|null The price in USD, or null if pricing is not available
1815 */
1816 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1817 $model = $query->model;
1818 $models = $this->get_models();
1819 $modelInfo = null;
1820
1821 // Find the model in the models list
1822 foreach ( $models as $m ) {
1823 if ( $m['model'] === $model ) {
1824 $modelInfo = $m;
1825 break;
1826 }
1827 }
1828
1829 if ( !$modelInfo || !isset( $modelInfo['price'] ) ) {
1830 return null;
1831 }
1832
1833 $price = $modelInfo['price'];
1834 $inUnits = 0;
1835 $outUnits = 0;
1836
1837 // Image generation queries
1838 if ( is_a( $query, 'Meow_MWAI_Query_Image' ) ) {
1839 // Check if this is a token-based model (Flash Image) or per-image model (Imagen)
1840 if ( isset( $reply->usage['total_tokens'] ) && $reply->usage['total_tokens'] > 0 ) {
1841 // Token-based pricing (Flash Image models)
1842 $inUnits = $reply->usage['prompt_tokens'] ?? 0;
1843 $outUnits = $reply->usage['completion_tokens'] ?? 0;
1844 }
1845 else {
1846 // Per-image pricing (Imagen models)
1847 $inUnits = 0; // No input cost for Imagen
1848 $outUnits = $query->maxResults; // Number of images generated
1849 }
1850 }
1851 // Standard text/chat queries
1852 else if ( isset( $reply->usage['total_tokens'] ) ) {
1853 $inUnits = $reply->usage['prompt_tokens'] ?? 0;
1854 $outUnits = $reply->usage['completion_tokens'] ?? 0;
1855 }
1856
1857 // Calculate price
1858 $unit = $modelInfo['unit'] ?? 1;
1859 $inPrice = isset( $price['in'] ) ? $price['in'] : 0;
1860 $outPrice = isset( $price['out'] ) ? $price['out'] : 0;
1861
1862 return ( $inUnits * $inPrice * $unit ) + ( $outUnits * $outPrice * $unit );
1863 }
1864
1865 /**
1866 * Check the connection to Google by listing models.
1867 * Uses the existing retrieve_models method with a limit for quick check.
1868 */
1869 public function connection_check() {
1870 try {
1871 // Use the existing retrieve_models method
1872 $models = $this->retrieve_models();
1873
1874 if ( !is_array( $models ) ) {
1875 throw new Exception( 'Invalid response format from Google' );
1876 }
1877
1878 $modelCount = count( $models );
1879 $availableModels = [];
1880
1881 // Get first 5 models for display
1882 $displayModels = array_slice( $models, 0, 5 );
1883 foreach ( $displayModels as $model ) {
1884 if ( isset( $model['model'] ) ) {
1885 $availableModels[] = $model['model'];
1886 }
1887 }
1888
1889 return [
1890 'success' => true,
1891 'service' => 'Google',
1892 'message' => "Connection successful. Found {$modelCount} Gemini models.",
1893 'details' => [
1894 'endpoint' => $this->endpoint . '/models',
1895 'model_count' => $modelCount,
1896 'sample_models' => $availableModels
1897 ]
1898 ];
1899 }
1900 catch ( Exception $e ) {
1901 return [
1902 'success' => false,
1903 'service' => 'Google',
1904 'error' => $e->getMessage(),
1905 'details' => [
1906 'endpoint' => $this->endpoint . '/models'
1907 ]
1908 ];
1909 }
1910 }
1911
1912 }
1913