649 lines
17 KiB
PHP
649 lines
17 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 {
|
|
|
|
/**
|
|
* API key.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $api_key = '';
|
|
|
|
/**
|
|
* Chat model (discussion, research, recommendations).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $chat_model = 'google/gemini-2.5-flash';
|
|
|
|
/**
|
|
* Clarity model (prompt analysis, quiz generation).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $clarity_model = 'google/gemini-2.5-flash';
|
|
|
|
/**
|
|
* Planning model (article outline generation).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $planning_model = 'google/gemini-2.5-flash';
|
|
|
|
/**
|
|
* Writing model (article draft generation).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $writing_model = 'anthropic/claude-3.5-sonnet';
|
|
|
|
/**
|
|
* Refinement model (paragraph edits, rewrites).
|
|
*
|
|
* @var string
|
|
*/
|
|
private $refinement_model = 'anthropic/claude-3.5-sonnet';
|
|
|
|
/**
|
|
* Image model.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $image_model = 'openai/gpt-4o';
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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.
|
|
$cached_models = get_transient( 'wpaw_openrouter_models' );
|
|
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',
|
|
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 ) {
|
|
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' ) . ')';
|
|
}
|
|
}
|
|
|
|
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 ) ) );
|
|
}
|
|
|
|
// Cache for 24 hours.
|
|
set_transient( 'wpaw_openrouter_models', $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_transient( 'wpaw_openrouter_models' );
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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'] ?? '';
|
|
|
|
// Get models from settings (6 models per model-preset-brief.md).
|
|
$this->chat_model = $settings['chat_model'] ?? $this->chat_model;
|
|
$this->clarity_model = $settings['clarity_model'] ?? $this->clarity_model;
|
|
$this->planning_model = $settings['planning_model'] ?? $this->planning_model;
|
|
$this->writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? $this->writing_model );
|
|
$this->refinement_model = $settings['refinement_model'] ?? $this->refinement_model;
|
|
$this->image_model = $settings['image_model'] ?? $this->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';
|
|
}
|
|
|
|
// 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 );
|
|
|
|
// 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 ( ! str_starts_with( $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 );
|
|
|
|
// 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 ) {
|
|
return new WP_Error(
|
|
'api_error',
|
|
sprintf( __( 'API error: HTTP %d', 'wp-agentic-writer' ), $http_code )
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $prompt Image prompt.
|
|
* @return array|WP_Error Response array with image URL or WP_Error on failure.
|
|
*/
|
|
public function generate_image( $prompt ) {
|
|
// Check API key.
|
|
if ( empty( $this->api_key ) ) {
|
|
return new WP_Error(
|
|
'no_api_key',
|
|
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
$messages = array(
|
|
array(
|
|
'role' => 'user',
|
|
'content' => sprintf(
|
|
'Generate an image based on this prompt: %s. Return only the image URL.',
|
|
$prompt
|
|
),
|
|
),
|
|
);
|
|
|
|
$response = $this->chat( $messages, array( 'model' => $this->image_model ), 'image' );
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
return $response;
|
|
}
|
|
|
|
// Extract image URL from response.
|
|
$content = $response['content'];
|
|
$url = '';
|
|
|
|
// Try to extract URL from content.
|
|
if ( preg_match( '/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp)/i', $content, $matches ) ) {
|
|
$url = $matches[0];
|
|
}
|
|
|
|
return array(
|
|
'url' => $url,
|
|
'prompt' => $prompt,
|
|
'cost' => $response['cost'],
|
|
'model' => $response['model'],
|
|
);
|
|
}
|
|
}
|