Files
wp-agentic-writer/includes/class-openrouter-provider.php
Dwindi Ramadhana 690991c526 refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
2026-06-17 05:27:58 +07:00

1438 lines
46 KiB
PHP

<?php
/**
* OpenRouter API Provider
*
* Handles all communication with OpenRouter API, including chat completion
* and image generation.
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_OpenRouter_Provider
*
* @since 0.1.0
*/
class WP_Agentic_Writer_OpenRouter_Provider implements
WP_Agentic_Writer_AI_Provider_Interface
{
/**
* API key.
*
* @var string
*/
private $api_key = "";
/**
* Chat model (discussion, research, recommendations).
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $chat_model = "";
/**
* Clarity model (prompt analysis, quiz generation).
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $clarity_model = "";
/**
* Planning model (article outline generation).
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $planning_model = "";
/**
* Writing model (article draft generation).
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $writing_model = "";
/**
* Refinement model (paragraph edits, rewrites).
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $refinement_model = "";
/**
* Image model.
* Initialized from WPAW_Model_Registry in constructor.
*
* @var string
*/
private $image_model = "";
/**
* Web search enabled.
*
* @var bool
*/
private $web_search_enabled = false;
/**
* Search depth.
*
* @var string
*/
private $search_depth = "medium";
/**
* Search engine.
*
* @var string
*/
private $search_engine = "auto";
/**
* API endpoint.
*
* @var string
*/
private $api_endpoint = "https://openrouter.ai/api/v1/chat/completions";
/**
* Get cached models from OpenRouter API.
* Stores full model objects in a separate transient from the ID list.
*
* @since 0.1.0
* @return array|WP_Error Models array or WP_Error on failure.
*/
public function get_cached_models()
{
// Check if we have cached models (full objects, not IDs).
$cache_key = "wpaw_openrouter_model_objects";
$cached_models = get_transient($cache_key);
if (false !== $cached_models) {
return $cached_models;
}
// Check API key.
if (empty($this->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,
);
}
}