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?output_modalities=all', 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 ) { 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' ) . ')'; } } if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { 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 ) ) ); } error_log( 'Image generation models: ' . implode( ', ', array_slice( $image_model_ids, 0, 20 ) ) ); } // Cache for 24 hours - use separate key for objects. set_transient( $cache_key, $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 both transient keys on refresh to ensure clean slate. delete_transient( 'wpaw_openrouter_model_objects' ); delete_transient( 'wpaw_openrouter_model_ids' ); } 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; } } /** * Validate that the model is available on OpenRouter before making API calls. * Uses a cached list of available model IDs to avoid repeated API calls. * * @since 0.1.0 * @param string $model Model ID to validate. * @return true|WP_Error True if valid, WP_Error if model unavailable. */ private function validate_model_availability( $model ) { // Strip :online suffix if present $base_model = trim( str_replace( ':online', '', (string) $model ) ); if ( $this->is_custom_model_id( $base_model ) ) { // Custom models are user-managed. Skip strict pre-validation and let // OpenRouter return the authoritative runtime response. return true; } // Get cached available model IDs (separate from full model objects). $cache_key = 'wpaw_openrouter_model_ids'; $available_models = get_transient( $cache_key ); if ( false === $available_models ) { $available_models = $this->fetch_available_models(); // Cache for 6 hours set_transient( $cache_key, $available_models, 6 * HOUR_IN_SECONDS ); } // Normalize: if old transient exists with full objects instead of IDs, // extract just the IDs for safe comparison. $model_ids = $this->normalize_model_ids( $available_models ); // Check if model is in available list. If missing, force one fresh fetch // to avoid false negatives from stale cache. if ( ! in_array( $base_model, $model_ids, true ) ) { $refreshed_models = $this->fetch_available_models(); if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) { set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS ); $model_ids = $this->normalize_model_ids( $refreshed_models ); } } if ( ! in_array( $base_model, $model_ids, true ) ) { $suggestion = $this->get_model_suggestion( $base_model ); $error_msg = sprintf( /* translators: %1$s: current model, %2$s: suggestion */ __( 'Model "%1$s" is not available on OpenRouter. %2$s', 'wp-agentic-writer' ), $base_model, $suggestion ); return new WP_Error( 'model_unavailable', $error_msg, array( 'status' => 400, 'code' => 'MODEL_UNAVAILABLE', 'current_model' => $base_model, ) ); } return true; } /** * Check whether model ID exists in user-defined custom models list. * * @since 0.2.1 * @param string $model_id Model ID. * @return bool */ private function is_custom_model_id( $model_id ) { $model_id = trim( (string) $model_id ); if ( '' === $model_id ) { return false; } foreach ( $this->get_custom_model_ids() as $custom_id ) { if ( 0 === strcasecmp( $custom_id, $model_id ) ) { return true; } } return false; } /** * Get user-defined custom model IDs. * * @since 0.2.1 * @return array */ private function get_custom_model_ids() { $custom_models = get_option( 'wp_agentic_writer_custom_models', array() ); if ( ! is_array( $custom_models ) ) { return array(); } $ids = array(); foreach ( $custom_models as $custom ) { if ( ! is_array( $custom ) ) { continue; } $custom_id = isset( $custom['id'] ) ? trim( (string) $custom['id'] ) : ''; if ( '' !== $custom_id ) { $ids[] = $custom_id; } } return $ids; } /** * Build model availability trace for debugging runtime model selection. * * @since 0.2.1 * @param string $model Model ID. * @return array */ private function build_model_trace( $model ) { $model = trim( str_replace( ':online', '', (string) $model ) ); $settings = get_option( 'wp_agentic_writer_settings', array() ); $cache_key = 'wpaw_openrouter_model_ids'; $cached_models = get_transient( $cache_key ); $cache_was_loaded = false !== $cached_models; $model_ids = $this->normalize_model_ids( $cached_models ); $cache_has_model = in_array( $model, $model_ids, true ); $refreshed_has_model = null; if ( ! $cache_has_model ) { $refreshed_models = $this->fetch_available_models(); if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) { set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS ); $refreshed_ids = $this->normalize_model_ids( $refreshed_models ); $refreshed_has_model = in_array( $model, $refreshed_ids, true ); } } return array( 'selected_model' => $model, 'settings_image_model' => isset( $settings['image_model'] ) ? (string) $settings['image_model'] : '', 'image_task_provider' => isset( $settings['task_providers']['image'] ) ? (string) $settings['task_providers']['image'] : 'openrouter', 'custom_model_ids' => $this->get_custom_model_ids(), 'custom_model_match' => $this->is_custom_model_id( $model ), 'model_cache_loaded' => $cache_was_loaded, 'model_cache_has_model' => $cache_has_model, 'refreshed_has_model' => $refreshed_has_model, ); } /** * Normalize cached data to extract model IDs. * Handles backward compatibility for old transient data that may contain * full model objects instead of just IDs. * * @since 0.2.0 * @param mixed $data Cached data (may be IDs array or full objects array). * @return array Normalized array of model ID strings. */ private function normalize_model_ids( $data ) { // If it's not an array, return empty if ( ! is_array( $data ) ) { return array(); } // If array is empty, return empty if ( empty( $data ) ) { return array(); } // Check if it's an array of strings (already normalized) or objects $first_item = reset( $data ); if ( is_string( $first_item ) ) { // Already normalized - just IDs as strings return $data; } if ( is_array( $first_item ) ) { // Old transient: array of model objects with 'id' key $ids = array(); foreach ( $data as $item ) { if ( isset( $item['id'] ) && is_string( $item['id'] ) ) { $ids[] = $item['id']; } } return $ids; } if ( is_object( $first_item ) ) { // Old transient: array of model objects with 'id' property $ids = array(); foreach ( $data as $item ) { if ( isset( $item->id ) && is_string( $item->id ) ) { $ids[] = $item->id; } } return $ids; } // Unknown format - return empty to force refresh return array(); } /** * Fetch available model IDs from OpenRouter API. * Caches only the IDs in a separate transient from full model objects. * * @since 0.1.0 * @return array List of available model IDs. */ private function fetch_available_models() { $response = wp_remote_get( 'https://openrouter.ai/api/v1/models?output_modalities=all', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, ), 'timeout' => 30, ) ); if ( is_wp_error( $response ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW: Failed to fetch OpenRouter models: ' . $response->get_error_message() ); } return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) { return array(); } $model_ids = array(); foreach ( $data['data'] as $model ) { if ( isset( $model['id'] ) ) { $model_ids[] = $model['id']; } } // Also flush old transient if it exists to prevent shape conflict. delete_transient( 'wpaw_openrouter_models' ); return $model_ids; } /** * Get a model suggestion based on the requested model. * * @since 0.1.0 * @param string $model Requested model ID. * @return string Suggestion message. */ private function get_model_suggestion( $model ) { $suggestions = array( 'anthropic/claude-3.5-sonnet' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ), 'anthropic/claude-3.5-sonnet-v2' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ), 'anthropic/claude-3-opus' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ), 'anthropic/claude-3-sonnet' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ), ); if ( isset( $suggestions[ $model ] ) ) { return $suggestions[ $model ]; } return __( 'Please go to Settings → Models and select a different model that is available on OpenRouter.', 'wp-agentic-writer' ); } /** * 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'] ?? ''; // Initialize model defaults from registry (set after settings to allow override). $registry_defaults = array( 'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ), 'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ), 'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ), 'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ), 'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ), 'image_model' => WPAW_Model_Registry::get_default_model( 'image' ), ); // Get models from settings (6 models per model-preset-brief.md). $this->chat_model = $settings['chat_model'] ?? $registry_defaults['chat_model']; $this->clarity_model = $settings['clarity_model'] ?? $registry_defaults['clarity_model']; $this->planning_model = $settings['planning_model'] ?? $registry_defaults['planning_model']; $this->writing_model = $settings['writing_model'] ?? $registry_defaults['writing_model']; $this->refinement_model = $settings['refinement_model'] ?? $registry_defaults['refinement_model']; $this->image_model = $settings['image_model'] ?? $registry_defaults['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'; } // Validate model availability before making API call $model_validation = $this->validate_model_availability( $model ); if ( is_wp_error( $model_validation ) ) { return $model_validation; } // 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 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW OpenRouter request: model=' . $model . ', messages_count=' . count( $messages ) . ', first_msg_role=' . (isset( $messages[0]['role'] ) ? $messages[0]['role'] : 'N/A') ); } // 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 ( 0 !== strpos( $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 ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW OpenRouter response: HTTP=' . $http_code . ', curl_error=' . $curl_error . ', result_type=' . gettype( $result ) . ', buffer_len=' . strlen( $buffer ) . ', accumulated_content_len=' . strlen( $accumulated_content ) ); } // 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 ) { // Try to extract error message from buffer $error_msg = 'API error'; $buffer_content = trim( $buffer ); if ( ! empty( $buffer_content ) ) { $error_data = json_decode( $buffer_content, true ); if ( isset( $error_data['error']['message'] ) ) { $error_msg = $error_data['error']['message']; } elseif ( isset( $error_data['message'] ) ) { $error_msg = $error_data['message']; } else { $error_msg = $buffer_content; } } if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW OpenRouter API error: HTTP=' . $http_code . ', Buffer: ' . substr( $buffer_content, 0, 500 ) . ', Error: ' . $error_msg ); } return new WP_Error( 'api_error', sprintf( __( 'API error: HTTP %d - %s', 'wp-agentic-writer' ), $http_code, $error_msg ) ); } // Log if content is unexpectedly empty if ( empty( $accumulated_content ) && ! empty( $buffer ) ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW OpenRouter: Empty content but buffer has data: ' . substr( trim( $buffer ), 0, 500 ) ); } } // 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; $model_trace = $this->build_model_trace( $model ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'WPAW image generation model trace: ' . wp_json_encode( $model_trace ) ); } $start_time = microtime( true ); $image_config = array( 'image_size' => '1K', ); if ( false !== strpos( (string) $size, 'x' ) ) { $parts = array_map( 'intval', explode( 'x', (string) $size ) ); if ( 2 === count( $parts ) && $parts[0] > 0 && $parts[1] > 0 ) { $ratio = $parts[0] / $parts[1]; if ( $ratio > 1.6 && $ratio < 1.9 ) { $image_config['aspect_ratio'] = '16:9'; } } } $request_body = array( 'model' => $model, 'messages' => array( array( 'role' => 'user', 'content' => $prompt, ), ), 'modalities' => $this->get_image_generation_modalities( $model ), 'image_config' => $image_config, 'stream' => false, ); $response = wp_remote_post( 'https://openrouter.ai/api/v1/chat/completions', 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( $request_body ), 'timeout' => 60, ) ); $generation_time = microtime( true ) - $start_time; if ( is_wp_error( $response ) ) { return new WP_Error( $response->get_error_code(), $response->get_error_message(), array( 'status' => 500, 'trace' => array_merge( $model_trace, array( 'endpoint' => 'https://openrouter.ai/api/v1/chat/completions', 'request_model' => $model, 'request_size' => $size, 'request_quality' => $quality, 'request_n' => $n, 'request_prompt_len' => strlen( (string) $prompt ), 'transport_error' => $response->get_error_message(), ) ), ) ); } $raw_body = wp_remote_retrieve_body( $response ); $body = json_decode( $raw_body, true ); $http_code = wp_remote_retrieve_response_code( $response ); $response_trace = array_merge( $model_trace, array( 'endpoint' => 'https://openrouter.ai/api/v1/chat/completions', 'request_model' => $model, 'request_size' => $size, 'request_quality' => $quality, 'request_n' => $n, 'request_modalities' => $request_body['modalities'], 'request_image_config' => $request_body['image_config'], 'request_prompt_len' => strlen( (string) $prompt ), 'openrouter_http' => $http_code, 'openrouter_response' => is_array( $body ) ? $body : substr( (string) $raw_body, 0, 2000 ), ) ); // Check for API errors if ( $http_code >= 400 ) { $error_msg = $body['error']['message'] ?? 'Image generation failed'; return new WP_Error( 'image_api_error', sprintf( __( 'Image generation failed (HTTP %d): %s', 'wp-agentic-writer' ), $http_code, $error_msg ), array( 'status' => $http_code, 'trace' => $response_trace, ) ); } $image_url = $body['choices'][0]['message']['images'][0]['image_url']['url'] ?? $body['choices'][0]['message']['images'][0]['imageUrl']['url'] ?? ''; if ( '' === $image_url ) { return new WP_Error( 'image_generation_failed', $body['error']['message'] ?? 'Unknown error - no image URL returned', array( 'status' => 502, 'trace' => $response_trace, ) ); } return array( 'url' => $image_url, 'cost' => $body['usage']['cost'] ?? 0.03, 'generation_time' => $generation_time, 'model' => $model, 'prompt' => $prompt, ); } /** * Determine OpenRouter modalities for an image generation model. * * @since 0.2.1 * @param string $model Model ID. * @return array */ private function get_image_generation_modalities( $model ) { $model = trim( str_replace( ':online', '', (string) $model ) ); $models = $this->get_cached_models(); if ( ! is_wp_error( $models ) && is_array( $models ) ) { foreach ( $models as $entry ) { $id = is_array( $entry ) ? ( $entry['id'] ?? '' ) : ( $entry->id ?? '' ); if ( 0 !== strcasecmp( (string) $id, $model ) ) { continue; } $architecture = is_array( $entry ) ? ( $entry['architecture'] ?? array() ) : (array) ( $entry->architecture ?? array() ); $output_modalities = isset( $architecture['output_modalities'] ) && is_array( $architecture['output_modalities'] ) ? $architecture['output_modalities'] : array(); if ( in_array( 'image', $output_modalities, true ) && in_array( 'text', $output_modalities, true ) ) { return array( 'image', 'text' ); } if ( in_array( 'image', $output_modalities, true ) ) { return array( 'image' ); } } } return array( 'image' ); } /** * 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?output_modalities=all', 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 ); } }