Current File : /home/kelaby89/cartel.express/wp-content/plugins/ai-engine/classes/engines/google.php
<?php

class Meow_MWAI_Engines_Google extends Meow_MWAI_Engines_Core {
  // Base (Google).
  protected $apiKey = null;
  protected $region = null;
  protected $projectId = null;
  protected $endpoint = null;

  // Response.
  protected $inModel = null;
  protected $inId = null;

  // Static
  private static $creating = false;

  public static function create( $core, $env ) {
    self::$creating = true;
    if ( class_exists( 'MeowPro_MWAI_Google' ) ) {
      $instance = new MeowPro_MWAI_Google( $core, $env );
    }
    else {
      $instance = new self( $core, $env );
    }
    self::$creating = false;
    return $instance;
  }

  /** Constructor. */
  public function __construct( $core, $env ) {
    $isOwnClass = get_class( $this ) === 'Meow_MWAI_Engines_Google';
    if ( $isOwnClass && !self::$creating ) {
      throw new Exception( 'Please use the create() method to instantiate the Meow_MWAI_Engines_Google class.' );
    }
    parent::__construct( $core, $env );
    $this->set_environment();
  }

  /**
  * Set environment variables based on $this->envType.
  *
  * @throws Exception If environment type is unknown.
  */
  protected function set_environment() {
    $env = $this->env;
    $this->apiKey = $env['apikey'];
    if ( $this->envType === 'google' ) {
      $this->region = isset( $env['region'] ) ? $env['region'] : null;
      $this->projectId = isset( $env['project_id'] ) ? $env['project_id'] : null;
      $this->endpoint = apply_filters(
        'mwai_google_endpoint',
        'https://generativelanguage.googleapis.com/v1beta',
        $this->env
      );
    }
    else {
      throw new Exception( 'Unknown environment type: ' . $this->envType );
    }
  }

  /**
  * Check for a JSON-formatted error in the data, and throw an exception if present.
  *
  * @param string $data
  * @throws Exception
  */
  public function check_for_error( $data ) {
    if ( strpos( $data, 'error' ) === false ) {
      return;
    }
    $jsonPart = ( strpos( $data, 'data: ' ) === 0 ) ? substr( $data, strlen( 'data: ' ) ) : $data;
    $json = json_decode( $jsonPart, true );
    if ( json_last_error() === JSON_ERROR_NONE && isset( $json['error'] ) ) {
      $error = $json['error'];
      $code = $error['code'];
      $message = $error['message'];
      throw new Exception( "Error $code: $message" );
    }
  }

  /**
  * Format function response for Google API
  * Google expects the response to be an object, not a primitive value
  */
  private function format_function_response( $value ) {
    // If it's already an array or object, return as-is
    if ( is_array( $value ) || is_object( $value ) ) {
      return $value;
    }

    // For primitive values (string, number, boolean), wrap in an object
    // This matches Google's expected format
    return [ 'result' => (string) $value ];
  }

  /**
  * Format a function call for internal usage.
  *
  * @param array $rawMessage
  * @return array
  */
  private function format_function_call( $rawMessage ) {
    // If the message already has Google's format with role and parts
    if ( isset( $rawMessage['role'] ) && isset( $rawMessage['parts'] ) &&
        !isset( $rawMessage['content'] ) && !isset( $rawMessage['tool_calls'] ) && !isset( $rawMessage['function_call'] ) ) {
      // Clean up any empty args arrays in functionCall parts
      $cleanedMessage = $rawMessage;
      if ( isset( $cleanedMessage['parts'] ) ) {
        foreach ( $cleanedMessage['parts'] as &$part ) {
          if ( isset( $part['functionCall'] ) && isset( $part['functionCall']['args'] ) ) {
            // Remove empty args arrays - Google doesn't accept them
            if ( empty( $part['functionCall']['args'] ) ) {
              unset( $part['functionCall']['args'] );
            }
          }
        }
      }
      return $cleanedMessage;
    }

    $parts = [];

    // Handle OpenAI-style tool_calls
    if ( isset( $rawMessage['tool_calls'] ) ) {
      foreach ( $rawMessage['tool_calls'] as $tool_call ) {
        if ( $tool_call['type'] === 'function' ) {
          $functionCall = [ 'name' => $tool_call['function']['name'] ];
          $args = $tool_call['function']['arguments'];
          if ( !empty( $args ) ) {
            // If args is a JSON string, decode it
            if ( is_string( $args ) ) {
              $args = json_decode( $args, true );
            }
            if ( !empty( $args ) ) {
              $functionCall['args'] = $args;
            }
          }
          $parts[] = [ 'functionCall' => $functionCall ];
        }
      }
    }
    // Handle single function_call
    elseif ( isset( $rawMessage['function_call'] ) ) {
      $functionCall = [ 'name' => $rawMessage['function_call']['name'] ];
      if ( isset( $rawMessage['function_call']['args'] ) ) {
        // Handle args - could be array, object, or empty
        $args = $rawMessage['function_call']['args'];
        if ( !empty( $args ) ) {
          $functionCall['args'] = $args;
        }
        // Don't include args field if it's empty
      }
      $parts[] = [ 'functionCall' => $functionCall ];
    }

    // Add text content if present
    if ( isset( $rawMessage['content'] ) && !empty( $rawMessage['content'] ) ) {
      $parts[] = [ 'text' => $rawMessage['content'] ];
    }

    // Return the original message if no function calls found, but ensure it's in Google format
    if ( empty( $parts ) ) {
      // Create a minimal valid Google format message
      return [ 'role' => 'model', 'parts' => [ [ 'text' => '' ] ] ];
    }

    return [ 'role' => 'model', 'parts' => $parts ];
  }

