first commit all files

This commit is contained in:
dwindown
2026-01-28 00:26:00 +07:00
parent 65dd207a74
commit 97426d5ab1
72 changed files with 91484 additions and 0 deletions

View File

@@ -0,0 +1,648 @@
<?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'],
);
}
}