google.php
| 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 |