  /**
  * Build the messages for the Google API payload.
  *
  * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
  * @return array
  */
  protected function build_messages( $query ) {
    $messages = [];

    // 1. Instructions (if any).
    if ( !empty( $query->instructions ) ) {
      $messages[] = [
        'role' => 'model',
        'parts' => [
          [ 'text' => $query->instructions ]
        ]
      ];
    }

    // 2. Existing messages (already partially formatted).
    foreach ( $query->messages as $message ) {

      // Convert roles: 'assistant' => 'model', 'user' => 'user'.
      $newMessage = [ 'role' => $message['role'], 'parts' => [] ];
      if ( isset( $message['content'] ) ) {
        $newMessage['parts'][] = [ 'text' => $message['content'] ];
      }
      if ( $newMessage['role'] === 'assistant' ) {
        $newMessage['role'] = 'model';
      }
      $messages[] = $newMessage;
    }

    // 3. Context (if any).
    if ( !empty( $query->context ) ) {
      $messages[] = [
        'role' => 'model',
        'parts' => [
          [ 'text' => $query->context ]
        ]
      ];
    }

    // 4. The final user message (check if there is an attached image).
    if ( $query->attachedFile ) {
      $data = $query->attachedFile->get_base64();
      $messages[] = [
        'role' => 'user',
        'parts' => [
          [ 'inlineData' => [ 'mimeType' => 'image/jpeg', 'data' => $data ] ],
          [ 'text' => $query->get_message() ]
        ]
      ];
      // Gemini doesn't support multi-turn chat with Vision.
      $messages = array_slice( $messages, -1 );
    }
    else {
      $messages[] = [
        'role' => 'user',
        'parts' => [
          [ 'text' => $query->get_message() ]
        ]
      ];
    }

    // 5. Streamline messages.
    $messages = $this->streamline_messages( $messages, 'model', 'parts' );

    // Debug: Log message count before feedback
    if ( $this->core->get_option( 'queries_debug_mode' ) ) {
      error_log( '[AI Engine Queries] Messages before feedback: ' . count( $messages ) );
    }

    // 6. Feedback data for Meow_MWAI_Query_Feedback.
    if ( $query instanceof Meow_MWAI_Query_Feedback && !empty( $query->blocks ) ) {
      foreach ( $query->blocks as $feedback_block ) {
        // Debug logging of raw message
        if ( $this->core->get_option( 'queries_debug_mode' ) ) {
          error_log( '[AI Engine Queries] Raw message before formatting: ' . json_encode( $feedback_block['rawMessage'] ) );
        }

        $formattedMessage = $this->format_function_call( $feedback_block['rawMessage'] );

        // Debug logging of formatted message
        if ( $this->core->get_option( 'queries_debug_mode' ) ) {
          error_log( '[AI Engine Queries] Formatted function call message: ' . json_encode( $formattedMessage ) );
        }

        // Check if Google returned multiple function calls but we only have one response
        $functionCallCount = 0;
        if ( isset( $formattedMessage['parts'] ) ) {
          foreach ( $formattedMessage['parts'] as $part ) {
            if ( isset( $part['functionCall'] ) ) {
              $functionCallCount++;
            }
          }
        }

        if ( $functionCallCount > 1 && count( $feedback_block['feedbacks'] ) != $functionCallCount ) {
          // Mismatch between function calls and responses
          // Google requires exact matching of function calls to responses
          $errorMsg = sprintf(
            'Function call/response mismatch: Google returned %d function calls but we have %d response(s). ' .
            'Google requires all function responses to be provided together.',
            $functionCallCount,
            count( $feedback_block['feedbacks'] )
          );

          // Log the error for debugging
          if ( $this->core->get_option( 'queries_debug_mode' ) ) {
            error_log( '[AI Engine Queries] ERROR: ' . $errorMsg );

            // Log which functions were called vs which were responded to
            $calledFunctions = [];
            foreach ( $formattedMessage['parts'] as $part ) {
              if ( isset( $part['functionCall'] ) ) {
                $calledFunctions[] = $part['functionCall']['name'] ?? 'unknown';
              }
            }
            $respondedFunctions = array_map( function ( $fb ) {
              return $fb['request']['name'] ?? 'unknown';
            }, $feedback_block['feedbacks'] );

            error_log( '[AI Engine Queries] Called functions: ' . implode( ', ', $calledFunctions ) );
            error_log( '[AI Engine Queries] Responded functions: ' . implode( ', ', $respondedFunctions ) );
          }

          throw new Exception( $errorMsg );
        }

        $messages[] = $formattedMessage;
        foreach ( $feedback_block['feedbacks'] as $feedback ) {
          $functionResponseMessage = [
            'role' => 'function',
            'parts' => [
              [
                'functionResponse' => [
                  'name' => $feedback['request']['name'],
                  'response' => $this->format_function_response( $feedback['reply']['value'] )
                ]
              ]
            ]
          ];

          // Debug logging of function response
          if ( $this->core->get_option( 'queries_debug_mode' ) ) {
            error_log( '[AI Engine Queries] Function response: ' . json_encode( $functionResponseMessage ) );
          }

          $messages[] = $functionResponseMessage;
        }
      }
    }

    // Debug logging of all messages
    if ( $this->core->get_option( 'queries_debug_mode' ) ) {
      error_log( '[AI Engine Queries] Total messages to Google: ' . count( $messages ) );
      foreach ( $messages as $index => $message ) {
        $role = $message['role'] ?? 'unknown';
        $preview = $role;
        if ( isset( $message['parts'][0] ) ) {
          if ( isset( $message['parts'][0]['text'] ) ) {
            $text = substr( $message['parts'][0]['text'], 0, 50 );
            $preview .= ' (text: "' . $text . '...")';
          }
          elseif ( isset( $message['parts'][0]['functionCall'] ) ) {
            $preview .= ' (functionCall: ' . $message['parts'][0]['functionCall']['name'] . ')';
          }
          elseif ( isset( $message['parts'][0]['functionResponse'] ) ) {
            $preview .= ' (functionResponse: ' . $message['parts'][0]['functionResponse']['name'] . ')';
          }
        }
        error_log( '[AI Engine Queries] Message[' . $index . ']: ' . $preview );
      }
    }

    return $messages;
  }

