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", [ "headers" => [ "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"] ?? []; // 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 = []; foreach ($models as $model) { $output_modalities = $model["architecture"]["output_modalities"] ?? []; 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 = []) { 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, [ "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", []); if (!is_array($custom_models)) { return []; } $ids = []; 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", []); $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 [ "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 []; } // If array is empty, return empty if (empty($data)) { return []; } // 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 = []; 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 = []; 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 []; } /** * 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", [ "headers" => [ "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 []; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!isset($data["data"]) || !is_array($data["data"])) { return []; } $model_ids = []; 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 = [ "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", ); } /** * Build optional request-level OpenRouter provider routing preferences. * * This is intentionally settings-driven. BYOK users may pin a provider and * disable fallbacks, but the plugin should not assume every OpenRouter model * should use OpenAI, Anthropic, Azure, or any other provider. * * @since 0.2.3 * @param array $options Request options. * @return array Provider routing preferences. */ private function get_provider_routing_preferences($options = []) { if (isset($options["provider"]) && is_array($options["provider"])) { return $options["provider"]; } if ( array_key_exists("openrouter_provider_routing", $options) && false === (bool) $options["openrouter_provider_routing"] ) { return []; } $settings = get_option("wp_agentic_writer_settings", []); $enabled = !empty($settings["openrouter_provider_routing_enabled"]); $provider_slug = isset($settings["openrouter_provider_slug"]) ? sanitize_key($settings["openrouter_provider_slug"]) : ""; if (!$enabled || "" === $provider_slug || "auto" === $provider_slug) { return []; } $routing = [ "order" => [$provider_slug], ]; if (!empty($settings["openrouter_provider_only"])) { $routing["only"] = [$provider_slug]; } if (isset($settings["openrouter_allow_provider_fallbacks"])) { $routing["allow_fallbacks"] = (bool) $settings["openrouter_allow_provider_fallbacks"]; } return $routing; } /** * 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; } /** * Create a one-off provider for a specific API key. * * @since 0.2.0 * @param string $api_key OpenRouter API key. * @return WP_Agentic_Writer_OpenRouter_Provider */ public static function for_api_key($api_key) { return new self($api_key); } /** * Constructor. * * @since 0.1.0 * @param string|null $api_key Optional OpenRouter API key override. */ private function __construct($api_key = null) { // Get settings from the unified settings array. $settings = get_option("wp_agentic_writer_settings", []); $this->api_key = null !== $api_key ? $api_key : $settings["openrouter_api_key"] ?? ""; // Initialize model defaults from registry (set after settings to allow override). $registry_defaults = [ "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 = [], $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 = [ "model" => $model, "messages" => $messages, "usage" => [ "include" => true, ], ]; $provider_routing = $this->get_provider_routing_preferences($options); if (!empty($provider_routing)) { $body["provider"] = $provider_routing; } // 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"] = [ [ "id" => "web", "web_search_options" => [ "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, [ "headers" => [ "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 = []; if (isset($data["choices"][0]["message"]["annotations"])) { foreach ( $data["choices"][0]["message"]["annotations"] as $annotation ) { if (isset($annotation["url"])) { $web_search_results[] = [ "url" => $annotation["url"], "title" => $annotation["title"] ?? "", "description" => $annotation["description"] ?? "", ]; } } } return [ "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 = [], $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)) { // Auto-fallback: try registry fallback model instead of hard-failing $fallback_model = WPAW_Model_Registry::get_fallback_model($type); if ($fallback_model && $fallback_model !== $model) { $fallback_validation = $this->validate_model_availability( $fallback_model, ); if (true === $fallback_validation) { $model = $fallback_model; if (defined("WP_DEBUG") && WP_DEBUG) { error_log( "WPAW: Model unavailable, auto-fallback to: {$fallback_model}", ); } } else { return $model_validation; } } else { return $model_validation; } } // Build request body. $body = [ "model" => $model, "messages" => $messages, "stream" => true, // Enable streaming! "stream_options" => [ "include_usage" => true, ], "usage" => [ "include" => true, ], ]; $provider_routing = $this->get_provider_routing_preferences($options); if (!empty($provider_routing)) { $body["provider"] = $provider_routing; } // 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"] = [ [ "id" => "web", "web_search_options" => [ "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 = []; $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, [ 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 => [ "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 [ "content" => $accumulated_content, "input_tokens" => $input_tokens, "output_tokens" => $output_tokens, "total_tokens" => $input_tokens + $output_tokens, "cost" => $cost, "model" => $model, "web_search_results" => [], // 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 = []) { 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 = [ "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 = [ "model" => $model, "messages" => [ [ "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", [ "headers" => [ "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(), [ "status" => 500, "trace" => array_merge($model_trace, [ "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, [ "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, ), [ "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", [ "status" => 502, "trace" => $response_trace, ], ); } return [ "url" => $image_url, "cost" => $body["usage"]["cost"] ?? 0.03, "generation_time" => $generation_time, "model" => $model, "prompt" => $prompt, "input_tokens" => (int) ($body["usage"]["prompt_tokens"] ?? 0), "output_tokens" => (int) ($body["usage"]["completion_tokens"] ?? 0), ]; } /** * 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) ($entry->architecture ?? []); $output_modalities = isset($architecture["output_modalities"]) && is_array($architecture["output_modalities"]) ? $architecture["output_modalities"] : []; if ( in_array("image", $output_modalities, true) && in_array("text", $output_modalities, true) ) { return ["image", "text"]; } if (in_array("image", $output_modalities, true)) { return ["image"]; } } } return ["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", [ "headers" => [ "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 [ "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, ["chat", "clarity", "planning", "writing", "refinement", "image"], true, ); } }