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 using OpenRouter image generation API. * * @since 0.1.0 * @param string $prompt Image prompt. * @param string $model Image model (optional, uses default if not provided). * @param array $options Additional options (size, quality, n). * @return array|WP_Error Response with image URL or error. */ public function generate_image( $prompt, $model = null, $options = array() ) { if ( empty( $this->api_key ) ) { return new WP_Error( 'no_api_key', 'OpenRouter API key not configured' ); } $model = $model ?? $this->image_model; $size = $options['size'] ?? '1024x576'; $quality = $options['quality'] ?? 'hd'; $n = $options['n'] ?? 1; $start_time = microtime( true ); $response = wp_remote_post( 'https://openrouter.ai/api/v1/images/generations', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url(), 'X-Title' => get_bloginfo( 'name' ), ), 'body' => wp_json_encode( array( 'model' => $model, 'prompt' => $prompt, 'n' => $n, 'size' => $size, 'quality' => $quality, ) ), 'timeout' => 60, ) ); $generation_time = microtime( true ) - $start_time; if ( is_wp_error( $response ) ) { return $response; } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['data'][0]['url'] ) ) { return new WP_Error( 'image_generation_failed', $body['error']['message'] ?? 'Unknown error' ); } return array( 'url' => $body['data'][0]['url'], 'cost' => $body['usage']['cost'] ?? 0.03, 'generation_time' => $generation_time, 'model' => $model, 'prompt' => $prompt, ); } /** * Check if provider is configured * * @return bool True if API key is set. */ public function is_configured() { return ! empty( $this->api_key ); } /** * Test connection to OpenRouter API * * @return array|WP_Error Success array or error. */ public function test_connection() { if ( ! $this->is_configured() ) { return new WP_Error( 'not_configured', 'OpenRouter API key not configured' ); } $response = wp_remote_get( 'https://openrouter.ai/api/v1/models', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, ), 'timeout' => 10, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return new WP_Error( 'connection_failed', sprintf( 'OpenRouter API returned status %d', $code ) ); } return array( 'success' => true, 'message' => 'Connected to OpenRouter successfully', ); } /** * Check if provider supports task type * * @param string $type Task type. * @return bool True (OpenRouter supports all task types). */ public function supports_task_type( $type ) { return in_array( $type, array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ), true ); } }