api_key ) ) { return new WP_Error( 'no_api_key', __( 'OpenRouter API key is not configured.', 'wp-agentic-writer' ) ); } // Fetch all models from OpenRouter API. $response = wp_remote_get( 'https://openrouter.ai/api/v1/models', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { return $response; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( isset( $data['error'] ) ) { return new WP_Error( 'api_error', $data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' ) ); } $models = $data['data'] ?? array(); // Debug: Log model count and categorize by output_modalities if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'OpenRouter API total models: ' . count( $models ) ); // Count models by output modality $text_count = 0; $image_count = 0; $image_model_ids = array(); foreach ( $models as $model ) { $output_modalities = $model['architecture']['output_modalities'] ?? array(); if ( in_array( 'text', $output_modalities, true ) ) { $text_count++; } if ( in_array( 'image', $output_modalities, true ) ) { $image_count++; $image_model_ids[] = $model['id'] . ' (' . ( $model['name'] ?? 'N/A' ) . ')'; } } error_log( "OpenRouter models by output_modalities: TEXT={$text_count}, IMAGE={$image_count}" ); error_log( 'Image generation models: ' . implode( ', ', array_slice( $image_model_ids, 0, 20 ) ) ); } // Cache for 24 hours. set_transient( 'wpaw_openrouter_models', $models, DAY_IN_SECONDS ); return $models; } /** * Fetch models and refresh cache when requested. * * @since 0.1.0 * @param bool $force_refresh Whether to refresh cache. * @return array|WP_Error Models array or WP_Error on failure. */ public function fetch_and_cache_models( $force_refresh = false ) { if ( $force_refresh ) { delete_transient( 'wpaw_openrouter_models' ); } return $this->get_cached_models(); } /** * Get writing model name (legacy: execution model). * * @since 0.1.0 * @return string */ public function get_execution_model() { return $this->writing_model; } /** * Get model for a specific task type. * * @since 0.1.0 * @param string $type Task type (chat, clarity, planning, writing, execution, refinement). * @param array $options Options array that may contain 'model' override. * @return string Model ID. */ private function get_model_for_type( $type, $options = array() ) { if ( isset( $options['model'] ) ) { return $options['model']; } switch ( $type ) { case 'chat': return $this->chat_model; case 'clarity': return $this->clarity_model; case 'writing': case 'execution': return $this->writing_model; case 'refinement': return $this->refinement_model; case 'planning': default: return $this->planning_model; } } /** * Get singleton instance. * * @since 0.1.0 * @return WP_Agentic_Writer_OpenRouter_Provider */ public static function get_instance() { static $instance = null; if ( null === $instance ) { $instance = new self(); } return $instance; } /** * Constructor. * * @since 0.1.0 */ private function __construct() { // Get settings from the unified settings array. $settings = get_option( 'wp_agentic_writer_settings', array() ); $this->api_key = $settings['openrouter_api_key'] ?? ''; // Get models from settings (6 models per model-preset-brief.md). $this->chat_model = $settings['chat_model'] ?? $this->chat_model; $this->clarity_model = $settings['clarity_model'] ?? $this->clarity_model; $this->planning_model = $settings['planning_model'] ?? $this->planning_model; $this->writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? $this->writing_model ); $this->refinement_model = $settings['refinement_model'] ?? $this->refinement_model; $this->image_model = $settings['image_model'] ?? $this->image_model; // Get web search settings. $this->web_search_enabled = isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled']; $this->search_depth = $settings['search_depth'] ?? 'medium'; $this->search_engine = $settings['search_engine'] ?? 'auto'; } /** * Chat completion (non-streaming). * * @since 0.1.0 * @param array $messages Chat messages. * @param array $options Additional options (model, max_tokens, etc.). * @param string $type Request type (planning or execution). * @return array|WP_Error Response array or WP_Error on failure. */ public function chat( $messages, $options = array(), $type = 'planning' ) { // Check API key. if ( empty( $this->api_key ) ) { return new WP_Error( 'no_api_key', __( 'OpenRouter API key is not configured.', 'wp-agentic-writer' ) ); } $web_search_enabled = $this->web_search_enabled; if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) { $web_search_enabled = (bool) $options['web_search_enabled']; } $search_depth = $options['search_depth'] ?? $this->search_depth; $search_engine = $options['search_engine'] ?? $this->search_engine; // Determine model based on type (6 models per model-preset-brief.md). $model = $this->get_model_for_type( $type, $options ); // Add :online suffix if web search is enabled. if ( $web_search_enabled && 'planning' === $type ) { $model .= ':online'; } // Build request body. $body = array( 'model' => $model, 'messages' => $messages, 'usage' => array( 'include' => true, ), ); // Add optional parameters. if ( isset( $options['max_tokens'] ) ) { $body['max_tokens'] = $options['max_tokens']; } if ( isset( $options['temperature'] ) ) { $body['temperature'] = $options['temperature']; } // Add web search options if enabled. if ( $web_search_enabled && 'planning' === $type ) { $body['plugins'] = array( array( 'id' => 'web', 'web_search_options' => array( 'search_context_size' => $search_depth, 'max_results' => 5, ), ), ); // Set search engine if specified. if ( 'auto' !== $search_engine ) { $body['plugins'][0]['web_search_options']['engine'] = $search_engine; } } // Send request. $response = wp_remote_post( $this->api_endpoint, array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url(), 'X-Title' => 'WP Agentic Writer', ), 'body' => wp_json_encode( $body ), 'timeout' => 120, // 2 minutes timeout. ) ); // Check for errors. if ( is_wp_error( $response ) ) { return $response; } // Get response body. $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); // Check for API errors. if ( isset( $data['error'] ) ) { return new WP_Error( 'api_error', $data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' ) ); } // Extract response data. $content = $data['choices'][0]['message']['content'] ?? ''; $input_tokens = $data['usage']['prompt_tokens'] ?? 0; $output_tokens = $data['usage']['completion_tokens'] ?? 0; $cost = $data['usage']['cost'] ?? 0.0; // Extract web search results if available. $web_search_results = array(); if ( isset( $data['choices'][0]['message']['annotations'] ) ) { foreach ( $data['choices'][0]['message']['annotations'] as $annotation ) { if ( isset( $annotation['url'] ) ) { $web_search_results[] = array( 'url' => $annotation['url'], 'title' => $annotation['title'] ?? '', 'description' => $annotation['description'] ?? '', ); } } } return array( 'content' => $content, 'input_tokens' => $input_tokens, 'output_tokens' => $output_tokens, 'total_tokens' => $input_tokens + $output_tokens, 'cost' => $cost, 'model' => $model, 'web_search_results' => $web_search_results, ); } /** * Stream chat completion with callback for each chunk. * * This method streams the AI response token by token, calling the callback * function with each accumulated chunk. This provides real-time feedback * to the user instead of waiting for the complete response. * * @since 0.1.0 * @param array $messages Chat messages. * @param array $options Additional options (model, max_tokens, etc.). * @param string $type Request type (planning or execution). * @param callable $callback Callback function( $chunk, $is_complete, $full_content ). * @return array|WP_Error Response array or WP_Error on failure. */ public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) { // Check API key. if ( empty( $this->api_key ) ) { return new WP_Error( 'no_api_key', __( 'OpenRouter API key is not configured.', 'wp-agentic-writer' ) ); } $web_search_enabled = $this->web_search_enabled; if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) { $web_search_enabled = (bool) $options['web_search_enabled']; } $search_depth = $options['search_depth'] ?? $this->search_depth; $search_engine = $options['search_engine'] ?? $this->search_engine; // Determine model based on type (6 models per model-preset-brief.md). $model = $this->get_model_for_type( $type, $options ); // Add :online suffix if web search is enabled (for planning or execution/chat). if ( $web_search_enabled ) { $model .= ':online'; } // Build request body. $body = array( 'model' => $model, 'messages' => $messages, 'stream' => true, // Enable streaming! 'stream_options' => array( 'include_usage' => true, ), 'usage' => array( 'include' => true, ), ); // Add optional parameters. if ( isset( $options['max_tokens'] ) ) { $body['max_tokens'] = $options['max_tokens']; } if ( isset( $options['temperature'] ) ) { $body['temperature'] = $options['temperature']; } // Add web search options if enabled. if ( $web_search_enabled ) { $body['plugins'] = array( array( 'id' => 'web', 'web_search_options' => array( 'search_context_size' => $search_depth, 'max_results' => 5, ), ), ); // Set search engine if specified. if ( 'auto' !== $search_engine ) { $body['plugins'][0]['web_search_options']['engine'] = $search_engine; } } // Accumulators for content and usage $accumulated_content = ''; $accumulated_usage = array(); $buffer = ''; // Buffer for incomplete lines // Wrapper callback to accumulate content and call user callback $accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, &$accumulated_usage, $callback ) { if ( ! $is_complete && ! empty( $chunk ) ) { $accumulated_content .= $chunk; } // Call user callback if provided if ( $callback ) { call_user_func( $callback, $chunk, $is_complete, $accumulated_content ); } }; // Use cURL for streaming support (wp_remote_post doesn't support streaming) $ch = curl_init( $this->api_endpoint ); $json_body = wp_json_encode( $body ); // Set up cURL options with write function curl_setopt_array( $ch, array( CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => false, CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) { // Append new data to buffer $buffer .= $data; // Process all complete lines while ( true ) { $newline_pos = strpos( $buffer, "\n" ); if ( false === $newline_pos ) { // No complete lines, wait for more data break; } // Extract one line $line = substr( $buffer, 0, $newline_pos ); $buffer = substr( $buffer, $newline_pos + 1 ); $line = trim( $line ); if ( empty( $line ) ) { continue; } if ( ! str_starts_with( $line, 'data: ' ) ) { continue; } $json_str = substr( $line, 6 ); if ( '[DONE]' === $json_str ) { call_user_func( $accumulating_callback, '', true ); return strlen( $data ); } $chunk = json_decode( $json_str, true ); if ( isset( $chunk['choices'][0]['delta']['content'] ) ) { $content = $chunk['choices'][0]['delta']['content']; call_user_func( $accumulating_callback, $content, false ); } // Accumulate usage data from final chunk if ( isset( $chunk['usage'] ) ) { $accumulated_usage = $chunk['usage']; } } return strlen( $data ); }, CURLOPT_HTTPHEADER => array( 'Authorization: Bearer ' . $this->api_key, 'Content-Type: application/json', 'HTTP-Referer: ' . home_url(), 'X-Title: WP Agentic Writer', ), CURLOPT_POSTFIELDS => $json_body, CURLOPT_TIMEOUT => 180, // 3 minutes timeout for slower models ) ); // Execute request $result = curl_exec( $ch ); $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); $curl_error = curl_error( $ch ); curl_close( $ch ); // Check for errors if ( $result === false && ! empty( $curl_error ) ) { return new WP_Error( 'curl_error', __( 'cURL error: ', 'wp-agentic-writer' ) . $curl_error ); } if ( $http_code >= 400 ) { return new WP_Error( 'api_error', sprintf( __( 'API error: HTTP %d', 'wp-agentic-writer' ), $http_code ) ); } // Calculate cost from usage data $input_tokens = $accumulated_usage['prompt_tokens'] ?? 0; $output_tokens = $accumulated_usage['completion_tokens'] ?? 0; $cost = $accumulated_usage['cost'] ?? 0.0; return array( 'content' => $accumulated_content, 'input_tokens' => $input_tokens, 'output_tokens' => $output_tokens, 'total_tokens' => $input_tokens + $output_tokens, 'cost' => $cost, 'model' => $model, 'web_search_results' => array(), // Streaming doesn't return web search results ); } /** * Generate image. * * @since 0.1.0 * @param string $prompt Image prompt. * @return array|WP_Error Response array with image URL or WP_Error on failure. */ public function generate_image( $prompt ) { // Check API key. if ( empty( $this->api_key ) ) { return new WP_Error( 'no_api_key', __( 'OpenRouter API key is not configured.', 'wp-agentic-writer' ) ); } $messages = array( array( 'role' => 'user', 'content' => sprintf( 'Generate an image based on this prompt: %s. Return only the image URL.', $prompt ), ), ); $response = $this->chat( $messages, array( 'model' => $this->image_model ), 'image' ); if ( is_wp_error( $response ) ) { return $response; } // Extract image URL from response. $content = $response['content']; $url = ''; // Try to extract URL from content. if ( preg_match( '/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp)/i', $content, $matches ) ) { $url = $matches[0]; } return array( 'url' => $url, 'prompt' => $prompt, 'cost' => $response['cost'], 'model' => $response['model'], ); } }