  /**
  * Build the body for the Google API request.
  *
  * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
  * @param callable $streamCallback
  * @return array
  */
  protected function build_body( $query, $streamCallback = null ) {
    $body = [];

    // Build generation config
    $body['generationConfig'] = [
      'candidateCount' => $query->maxResults,
      'maxOutputTokens' => $query->maxTokens,
      'temperature' => $query->temperature,
      'stopSequences' => []
    ];

    // Add tools if available
    $hasTools = false;

    // Check for functions
    if ( !empty( $query->functions ) ) {
      if ( !isset( $body['tools'] ) ) {
        $body['tools'] = [];
      }
      $body['tools'][] = [ 'function_declarations' => [] ];
      foreach ( $query->functions as $function ) {
        $body['tools'][0]['function_declarations'][] = $function->serializeForOpenAI();
      }
      $body['tool_config'] = [
        'function_calling_config' => [ 'mode' => 'AUTO' ]
      ];
      $hasTools = true;
    }

    // Check for web_search tool
    if ( !empty( $query->tools ) && in_array( 'web_search', $query->tools ) ) {
      if ( !isset( $body['tools'] ) ) {
        $body['tools'] = [];
      }
      $body['tools'][] = [ 'google_search' => (object) [] ];
      $hasTools = true;
    }

    // Check for thinking tool (Gemini 2.5+ models)
    if ( !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
      if ( !isset( $body['generationConfig']['thinkingConfig'] ) ) {
        $body['generationConfig']['thinkingConfig'] = [];
      }
      // Use dynamic thinking by default (-1 lets the model decide)
      $body['generationConfig']['thinkingConfig']['thinkingBudget'] = -1;
      
      // Always include thought summaries when thinking is enabled
      // This allows us to see thinking events in the UI
      $body['generationConfig']['thinkingConfig']['includeThoughts'] = true;
      
      // Log that thinking is enabled
      if ( $this->core->get_option( 'queries_debug_mode' ) ) {
        error_log( '[AI Engine] Thinking tool enabled for Gemini with dynamic budget' );
      }
    }

    // Build messages
    $body['contents'] = $this->build_messages( $query );

    // Note: Function result events are now emitted centrally in core.php
    // when the function is actually executed

    return $body;
  }

  /**
  * Build headers for the request.
  *
  * @param Meow_MWAI_Query_Completion|Meow_MWAI_Query_Feedback $query
  * @throws Exception If no API Key is provided.
  * @return array
  */
  protected function build_headers( $query ) {
    if ( $query->apiKey ) {
      $this->apiKey = $query->apiKey;
    }
    if ( empty( $this->apiKey ) ) {
      throw new Exception( 'No API Key provided. Please visit the Settings.' );
    }
    return [ 'Content-Type' => 'application/json' ];
  }

