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.
1438 lines
46 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|