1171 lines
34 KiB
PHP
1171 lines
34 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',
|
|
array(
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $this->api_key,
|
|
),
|
|
'timeout' => 30,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
$body = wp_remote_retrieve_body( $response );
|
|
$data = json_decode( $body, true );
|
|
|
|
if ( isset( $data['error'] ) ) {
|
|
return new WP_Error(
|
|
'api_error',
|
|
$data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
$models = $data['data'] ?? array();
|
|
|
|
// Debug: Log model count and categorize by output_modalities
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'OpenRouter API total models: ' . count( $models ) );
|
|
}
|
|
|
|
// Count models by output modality
|
|
$text_count = 0;
|
|
$image_count = 0;
|
|
$image_model_ids = array();
|
|
|
|
foreach ( $models as $model ) {
|
|
$output_modalities = $model['architecture']['output_modalities'] ?? array();
|
|
if ( in_array( 'text', $output_modalities, true ) ) {
|
|
$text_count++;
|
|
}
|
|
if ( in_array( 'image', $output_modalities, true ) ) {
|
|
$image_count++;
|
|
$image_model_ids[] = $model['id'] . ' (' . ( $model['name'] ?? 'N/A' ) . ')';
|
|
}
|
|
}
|
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( "OpenRouter models by output_modalities: TEXT={$text_count}, IMAGE={$image_count}" );
|
|
error_log( 'Image generation models: ' . implode( ', ', array_slice( $image_model_ids, 0, 20 ) ) );
|
|
}
|
|
error_log( 'Image generation models: ' . implode( ', ', array_slice( $image_model_ids, 0, 20 ) ) );
|
|
}
|
|
|
|
// Cache for 24 hours - use separate key for objects.
|
|
set_transient( $cache_key, $models, DAY_IN_SECONDS );
|
|
|
|
return $models;
|
|
}
|
|
|
|
/**
|
|
* Fetch models and refresh cache when requested.
|
|
*
|
|
* @since 0.1.0
|
|
* @param bool $force_refresh Whether to refresh cache.
|
|
* @return array|WP_Error Models array or WP_Error on failure.
|
|
*/
|
|
public function fetch_and_cache_models( $force_refresh = false ) {
|
|
if ( $force_refresh ) {
|
|
// Delete both transient keys on refresh to ensure clean slate.
|
|
delete_transient( 'wpaw_openrouter_model_objects' );
|
|
delete_transient( 'wpaw_openrouter_model_ids' );
|
|
}
|
|
|
|
return $this->get_cached_models();
|
|
}
|
|
|
|
/**
|
|
* Get writing model name (legacy: execution model).
|
|
*
|
|
* @since 0.1.0
|
|
* @return string
|
|
*/
|
|
public function get_execution_model() {
|
|
return $this->writing_model;
|
|
}
|
|
|
|
/**
|
|
* Get model for a specific task type.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $type Task type (chat, clarity, planning, writing, execution, refinement).
|
|
* @param array $options Options array that may contain 'model' override.
|
|
* @return string Model ID.
|
|
*/
|
|
private function get_model_for_type( $type, $options = array() ) {
|
|
if ( isset( $options['model'] ) ) {
|
|
return $options['model'];
|
|
}
|
|
|
|
switch ( $type ) {
|
|
case 'chat':
|
|
return $this->chat_model;
|
|
case 'clarity':
|
|
return $this->clarity_model;
|
|
case 'writing':
|
|
case 'execution':
|
|
return $this->writing_model;
|
|
case 'refinement':
|
|
return $this->refinement_model;
|
|
case 'planning':
|
|
default:
|
|
return $this->planning_model;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate that the model is available on OpenRouter before making API calls.
|
|
* Uses a cached list of available model IDs to avoid repeated API calls.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $model Model ID to validate.
|
|
* @return true|WP_Error True if valid, WP_Error if model unavailable.
|
|
*/
|
|
private function validate_model_availability( $model ) {
|
|
// Strip :online suffix if present
|
|
$base_model = trim( str_replace( ':online', '', (string) $model ) );
|
|
if ( $this->is_custom_model_id( $base_model ) ) {
|
|
// Custom models are user-managed. Skip strict pre-validation and let
|
|
// OpenRouter return the authoritative runtime response.
|
|
return true;
|
|
}
|
|
|
|
// Get cached available model IDs (separate from full model objects).
|
|
$cache_key = 'wpaw_openrouter_model_ids';
|
|
$available_models = get_transient( $cache_key );
|
|
|
|
if ( false === $available_models ) {
|
|
$available_models = $this->fetch_available_models();
|
|
// Cache for 6 hours
|
|
set_transient( $cache_key, $available_models, 6 * HOUR_IN_SECONDS );
|
|
}
|
|
|
|
// Normalize: if old transient exists with full objects instead of IDs,
|
|
// extract just the IDs for safe comparison.
|
|
$model_ids = $this->normalize_model_ids( $available_models );
|
|
|
|
// Check if model is in available list. If missing, force one fresh fetch
|
|
// to avoid false negatives from stale cache.
|
|
if ( ! in_array( $base_model, $model_ids, true ) ) {
|
|
$refreshed_models = $this->fetch_available_models();
|
|
if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) {
|
|
set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS );
|
|
$model_ids = $this->normalize_model_ids( $refreshed_models );
|
|
}
|
|
}
|
|
|
|
if ( ! in_array( $base_model, $model_ids, true ) ) {
|
|
$suggestion = $this->get_model_suggestion( $base_model );
|
|
$error_msg = sprintf(
|
|
/* translators: %1$s: current model, %2$s: suggestion */
|
|
__( 'Model "%1$s" is not available on OpenRouter. %2$s', 'wp-agentic-writer' ),
|
|
$base_model,
|
|
$suggestion
|
|
);
|
|
return new WP_Error(
|
|
'model_unavailable',
|
|
$error_msg,
|
|
array(
|
|
'status' => 400,
|
|
'code' => 'MODEL_UNAVAILABLE',
|
|
'current_model' => $base_model,
|
|
)
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check whether model ID exists in user-defined custom models list.
|
|
*
|
|
* @since 0.2.1
|
|
* @param string $model_id Model ID.
|
|
* @return bool
|
|
*/
|
|
private function is_custom_model_id( $model_id ) {
|
|
$model_id = trim( (string) $model_id );
|
|
if ( '' === $model_id ) {
|
|
return false;
|
|
}
|
|
|
|
foreach ( $this->get_custom_model_ids() as $custom_id ) {
|
|
if ( 0 === strcasecmp( $custom_id, $model_id ) ) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get user-defined custom model IDs.
|
|
*
|
|
* @since 0.2.1
|
|
* @return array
|
|
*/
|
|
private function get_custom_model_ids() {
|
|
$custom_models = get_option( 'wp_agentic_writer_custom_models', array() );
|
|
if ( ! is_array( $custom_models ) ) {
|
|
return array();
|
|
}
|
|
|
|
$ids = array();
|
|
foreach ( $custom_models as $custom ) {
|
|
if ( ! is_array( $custom ) ) {
|
|
continue;
|
|
}
|
|
$custom_id = isset( $custom['id'] ) ? trim( (string) $custom['id'] ) : '';
|
|
if ( '' !== $custom_id ) {
|
|
$ids[] = $custom_id;
|
|
}
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
/**
|
|
* Build model availability trace for debugging runtime model selection.
|
|
*
|
|
* @since 0.2.1
|
|
* @param string $model Model ID.
|
|
* @return array
|
|
*/
|
|
private function build_model_trace( $model ) {
|
|
$model = trim( str_replace( ':online', '', (string) $model ) );
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
|
|
$cache_key = 'wpaw_openrouter_model_ids';
|
|
$cached_models = get_transient( $cache_key );
|
|
$cache_was_loaded = false !== $cached_models;
|
|
$model_ids = $this->normalize_model_ids( $cached_models );
|
|
$cache_has_model = in_array( $model, $model_ids, true );
|
|
|
|
$refreshed_has_model = null;
|
|
if ( ! $cache_has_model ) {
|
|
$refreshed_models = $this->fetch_available_models();
|
|
if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) {
|
|
set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS );
|
|
$refreshed_ids = $this->normalize_model_ids( $refreshed_models );
|
|
$refreshed_has_model = in_array( $model, $refreshed_ids, true );
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'selected_model' => $model,
|
|
'settings_image_model' => isset( $settings['image_model'] ) ? (string) $settings['image_model'] : '',
|
|
'image_task_provider' => isset( $settings['task_providers']['image'] ) ? (string) $settings['task_providers']['image'] : 'openrouter',
|
|
'custom_model_ids' => $this->get_custom_model_ids(),
|
|
'custom_model_match' => $this->is_custom_model_id( $model ),
|
|
'model_cache_loaded' => $cache_was_loaded,
|
|
'model_cache_has_model' => $cache_has_model,
|
|
'refreshed_has_model' => $refreshed_has_model,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize cached data to extract model IDs.
|
|
* Handles backward compatibility for old transient data that may contain
|
|
* full model objects instead of just IDs.
|
|
*
|
|
* @since 0.2.0
|
|
* @param mixed $data Cached data (may be IDs array or full objects array).
|
|
* @return array Normalized array of model ID strings.
|
|
*/
|
|
private function normalize_model_ids( $data ) {
|
|
// If it's not an array, return empty
|
|
if ( ! is_array( $data ) ) {
|
|
return array();
|
|
}
|
|
|
|
// If array is empty, return empty
|
|
if ( empty( $data ) ) {
|
|
return array();
|
|
}
|
|
|
|
// Check if it's an array of strings (already normalized) or objects
|
|
$first_item = reset( $data );
|
|
|
|
if ( is_string( $first_item ) ) {
|
|
// Already normalized - just IDs as strings
|
|
return $data;
|
|
}
|
|
|
|
if ( is_array( $first_item ) ) {
|
|
// Old transient: array of model objects with 'id' key
|
|
$ids = array();
|
|
foreach ( $data as $item ) {
|
|
if ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
|
|
$ids[] = $item['id'];
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
if ( is_object( $first_item ) ) {
|
|
// Old transient: array of model objects with 'id' property
|
|
$ids = array();
|
|
foreach ( $data as $item ) {
|
|
if ( isset( $item->id ) && is_string( $item->id ) ) {
|
|
$ids[] = $item->id;
|
|
}
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
// Unknown format - return empty to force refresh
|
|
return array();
|
|
}
|
|
|
|
/**
|
|
* Fetch available model IDs from OpenRouter API.
|
|
* Caches only the IDs in a separate transient from full model objects.
|
|
*
|
|
* @since 0.1.0
|
|
* @return array List of available model IDs.
|
|
*/
|
|
private function fetch_available_models() {
|
|
$response = wp_remote_get(
|
|
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
|
array(
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $this->api_key,
|
|
),
|
|
'timeout' => 30,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW: Failed to fetch OpenRouter models: ' . $response->get_error_message() );
|
|
}
|
|
return array();
|
|
}
|
|
|
|
$body = wp_remote_retrieve_body( $response );
|
|
$data = json_decode( $body, true );
|
|
|
|
if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) {
|
|
return array();
|
|
}
|
|
|
|
$model_ids = array();
|
|
foreach ( $data['data'] as $model ) {
|
|
if ( isset( $model['id'] ) ) {
|
|
$model_ids[] = $model['id'];
|
|
}
|
|
}
|
|
|
|
// Also flush old transient if it exists to prevent shape conflict.
|
|
delete_transient( 'wpaw_openrouter_models' );
|
|
|
|
return $model_ids;
|
|
}
|
|
|
|
/**
|
|
* Get a model suggestion based on the requested model.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $model Requested model ID.
|
|
* @return string Suggestion message.
|
|
*/
|
|
private function get_model_suggestion( $model ) {
|
|
$suggestions = array(
|
|
'anthropic/claude-3.5-sonnet' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
|
'anthropic/claude-3.5-sonnet-v2' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
|
'anthropic/claude-3-opus' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
|
'anthropic/claude-3-sonnet' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
|
);
|
|
|
|
if ( isset( $suggestions[ $model ] ) ) {
|
|
return $suggestions[ $model ];
|
|
}
|
|
|
|
return __( 'Please go to Settings → Models and select a different model that is available on OpenRouter.', 'wp-agentic-writer' );
|
|
}
|
|
|
|
/**
|
|
* Get singleton instance.
|
|
*
|
|
* @since 0.1.0
|
|
* @return WP_Agentic_Writer_OpenRouter_Provider
|
|
*/
|
|
public static function get_instance() {
|
|
static $instance = null;
|
|
|
|
if ( null === $instance ) {
|
|
$instance = new self();
|
|
}
|
|
|
|
return $instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
private function __construct() {
|
|
// Get settings from the unified settings array.
|
|
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
|
$this->api_key = $settings['openrouter_api_key'] ?? '';
|
|
|
|
// Initialize model defaults from registry (set after settings to allow override).
|
|
$registry_defaults = array(
|
|
'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ),
|
|
'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ),
|
|
'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ),
|
|
'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ),
|
|
'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ),
|
|
'image_model' => WPAW_Model_Registry::get_default_model( 'image' ),
|
|
);
|
|
|
|
// Get models from settings (6 models per model-preset-brief.md).
|
|
$this->chat_model = $settings['chat_model'] ?? $registry_defaults['chat_model'];
|
|
$this->clarity_model = $settings['clarity_model'] ?? $registry_defaults['clarity_model'];
|
|
$this->planning_model = $settings['planning_model'] ?? $registry_defaults['planning_model'];
|
|
$this->writing_model = $settings['writing_model'] ?? $registry_defaults['writing_model'];
|
|
$this->refinement_model = $settings['refinement_model'] ?? $registry_defaults['refinement_model'];
|
|
$this->image_model = $settings['image_model'] ?? $registry_defaults['image_model'];
|
|
|
|
// Get web search settings.
|
|
$this->web_search_enabled = isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'];
|
|
$this->search_depth = $settings['search_depth'] ?? 'medium';
|
|
$this->search_engine = $settings['search_engine'] ?? 'auto';
|
|
}
|
|
|
|
/**
|
|
* Chat completion (non-streaming).
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $messages Chat messages.
|
|
* @param array $options Additional options (model, max_tokens, etc.).
|
|
* @param string $type Request type (planning or execution).
|
|
* @return array|WP_Error Response array or WP_Error on failure.
|
|
*/
|
|
public function chat( $messages, $options = array(), $type = 'planning' ) {
|
|
// Check API key.
|
|
if ( empty( $this->api_key ) ) {
|
|
return new WP_Error(
|
|
'no_api_key',
|
|
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
$web_search_enabled = $this->web_search_enabled;
|
|
if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) {
|
|
$web_search_enabled = (bool) $options['web_search_enabled'];
|
|
}
|
|
$search_depth = $options['search_depth'] ?? $this->search_depth;
|
|
$search_engine = $options['search_engine'] ?? $this->search_engine;
|
|
|
|
// Determine model based on type (6 models per model-preset-brief.md).
|
|
$model = $this->get_model_for_type( $type, $options );
|
|
|
|
// Add :online suffix if web search is enabled.
|
|
if ( $web_search_enabled && 'planning' === $type ) {
|
|
$model .= ':online';
|
|
}
|
|
|
|
// Build request body.
|
|
$body = array(
|
|
'model' => $model,
|
|
'messages' => $messages,
|
|
'usage' => array(
|
|
'include' => true,
|
|
),
|
|
);
|
|
|
|
// Add optional parameters.
|
|
if ( isset( $options['max_tokens'] ) ) {
|
|
$body['max_tokens'] = $options['max_tokens'];
|
|
}
|
|
|
|
if ( isset( $options['temperature'] ) ) {
|
|
$body['temperature'] = $options['temperature'];
|
|
}
|
|
|
|
// Add web search options if enabled.
|
|
if ( $web_search_enabled && 'planning' === $type ) {
|
|
$body['plugins'] = array(
|
|
array(
|
|
'id' => 'web',
|
|
'web_search_options' => array(
|
|
'search_context_size' => $search_depth,
|
|
'max_results' => 5,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Set search engine if specified.
|
|
if ( 'auto' !== $search_engine ) {
|
|
$body['plugins'][0]['web_search_options']['engine'] = $search_engine;
|
|
}
|
|
}
|
|
|
|
// Send request.
|
|
$response = wp_remote_post(
|
|
$this->api_endpoint,
|
|
array(
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $this->api_key,
|
|
'Content-Type' => 'application/json',
|
|
'HTTP-Referer' => home_url(),
|
|
'X-Title' => 'WP Agentic Writer',
|
|
),
|
|
'body' => wp_json_encode( $body ),
|
|
'timeout' => 120, // 2 minutes timeout.
|
|
)
|
|
);
|
|
|
|
// Check for errors.
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
// Get response body.
|
|
$body = wp_remote_retrieve_body( $response );
|
|
$data = json_decode( $body, true );
|
|
|
|
// Check for API errors.
|
|
if ( isset( $data['error'] ) ) {
|
|
return new WP_Error(
|
|
'api_error',
|
|
$data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
// Extract response data.
|
|
$content = $data['choices'][0]['message']['content'] ?? '';
|
|
$input_tokens = $data['usage']['prompt_tokens'] ?? 0;
|
|
$output_tokens = $data['usage']['completion_tokens'] ?? 0;
|
|
$cost = $data['usage']['cost'] ?? 0.0;
|
|
|
|
// Extract web search results if available.
|
|
$web_search_results = array();
|
|
if ( isset( $data['choices'][0]['message']['annotations'] ) ) {
|
|
foreach ( $data['choices'][0]['message']['annotations'] as $annotation ) {
|
|
if ( isset( $annotation['url'] ) ) {
|
|
$web_search_results[] = array(
|
|
'url' => $annotation['url'],
|
|
'title' => $annotation['title'] ?? '',
|
|
'description' => $annotation['description'] ?? '',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return array(
|
|
'content' => $content,
|
|
'input_tokens' => $input_tokens,
|
|
'output_tokens' => $output_tokens,
|
|
'total_tokens' => $input_tokens + $output_tokens,
|
|
'cost' => $cost,
|
|
'model' => $model,
|
|
'web_search_results' => $web_search_results,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stream chat completion with callback for each chunk.
|
|
*
|
|
* This method streams the AI response token by token, calling the callback
|
|
* function with each accumulated chunk. This provides real-time feedback
|
|
* to the user instead of waiting for the complete response.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $messages Chat messages.
|
|
* @param array $options Additional options (model, max_tokens, etc.).
|
|
* @param string $type Request type (planning or execution).
|
|
* @param callable $callback Callback function( $chunk, $is_complete, $full_content ).
|
|
* @return array|WP_Error Response array or WP_Error on failure.
|
|
*/
|
|
public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
|
|
// Check API key.
|
|
if ( empty( $this->api_key ) ) {
|
|
return new WP_Error(
|
|
'no_api_key',
|
|
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
$web_search_enabled = $this->web_search_enabled;
|
|
if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) {
|
|
$web_search_enabled = (bool) $options['web_search_enabled'];
|
|
}
|
|
$search_depth = $options['search_depth'] ?? $this->search_depth;
|
|
$search_engine = $options['search_engine'] ?? $this->search_engine;
|
|
|
|
// Determine model based on type (6 models per model-preset-brief.md).
|
|
$model = $this->get_model_for_type( $type, $options );
|
|
|
|
// Add :online suffix if web search is enabled (for planning or execution/chat).
|
|
if ( $web_search_enabled ) {
|
|
$model .= ':online';
|
|
}
|
|
|
|
// Validate model availability before making API call
|
|
$model_validation = $this->validate_model_availability( $model );
|
|
if ( is_wp_error( $model_validation ) ) {
|
|
return $model_validation;
|
|
}
|
|
|
|
// Build request body.
|
|
$body = array(
|
|
'model' => $model,
|
|
'messages' => $messages,
|
|
'stream' => true, // Enable streaming!
|
|
'stream_options' => array(
|
|
'include_usage' => true,
|
|
),
|
|
'usage' => array(
|
|
'include' => true,
|
|
),
|
|
);
|
|
|
|
// Add optional parameters.
|
|
if ( isset( $options['max_tokens'] ) ) {
|
|
$body['max_tokens'] = $options['max_tokens'];
|
|
}
|
|
|
|
if ( isset( $options['temperature'] ) ) {
|
|
$body['temperature'] = $options['temperature'];
|
|
}
|
|
|
|
// Add web search options if enabled.
|
|
if ( $web_search_enabled ) {
|
|
$body['plugins'] = array(
|
|
array(
|
|
'id' => 'web',
|
|
'web_search_options' => array(
|
|
'search_context_size' => $search_depth,
|
|
'max_results' => 5,
|
|
),
|
|
),
|
|
);
|
|
|
|
// Set search engine if specified.
|
|
if ( 'auto' !== $search_engine ) {
|
|
$body['plugins'][0]['web_search_options']['engine'] = $search_engine;
|
|
}
|
|
}
|
|
|
|
// Accumulators for content and usage
|
|
$accumulated_content = '';
|
|
$accumulated_usage = array();
|
|
$buffer = ''; // Buffer for incomplete lines
|
|
|
|
// Wrapper callback to accumulate content and call user callback
|
|
$accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, &$accumulated_usage, $callback ) {
|
|
if ( ! $is_complete && ! empty( $chunk ) ) {
|
|
$accumulated_content .= $chunk;
|
|
}
|
|
|
|
// Call user callback if provided
|
|
if ( $callback ) {
|
|
call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
|
|
}
|
|
};
|
|
|
|
// Use cURL for streaming support (wp_remote_post doesn't support streaming)
|
|
$ch = curl_init( $this->api_endpoint );
|
|
|
|
$json_body = wp_json_encode( $body );
|
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW OpenRouter request: model=' . $model . ', messages_count=' . count( $messages ) . ', first_msg_role=' . (isset( $messages[0]['role'] ) ? $messages[0]['role'] : 'N/A') );
|
|
}
|
|
|
|
// Set up cURL options with write function
|
|
curl_setopt_array( $ch, array(
|
|
CURLOPT_POST => true,
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
|
|
// Append new data to buffer
|
|
$buffer .= $data;
|
|
|
|
// Process all complete lines
|
|
while ( true ) {
|
|
$newline_pos = strpos( $buffer, "\n" );
|
|
if ( false === $newline_pos ) {
|
|
// No complete lines, wait for more data
|
|
break;
|
|
}
|
|
|
|
// Extract one line
|
|
$line = substr( $buffer, 0, $newline_pos );
|
|
$buffer = substr( $buffer, $newline_pos + 1 );
|
|
|
|
$line = trim( $line );
|
|
if ( empty( $line ) ) {
|
|
continue;
|
|
}
|
|
|
|
if ( 0 !== strpos( $line, 'data: ' ) ) {
|
|
continue;
|
|
}
|
|
|
|
$json_str = substr( $line, 6 );
|
|
|
|
if ( '[DONE]' === $json_str ) {
|
|
call_user_func( $accumulating_callback, '', true );
|
|
return strlen( $data );
|
|
}
|
|
|
|
$chunk = json_decode( $json_str, true );
|
|
if ( isset( $chunk['choices'][0]['delta']['content'] ) ) {
|
|
$content = $chunk['choices'][0]['delta']['content'];
|
|
call_user_func( $accumulating_callback, $content, false );
|
|
}
|
|
|
|
// Accumulate usage data from final chunk
|
|
if ( isset( $chunk['usage'] ) ) {
|
|
$accumulated_usage = $chunk['usage'];
|
|
}
|
|
}
|
|
|
|
return strlen( $data );
|
|
},
|
|
CURLOPT_HTTPHEADER => array(
|
|
'Authorization: Bearer ' . $this->api_key,
|
|
'Content-Type: application/json',
|
|
'HTTP-Referer: ' . home_url(),
|
|
'X-Title: WP Agentic Writer',
|
|
),
|
|
CURLOPT_POSTFIELDS => $json_body,
|
|
CURLOPT_TIMEOUT => 180, // 3 minutes timeout for slower models
|
|
) );
|
|
|
|
// Execute request
|
|
$result = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
$curl_error = curl_error( $ch );
|
|
curl_close( $ch );
|
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW OpenRouter response: HTTP=' . $http_code . ', curl_error=' . $curl_error . ', result_type=' . gettype( $result ) . ', buffer_len=' . strlen( $buffer ) . ', accumulated_content_len=' . strlen( $accumulated_content ) );
|
|
}
|
|
|
|
// Check for errors
|
|
if ( $result === false && ! empty( $curl_error ) ) {
|
|
return new WP_Error(
|
|
'curl_error',
|
|
__( 'cURL error: ', 'wp-agentic-writer' ) . $curl_error
|
|
);
|
|
}
|
|
|
|
if ( $http_code >= 400 ) {
|
|
// Try to extract error message from buffer
|
|
$error_msg = 'API error';
|
|
$buffer_content = trim( $buffer );
|
|
if ( ! empty( $buffer_content ) ) {
|
|
$error_data = json_decode( $buffer_content, true );
|
|
if ( isset( $error_data['error']['message'] ) ) {
|
|
$error_msg = $error_data['error']['message'];
|
|
} elseif ( isset( $error_data['message'] ) ) {
|
|
$error_msg = $error_data['message'];
|
|
} else {
|
|
$error_msg = $buffer_content;
|
|
}
|
|
}
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW OpenRouter API error: HTTP=' . $http_code . ', Buffer: ' . substr( $buffer_content, 0, 500 ) . ', Error: ' . $error_msg );
|
|
}
|
|
return new WP_Error(
|
|
'api_error',
|
|
sprintf( __( 'API error: HTTP %d - %s', 'wp-agentic-writer' ), $http_code, $error_msg )
|
|
);
|
|
}
|
|
|
|
// Log if content is unexpectedly empty
|
|
if ( empty( $accumulated_content ) && ! empty( $buffer ) ) {
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW OpenRouter: Empty content but buffer has data: ' . substr( trim( $buffer ), 0, 500 ) );
|
|
}
|
|
}
|
|
|
|
// Calculate cost from usage data
|
|
$input_tokens = $accumulated_usage['prompt_tokens'] ?? 0;
|
|
$output_tokens = $accumulated_usage['completion_tokens'] ?? 0;
|
|
$cost = $accumulated_usage['cost'] ?? 0.0;
|
|
|
|
return array(
|
|
'content' => $accumulated_content,
|
|
'input_tokens' => $input_tokens,
|
|
'output_tokens' => $output_tokens,
|
|
'total_tokens' => $input_tokens + $output_tokens,
|
|
'cost' => $cost,
|
|
'model' => $model,
|
|
'web_search_results' => array(), // Streaming doesn't return web search results
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate image using OpenRouter image generation API.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $prompt Image prompt.
|
|
* @param string $model Image model (optional, uses default if not provided).
|
|
* @param array $options Additional options (size, quality, n).
|
|
* @return array|WP_Error Response with image URL or error.
|
|
*/
|
|
public function generate_image( $prompt, $model = null, $options = array() ) {
|
|
if ( empty( $this->api_key ) ) {
|
|
return new WP_Error( 'no_api_key', 'OpenRouter API key not configured' );
|
|
}
|
|
|
|
$model = $model ?? $this->image_model;
|
|
$size = $options['size'] ?? '1024x576';
|
|
$quality = $options['quality'] ?? 'hd';
|
|
$n = $options['n'] ?? 1;
|
|
$model_trace = $this->build_model_trace( $model );
|
|
|
|
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
|
error_log( 'WPAW image generation model trace: ' . wp_json_encode( $model_trace ) );
|
|
}
|
|
|
|
$start_time = microtime( true );
|
|
$image_config = array(
|
|
'image_size' => '1K',
|
|
);
|
|
if ( false !== strpos( (string) $size, 'x' ) ) {
|
|
$parts = array_map( 'intval', explode( 'x', (string) $size ) );
|
|
if ( 2 === count( $parts ) && $parts[0] > 0 && $parts[1] > 0 ) {
|
|
$ratio = $parts[0] / $parts[1];
|
|
if ( $ratio > 1.6 && $ratio < 1.9 ) {
|
|
$image_config['aspect_ratio'] = '16:9';
|
|
}
|
|
}
|
|
}
|
|
|
|
$request_body = array(
|
|
'model' => $model,
|
|
'messages' => array(
|
|
array(
|
|
'role' => 'user',
|
|
'content' => $prompt,
|
|
),
|
|
),
|
|
'modalities' => $this->get_image_generation_modalities( $model ),
|
|
'image_config' => $image_config,
|
|
'stream' => false,
|
|
);
|
|
|
|
$response = wp_remote_post(
|
|
'https://openrouter.ai/api/v1/chat/completions',
|
|
array(
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $this->api_key,
|
|
'Content-Type' => 'application/json',
|
|
'HTTP-Referer' => home_url(),
|
|
'X-Title' => get_bloginfo( 'name' ),
|
|
),
|
|
'body' => wp_json_encode( $request_body ),
|
|
'timeout' => 60,
|
|
)
|
|
);
|
|
|
|
$generation_time = microtime( true ) - $start_time;
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return new WP_Error(
|
|
$response->get_error_code(),
|
|
$response->get_error_message(),
|
|
array(
|
|
'status' => 500,
|
|
'trace' => array_merge(
|
|
$model_trace,
|
|
array(
|
|
'endpoint' => 'https://openrouter.ai/api/v1/chat/completions',
|
|
'request_model' => $model,
|
|
'request_size' => $size,
|
|
'request_quality' => $quality,
|
|
'request_n' => $n,
|
|
'request_prompt_len' => strlen( (string) $prompt ),
|
|
'transport_error' => $response->get_error_message(),
|
|
)
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
$raw_body = wp_remote_retrieve_body( $response );
|
|
$body = json_decode( $raw_body, true );
|
|
$http_code = wp_remote_retrieve_response_code( $response );
|
|
$response_trace = array_merge(
|
|
$model_trace,
|
|
array(
|
|
'endpoint' => 'https://openrouter.ai/api/v1/chat/completions',
|
|
'request_model' => $model,
|
|
'request_size' => $size,
|
|
'request_quality' => $quality,
|
|
'request_n' => $n,
|
|
'request_modalities' => $request_body['modalities'],
|
|
'request_image_config' => $request_body['image_config'],
|
|
'request_prompt_len' => strlen( (string) $prompt ),
|
|
'openrouter_http' => $http_code,
|
|
'openrouter_response' => is_array( $body ) ? $body : substr( (string) $raw_body, 0, 2000 ),
|
|
)
|
|
);
|
|
|
|
// Check for API errors
|
|
if ( $http_code >= 400 ) {
|
|
$error_msg = $body['error']['message'] ?? 'Image generation failed';
|
|
return new WP_Error(
|
|
'image_api_error',
|
|
sprintf( __( 'Image generation failed (HTTP %d): %s', 'wp-agentic-writer' ), $http_code, $error_msg ),
|
|
array(
|
|
'status' => $http_code,
|
|
'trace' => $response_trace,
|
|
)
|
|
);
|
|
}
|
|
|
|
$image_url = $body['choices'][0]['message']['images'][0]['image_url']['url']
|
|
?? $body['choices'][0]['message']['images'][0]['imageUrl']['url']
|
|
?? '';
|
|
|
|
if ( '' === $image_url ) {
|
|
return new WP_Error(
|
|
'image_generation_failed',
|
|
$body['error']['message'] ?? 'Unknown error - no image URL returned',
|
|
array(
|
|
'status' => 502,
|
|
'trace' => $response_trace,
|
|
)
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'url' => $image_url,
|
|
'cost' => $body['usage']['cost'] ?? 0.03,
|
|
'generation_time' => $generation_time,
|
|
'model' => $model,
|
|
'prompt' => $prompt,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determine OpenRouter modalities for an image generation model.
|
|
*
|
|
* @since 0.2.1
|
|
* @param string $model Model ID.
|
|
* @return array
|
|
*/
|
|
private function get_image_generation_modalities( $model ) {
|
|
$model = trim( str_replace( ':online', '', (string) $model ) );
|
|
$models = $this->get_cached_models();
|
|
if ( ! is_wp_error( $models ) && is_array( $models ) ) {
|
|
foreach ( $models as $entry ) {
|
|
$id = is_array( $entry ) ? ( $entry['id'] ?? '' ) : ( $entry->id ?? '' );
|
|
if ( 0 !== strcasecmp( (string) $id, $model ) ) {
|
|
continue;
|
|
}
|
|
$architecture = is_array( $entry ) ? ( $entry['architecture'] ?? array() ) : (array) ( $entry->architecture ?? array() );
|
|
$output_modalities = isset( $architecture['output_modalities'] ) && is_array( $architecture['output_modalities'] )
|
|
? $architecture['output_modalities']
|
|
: array();
|
|
if ( in_array( 'image', $output_modalities, true ) && in_array( 'text', $output_modalities, true ) ) {
|
|
return array( 'image', 'text' );
|
|
}
|
|
if ( in_array( 'image', $output_modalities, true ) ) {
|
|
return array( 'image' );
|
|
}
|
|
}
|
|
}
|
|
|
|
return array( 'image' );
|
|
}
|
|
|
|
/**
|
|
* Check if provider is configured
|
|
*
|
|
* @return bool True if API key is set.
|
|
*/
|
|
public function is_configured() {
|
|
return ! empty( $this->api_key );
|
|
}
|
|
|
|
/**
|
|
* Test connection to OpenRouter API
|
|
*
|
|
* @return array|WP_Error Success array or error.
|
|
*/
|
|
public function test_connection() {
|
|
if ( ! $this->is_configured() ) {
|
|
return new WP_Error( 'not_configured', 'OpenRouter API key not configured' );
|
|
}
|
|
|
|
$response = wp_remote_get(
|
|
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
|
array(
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $this->api_key,
|
|
),
|
|
'timeout' => 10,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
if ( 200 !== $code ) {
|
|
return new WP_Error(
|
|
'connection_failed',
|
|
sprintf( 'OpenRouter API returned status %d', $code )
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'success' => true,
|
|
'message' => 'Connected to OpenRouter successfully',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if provider supports task type
|
|
*
|
|
* @param string $type Task type.
|
|
* @return bool True (OpenRouter supports all task types).
|
|
*/
|
|
public function supports_task_type( $type ) {
|
|
return in_array(
|
|
$type,
|
|
array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
|
|
true
|
|
);
|
|
}
|
|
}
|