  /**
  * Build WP remote request options.
  *
  * @param array  $headers
  * @param array  $json
  * @param array  $forms
  * @param string $method
  * @throws Exception If form-data requests are used (unsupported).
  * @return array
  */
  protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
    $body = null;
    if ( !empty( $forms ) ) {
      throw new Exception( 'No support for form-data requests yet.' );
    }
    else if ( !empty( $json ) ) {
      $body = json_encode( $json );
    }
    return [
      'headers' => $headers,
      'method' => $method,
      'timeout' => MWAI_TIMEOUT,
      'body' => $body,
      'sslverify' => false
    ];
  }

  /**
  * Run the query against the Google endpoint.
  *
  * @param string $url
  * @param array  $options
  * @throws Exception
  * @return array
  */
  public function run_query( $url, $options ) {

    try {
      $res = wp_remote_get( $url, $options );
      if ( is_wp_error( $res ) ) {
        throw new Exception( $res->get_error_message() );
      }
      $response = wp_remote_retrieve_body( $res );
      $headersRes = wp_remote_retrieve_headers( $res );
      $headers = $headersRes->getAll();
      $normalizedHeaders = array_change_key_case( $headers, CASE_LOWER );
      $resContentType = $normalizedHeaders['content-type'] ?? '';
      if (
        strpos( $resContentType, 'multipart/form-data' ) !== false ||
          strpos( $resContentType, 'text/plain' ) !== false
      ) {
        return [
          'headers' => $headers,
          'data' => $response
        ];
      }
      $data = json_decode( $response, true );
      $this->handle_response_errors( $data );
      return [ 'headers' => $headers, 'data' => $data ];
    }
    catch ( Exception $e ) {
      Meow_MWAI_Logging::error( '(Google) ' . $e->getMessage() );
      throw $e;
    }
  }

  /**
  * Run a completion query on the Google endpoint.
  *
  * @param Meow_MWAI_Query_Completion $query
  * @throws Exception
  * @return Meow_MWAI_Reply
  */
  public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
    // Reset request-specific state to prevent leakage between requests
    $this->reset_request_state();

    // Initialize debug mode
    $this->init_debug_mode( $query );

    // Build body using the new method which handles event emission
    $body = $this->build_body( $query, $streamCallback );

    $url = $this->endpoint . '/models/' . $query->model . ':generateContent';
    if ( strpos( $url, '?' ) === false ) {
      $url .= '?key=' . $this->apiKey;
    }
    else {
      $url .= '&key=' . $this->apiKey;
    }

    $headers = $this->build_headers( $query );
    $options = $this->build_options( $headers, $body );

    // Emit "Request sent" event for feedback queries
    if ( $this->currentDebugMode && !empty( $streamCallback ) &&
         ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
      $event = Meow_MWAI_Event::request_sent()
        ->set_metadata( 'is_feedback', true )
        ->set_metadata( 'feedback_count', count( $query->blocks ) );
      call_user_func( $streamCallback, $event );
    }

    try {
      $res = $this->run_query( $url, $options );
      $reply = new Meow_MWAI_Reply( $query );

      $data = $res['data'];
      if ( empty( $data ) ) {
        throw new Exception( 'No content received (res is null).' );
      }

      $returned_choices = [];
      if ( isset( $data['candidates'] ) ) {
        // Debug: Log if we're using thinking
        if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
          error_log( '[AI Engine] Processing response with thinking enabled' );
          if ( isset( $data['candidates'][0] ) ) {
            error_log( '[AI Engine] Full candidate structure: ' . json_encode( $data['candidates'][0] ) );
          }
        }
        
        foreach ( $data['candidates'] as $candidate ) {
          $content = $candidate['content'];

          // Check if there are any parts with function calls
          $functionCalls = [];
          $textContent = '';

          if ( isset( $content['parts'] ) ) {
            // Debug: Log the parts structure when thinking is enabled
            if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
              error_log( '[AI Engine] Response parts: ' . json_encode( $content['parts'] ) );
            }
            
            foreach ( $content['parts'] as $part ) {
              if ( isset( $part['functionCall'] ) ) {
                $functionCalls[] = $part['functionCall'];

                // Emit function calling event if debug mode is enabled
                if ( $this->currentDebugMode && !empty( $streamCallback ) ) {
                  $functionName = $part['functionCall']['name'] ?? 'unknown';
                  $functionArgs = isset( $part['functionCall']['args'] ) ? json_encode( $part['functionCall']['args'] ) : '';

                  $event = Meow_MWAI_Event::function_calling( $functionName, $functionArgs );
                  call_user_func( $streamCallback, $event );
                }
              }
              elseif ( isset( $part['text'] ) ) {
                // Check if this is a thought part (Gemini thinking)
                if ( isset( $part['thought'] ) && $part['thought'] === true ) {
                  // Emit thought event if streaming is available
                  if ( !empty( $streamCallback ) ) {
                    $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['THINKING'] );
                    $event->set_content( $part['text'] );
                    call_user_func( $streamCallback, $event );
                  }
                  // Store thought summaries in reply metadata
                  if ( !isset( $reply->extraData['thoughts'] ) ) {
                    $reply->extraData['thoughts'] = [];
                  }
                  $reply->extraData['thoughts'][] = $part['text'];
                }
                else {
                  // Regular text content
                  $textContent .= $part['text'];
                }
              }
            }
          }

          // If we have function calls, return them in Google's expected format
          if ( !empty( $functionCalls ) ) {
            // Debug: Log when we find multiple function calls
            if ( $this->core->get_option( 'queries_debug_mode' ) ) {
              error_log( '[AI Engine Queries] Google returned ' . count( $functionCalls ) . ' function calls in one response' );
              foreach ( $functionCalls as $idx => $fc ) {
                error_log( '[AI Engine Queries] Function call[' . $idx . ']: ' . $fc['name'] );
              }
            }

            // Google can return multiple function calls that need to be executed together
            // When this happens, we create separate choices but they share the same rawMessage
            $sharedRawMessage = $content; // The original Google response

            foreach ( $functionCalls as $function_call ) {
              $returned_choices[] = [
                'message' => [
                  'content' => null,
                  'function_call' => $function_call
                ],
                '_rawMessage' => $sharedRawMessage // Store for later use
              ];
            }
          }

          // Add text content if present (separate from function calls)
          if ( !empty( $textContent ) ) {
            $returned_choices[] = [ 'role' => 'assistant', 'text' => $textContent ];
          }
        }
      }

      // Create a proper Google-formatted rawMessage for the function calls
      $googleRawMessage = null;
      if ( isset( $data['candidates'][0]['content'] ) ) {
        $googleRawMessage = $data['candidates'][0]['content'];
      }

      $reply->set_choices( $returned_choices, $googleRawMessage );

      // Handle grounding metadata if present (from web search)
      if ( isset( $data['candidates'][0]['groundingMetadata'] ) ) {
        $groundingMetadata = $data['candidates'][0]['groundingMetadata'];

        // Add grounding metadata to the reply for potential use
        $reply->extraData['groundingMetadata'] = $groundingMetadata;

        // If debug mode is enabled and we have a stream callback, emit web search events
        if ( $this->currentDebugMode && !empty( $streamCallback ) && isset( $groundingMetadata['searchQueries'] ) ) {
          foreach ( $groundingMetadata['searchQueries'] as $searchQuery ) {
            $event = new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['WEB_SEARCH'] );
            $event->set_content( 'Searching: ' . $searchQuery );
            call_user_func( $streamCallback, $event );
          }
        }
      }

      // Debug: Check how many feedbacks were created
      if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $reply->needFeedbacks ) ) {
        error_log( '[AI Engine Queries] Google reply has ' . count( $reply->needFeedbacks ) . ' needFeedbacks' );
        foreach ( $reply->needFeedbacks as $idx => $feedback ) {
          error_log( '[AI Engine Queries] Feedback[' . $idx . ']: ' . $feedback['name'] );
        }
      }

      // Handle usage metadata including thinking tokens if present
      if ( isset( $data['usageMetadata'] ) ) {
        $usageMetadata = $data['usageMetadata'];
        
        // Extract thinking tokens if available
        if ( isset( $usageMetadata['thoughtsTokenCount'] ) ) {
          $reply->extraData['thoughtsTokenCount'] = $usageMetadata['thoughtsTokenCount'];
          
          // Log thinking tokens in debug mode
          if ( $this->core->get_option( 'queries_debug_mode' ) ) {
            error_log( '[AI Engine Queries] Thinking tokens used: ' . $usageMetadata['thoughtsTokenCount'] );
          }
        }
        
        // Pass token counts if available
        $inTokens = isset( $usageMetadata['promptTokenCount'] ) ? $usageMetadata['promptTokenCount'] : null;
        $outTokens = isset( $usageMetadata['candidatesTokenCount'] ) ? $usageMetadata['candidatesTokenCount'] : null;
        $this->handle_tokens_usage( $reply, $query, $query->model, $inTokens, $outTokens );
      }
      else {
        $this->handle_tokens_usage( $reply, $query, $query->model, null, null );
      }
      
      return $reply;
    }
    catch ( Exception $e ) {
      // Add more context for common Google errors
      $errorMessage = $e->getMessage();

      if ( strpos( $errorMessage, 'number of function response parts is equal to the number of function call parts' ) !== false ) {
        $errorMessage = 'Google requires all function responses to match the number of function calls. ' .
                       'This error typically occurs when there is a mismatch between the number of ' .
                       'function calls made by the AI and the number of responses provided.';
      }

      Meow_MWAI_Logging::error( '(Google) ' . $errorMessage );
      throw new Exception( 'From Google: ' . $errorMessage );
    }
  }

  /**
  * Handle usage tokens.
  */
  public function handle_tokens_usage( $reply, $query, $returned_model, $returned_in_tokens, $returned_out_tokens ) {
    $returned_in_tokens = !is_null( $returned_in_tokens ) ? $returned_in_tokens : $reply->get_in_tokens( $query );
    $returned_out_tokens = !is_null( $returned_out_tokens ) ? $returned_out_tokens : $reply->get_out_tokens();
    $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
    $reply->set_usage( $usage );
  }

  /**
  * Check if there are errors in the response from Google, and throw an exception if so.
  *
  * @param array $data
  * @throws Exception
  */
  public function handle_response_errors( $data ) {
    if ( isset( $data['error'] ) ) {
      $message = $data['error']['message'];
      if ( preg_match( '/API key provided(: .*)\./', $message, $matches ) ) {
        $message = str_replace( $matches[1], '', $message );
      }
      throw new Exception( $message );
    }
  }

  /**
  * Get models via the core method.
  *
  * @return array
  */
  public function get_models() {
    return $this->core->get_engine_models( 'google' );
  }

  /**
  * Retrieve models from Google's generative language endpoint.
  *
  * @throws Exception
  * @return array
  */
  private function format_model_name( $model_id ) {
    // Special cases for specific models that need manual handling
    $special_names = [
      'gemini-live-2.5-flash-preview' => 'Gemini 2.5 Flash Live',
      'gemini-2.0-flash-live-001' => 'Gemini 2.0 Flash Live',
    ];

    if ( isset( $special_names[$model_id] ) ) {
      return $special_names[$model_id];
    }

    // Store original for differentiating similar models
    $original_id = $model_id;
    
    // Remove common suffixes but keep track if we need to differentiate
    $cleaned = $model_id;
    
    // Extract date suffix if present (like -preview-03-25)
    $date_suffix = '';
    if ( preg_match( '/-preview-(\d{2}-\d{2})(?:-thinking)?$/', $cleaned, $matches ) ) {
      $date_suffix = $matches[1];
      $cleaned = preg_replace( '/-preview-\d{2}-\d{2}(?:-thinking)?$/', '', $cleaned );
    }
    
    // Check if it's a thinking model
    $is_thinking = strpos( $original_id, '-thinking' ) !== false;
    if ( $is_thinking ) {
      $cleaned = str_replace( '-thinking', '', $cleaned );
    }
    
    // Check if it's a TTS preview model
    $is_preview_tts = strpos( $original_id, 'preview-tts' ) !== false;
    
    // Keep version suffixes (like -001, -002) if they help distinguish models
    $has_version_suffix = preg_match( '/-\d{3}$/', $cleaned );
    $version_suffix = '';
    if ( $has_version_suffix ) {
      preg_match( '/(-\d{3})$/', $cleaned, $matches );
      $version_suffix = $matches[1];
      $cleaned = preg_replace( '/-\d{3}$/', '', $cleaned );
    }
    
    // Track if it's a preview model
    $is_preview = strpos( $cleaned, '-preview' ) !== false || !empty( $date_suffix );
    $cleaned = preg_replace( '/-preview$/', '', $cleaned );
    
    // Track if it's experimental
    $is_experimental = strpos( $original_id, '-exp' ) !== false;
    $cleaned = preg_replace( '/-exp$/', '', $cleaned );
    $cleaned = preg_replace( '/-generate$/', '', $cleaned );
    
    // Don't remove -latest suffix here, we'll handle it separately
    $has_latest = strpos( $cleaned, '-latest' ) !== false;
    $cleaned = preg_replace( '/-latest$/', '', $cleaned );

    // Handle specific feature names
    if ( strpos( $cleaned, 'preview-native-audio-dialog' ) !== false ) {
      $cleaned = str_replace( 'preview-native-audio-dialog', 'Native Audio', $cleaned );
    }
    else if ( strpos( $cleaned, 'exp-native-audio-thinking-dialog' ) !== false ) {
      $cleaned = str_replace( 'exp-native-audio-thinking-dialog', 'Native Audio', $cleaned );
    }
    else if ( strpos( $cleaned, 'preview-image-generation' ) !== false ) {
      $cleaned = str_replace( 'preview-image-generation', 'Preview Image Generation', $cleaned );
    }
    else if ( strpos( $cleaned, 'preview-tts' ) !== false ) {
      $cleaned = str_replace( 'preview-tts', '', $cleaned );
      // We'll add (Preview TTS) as a suffix later
    }

    // Parse components
    $parts = explode( '-', $cleaned );
    $formatted_parts = [];

    // Process each part
    foreach ( $parts as $part ) {
      if ( $part === 'gemini' ) {
        $formatted_parts[] = 'Gemini';
      }
      else if ( $part === 'imagen' ) {
        $formatted_parts[] = 'Imagen';
      }
      else if ( $part === 'veo' ) {
        $formatted_parts[] = 'Veo';
      }
      else if ( $part === 'pro' ) {
        $formatted_parts[] = 'Pro';
      }
      else if ( $part === 'flash' ) {
        $formatted_parts[] = 'Flash';
      }
      else if ( $part === 'lite' ) {
        // Check if previous part was Flash to create Flash-Lite
        if ( !empty( $formatted_parts ) && $formatted_parts[count( $formatted_parts ) - 1] === 'Flash' ) {
          $formatted_parts[count( $formatted_parts ) - 1] = 'Flash-Lite';
        }
        else {
          $formatted_parts[] = 'Lite';
        }
      }
      else if ( $part === 'ultra' ) {
        $formatted_parts[] = 'Ultra';
      }
      else if ( $part === 'tts' || $part === 'TTS' ) {
        $formatted_parts[] = 'TTS';
      }
      else if ( preg_match( '/^\d+\.\d+$/', $part ) ) {
        // Version numbers
        $formatted_parts[] = $part;
      }
      else if ( preg_match( '/^(\d+)B$/i', $part, $matches ) ) {
        // Model sizes like 8B - be consistent with capitalization
        $formatted_parts[] = '-' . $matches[1] . 'B';
      }
      else if ( $part === 'latest' ) {
        // Don't include 'latest' here as it's handled separately
        continue;
      }
      else if ( !in_array( $part, ['generate', 'preview', 'exp'] ) ) {
        // Keep other parts unless they're common suffixes
        $formatted_parts[] = ucfirst( $part );
      }
    }

    // Join with appropriate spacing
    $name = implode( ' ', $formatted_parts );

    // Clean up double spaces and fix specific patterns
    $name = preg_replace( '/\s+/', ' ', $name );
    $name = str_replace( ' -', '-', $name );

    // Special formatting for Imagen and Veo versions
    if ( strpos( $name, 'Imagen 4.0' ) === 0 ) {
      $name = str_replace( 'Imagen 4.0', 'Imagen 4', $name );
    }
    else if ( strpos( $name, 'Veo 2.0' ) === 0 ) {
      $name = str_replace( 'Veo 2.0', 'Veo 2', $name );
    }
    
    // Check for date pattern "xx xx" where x are numbers (like "03 07")
    $date_from_name = '';
    if ( preg_match( '/\s(\d{2})\s(\d{2})$/', $name, $matches ) ) {
      $date_from_name = $matches[1] . '/' . $matches[2];
      // Remove the date pattern from the name (we'll add it as a suffix later)
      $name = preg_replace( '/\s\d{2}\s\d{2}$/', '', $name );
    }
    
    // Add suffixes to distinguish similar models
    $suffixes = [];
    
    // Add date from name if found (like "03 07")
    if ( !empty( $date_from_name ) ) {
      $suffixes[] = $date_from_name;
    }
    // Add date suffix for preview models with dates
    else if ( !empty( $date_suffix ) ) {
      // Convert date format from MM-DD to a more readable format
      $parts = explode( '-', $date_suffix );
      if ( count( $parts ) == 2 ) {
        $month = intval( $parts[0] );
        $day = intval( $parts[1] );
        $months = [ '', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
        $suffixes[] = $months[$month] . ' ' . $day;
      }
    }
    // Otherwise, add preview suffix if it's a preview model
    else if ( $is_preview && strpos( $name, 'Preview' ) === false && strpos( $name, 'preview' ) === false ) {
      $suffixes[] = 'Preview';
    }
    
    // Add version suffix for numbered models (like -001, -002)
    // Special handling: if base model exists (without -001), then -001 should be marked
    if ( !empty( $version_suffix ) ) {
      // Extract just the number without the dash
      $version_num = str_replace( '-', '', $version_suffix );
      $version_int = intval( $version_num );
      
      // Always add version suffix for -001 if it's not the only version
      // This helps distinguish when both base and -001 exist
      if ( $version_int === 1 ) {
        // Check if this looks like a model that might have a base version
        // (e.g., gemini-2.0-flash vs gemini-2.0-flash-001)
        if ( strpos( $original_id, 'flash-8b-001' ) !== false ||
             strpos( $original_id, 'flash-001' ) !== false ||
             strpos( $original_id, 'flash-lite-001' ) !== false ) {
          $suffixes[] = 'v1';
        }
      } else {
        // For -002 and higher, always add version
        $suffixes[] = 'v' . ltrim( $version_num, '0' );
      }
    }
    
    // Handle "latest" suffix
    if ( $has_latest && strpos( $name, 'Latest' ) === false ) {
      $suffixes[] = 'Latest';
    }
    
    // Handle thinking models
    if ( $is_thinking && strpos( $name, 'Thinking' ) === false ) {
      $suffixes[] = 'Thinking';
    }
    
    // Handle TTS preview models
    if ( $is_preview_tts ) {
      $suffixes[] = 'Preview TTS';
    }
    
    // Append all suffixes with parentheses
    if ( !empty( $suffixes ) ) {
      $name .= ' (' . implode( ', ', $suffixes ) . ')';
    }

    return trim( $name );
  }

  public function retrieve_models() {
    $url = $this->endpoint . '/models?key=' . $this->apiKey;
    $response = wp_remote_get( $url );
    if ( is_wp_error( $response ) ) {
      throw new Exception( 'AI Engine: ' . $response->get_error_message() );
    }
    $body = json_decode( $response['body'], true );
    $models = [];
    foreach ( $body['models'] as $model ) {
      // Determine model family
      $family = 'gemini';
      if ( strpos( $model['name'], 'imagen' ) !== false ) {
        $family = 'imagen';
      }
      else if ( strpos( $model['name'], 'veo' ) !== false ) {
        $family = 'veo';
      }
      else if ( strpos( $model['name'], 'gemini' ) === false ) {
        // Skip models that aren't gemini, imagen, or veo
        continue;
      }
      $maxCompletionTokens = $model['outputTokenLimit'];
      $maxContextualTokens = $model['inputTokenLimit'];
      $priceIn = 0;
      $priceOut = 0;

      // If Model Name contains "Experimental", skip it (except for embedding models)
      if ( strpos( $model['name'], '-exp' ) !== false && strpos( $model['name'], 'embedding' ) === false ) {
        error_log( 'Skipping experimental model: ' . $model['name'] );
        continue;
      }

      // Set tags based on model family and features
      $tags = [ 'core' ];
      $features = [ 'completion' ];

      if ( $family === 'imagen' ) {
        $tags[] = 'image-generation';
        $features = [ 'image-generation' ];
      }
      else if ( $family === 'veo' ) {
        $tags[] = 'video-generation';
        $features = [ 'video-generation' ];
      }
      else {
        // Gemini models
        $tags[] = 'chat';

        if ( preg_match( '/\((beta|alpha|preview)\)/i', $model['name'] ) ) {
          $tags[] = 'preview';
          $model['name'] = preg_replace( '/\((beta|alpha|preview)\)/i', '', $model['name'] );
        }
        if ( preg_match( '/vision/i', $model['name'] ) ) {
          $tags[] = 'vision';
        }
        else if ( preg_match( '/(vision|multimodal)/i', $model['description'] ) ) {
          $tags[] = 'vision';
        }
        if ( preg_match( '/flash/i', $model['name'] ) ) {
          $tags[] = 'vision';
          $tags[] = 'functions';
        }
        if ( preg_match( '/(tts|text-to-speech)/i', $model['name'] ) ) {
          $tags[] = 'tts';
          $features = [ 'text-to-speech' ];
        }
        if ( preg_match( '/embedding/i', $model['name'] ) ) {
          $tags[] = 'embedding';
          $tags[] = 'matryoshka'; // Gemini embeddings support matryoshka (dimension truncation)
          $features = [ 'embedding' ];
          
          // Check if it's an experimental embedding model
          if ( strpos( $model['name'], '-exp' ) !== false ) {
            $tags[] = 'experimental';
          }
        }
      }
      $model_id = preg_replace( '/^models\//', '', $model['name'] );
      $nice_name = $this->format_model_name( $model_id );
      
      // Default tools
      $tools = [ 'web_search' ];
      
      // Add thinking tool for Gemini 2.5 models
      if ( preg_match( '/gemini-2\.5-(pro|flash)/i', $model_id ) ) {
        $tools[] = 'thinking';
        $tags[] = 'thinking';
      }
      
      $model = [
        'model' => $model_id,
        'name' => $nice_name,
        'family' => $family,
        'features' => $features,
        'type' => 'token',
        'unit' => 1 / 1000,
        'maxCompletionTokens' => $maxCompletionTokens,
        'maxContextualTokens' => $maxContextualTokens,
        'tags' => $tags,
        'tools' => $tools
      ];
      
      // Add dimensions for embedding models
      if ( in_array( 'embedding', $tags ) ) {
        // Gemini embedding models have 3072 dimensions
        $model['dimensions'] = [ 3072 ];
      }
      if ( $priceIn > 0 && $priceOut > 0 ) {
        $model['price'] = [ 'in' => $priceIn, 'out' => $priceOut ];
      }
      $models[] = $model;
    }
    
    // Sort models to put most recent versions first
    usort( $models, function( $a, $b ) {
      // First, sort by family (gemini, imagen, veo)
      $family_order = [ 'gemini' => 1, 'imagen' => 2, 'veo' => 3 ];
      $family_a = $family_order[$a['family']] ?? 999;
      $family_b = $family_order[$b['family']] ?? 999;
      
      if ( $family_a !== $family_b ) {
        return $family_a - $family_b;
      }
      
      // Within the same family, extract version numbers and sort descending
      $model_a = $a['model'];
      $model_b = $b['model'];
      
      // Extract version numbers (e.g., 2.5, 2.0, 1.5, 1.0)
      preg_match( '/(\d+\.\d+)/', $model_a, $matches_a );
      preg_match( '/(\d+\.\d+)/', $model_b, $matches_b );
      
      $version_a = isset( $matches_a[1] ) ? floatval( $matches_a[1] ) : 0;
      $version_b = isset( $matches_b[1] ) ? floatval( $matches_b[1] ) : 0;
      
      // Sort by version descending (newer first)
      if ( $version_a !== $version_b ) {
        return $version_b <=> $version_a;
      }
      
      // For same version, sort by model variant
      // Priority: pro > flash > flash-8b > flash-lite
      $variant_order = [
        'pro' => 1,
        'flash' => 2,
        'flash-8b' => 3,
        'flash-lite' => 4,
      ];
      
      // Determine variant
      $variant_a = 'other';
      $variant_b = 'other';
      
      if ( strpos( $model_a, 'pro' ) !== false ) $variant_a = 'pro';
      elseif ( strpos( $model_a, 'flash-lite' ) !== false ) $variant_a = 'flash-lite';
      elseif ( strpos( $model_a, 'flash-8b' ) !== false ) $variant_a = 'flash-8b';
      elseif ( strpos( $model_a, 'flash' ) !== false ) $variant_a = 'flash';
      
      if ( strpos( $model_b, 'pro' ) !== false ) $variant_b = 'pro';
      elseif ( strpos( $model_b, 'flash-lite' ) !== false ) $variant_b = 'flash-lite';
      elseif ( strpos( $model_b, 'flash-8b' ) !== false ) $variant_b = 'flash-8b';
      elseif ( strpos( $model_b, 'flash' ) !== false ) $variant_b = 'flash';
      
      $order_a = $variant_order[$variant_a] ?? 999;
      $order_b = $variant_order[$variant_b] ?? 999;
      
      if ( $order_a !== $order_b ) {
        return $order_a - $order_b;
      }
      
      // For same variant, sort by specific suffixes
      // Base model > latest > dated previews > numbered versions
      $is_base_a = !preg_match( '/-(?:latest|preview|\d{3})/', $model_a );
      $is_base_b = !preg_match( '/-(?:latest|preview|\d{3})/', $model_b );
      
      if ( $is_base_a && !$is_base_b ) return -1;
      if ( !$is_base_a && $is_base_b ) return 1;
      
      // Latest comes after base
      $is_latest_a = strpos( $model_a, '-latest' ) !== false;
      $is_latest_b = strpos( $model_b, '-latest' ) !== false;
      
      if ( $is_latest_a && !$is_latest_b ) return -1;
      if ( !$is_latest_a && $is_latest_b ) return 1;
      
      // Then preview models (sorted by date descending)
      preg_match( '/-preview-(\d{2})-(\d{2})/', $model_a, $date_a );
      preg_match( '/-preview-(\d{2})-(\d{2})/', $model_b, $date_b );
      
      if ( !empty( $date_a ) && !empty( $date_b ) ) {
        // Compare dates (month then day)
        $month_a = intval( $date_a[1] );
        $month_b = intval( $date_b[1] );
        if ( $month_a !== $month_b ) {
          return $month_b - $month_a; // Descending
        }
        $day_a = intval( $date_a[2] );
        $day_b = intval( $date_b[2] );
        return $day_b - $day_a; // Descending
      }
      
      if ( !empty( $date_a ) && empty( $date_b ) ) return -1;
      if ( empty( $date_a ) && !empty( $date_b ) ) return 1;
      
      // Finally, numbered versions (descending)
      preg_match( '/-(\d{3})$/', $model_a, $num_a );
      preg_match( '/-(\d{3})$/', $model_b, $num_b );
      
      if ( !empty( $num_a ) && !empty( $num_b ) ) {
        return intval( $num_b[1] ) - intval( $num_a[1] );
      }
      
      // Fallback to string comparison
      return strcasecmp( $model_a, $model_b );
    });
    
    return $models;
  }

  /**
  * Google pricing is not currently supported.
  *
  * @return null
  */
  public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
    return null;
  }

  /**
   * Check the connection to Google by listing models.
   * Uses the existing retrieve_models method with a limit for quick check.
   */
  public function connection_check() {
    try {
      // Use the existing retrieve_models method
      $models = $this->retrieve_models();

      if ( !is_array( $models ) ) {
        throw new Exception( 'Invalid response format from Google' );
      }

      $modelCount = count( $models );
      $availableModels = [];

      // Get first 5 models for display
      $displayModels = array_slice( $models, 0, 5 );
      foreach ( $displayModels as $model ) {
        if ( isset( $model['model'] ) ) {
          $availableModels[] = $model['model'];
        }
      }

      return [
        'success' => true,
        'service' => 'Google',
        'message' => "Connection successful. Found {$modelCount} Gemini models.",
        'details' => [
          'endpoint' => $this->endpoint . '/models',
          'model_count' => $modelCount,
          'sample_models' => $availableModels,
          'region' => $this->region ?? 'us-central1'
        ]
      ];
    }
    catch ( Exception $e ) {
      return [
        'success' => false,
        'service' => 'Google',
        'error' => $e->getMessage(),
        'details' => [
          'endpoint' => $this->endpoint . '/models'
        ]
      ];
    }
  }
}
Page not found – Hello World !