Files
wp-agentic-writer/includes/class-openrouter-provider.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
);
}
}