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 ) { 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' ] ]; } } }