- 3-step OpenRouter pipeline: Gatekeeper scoring/classification, PubMed+Google URL extraction, Markdown summary generation - REST worker routes /wp-json/peptide-news/v1/worker/* with X-Peptide-News-Token auth - Standalone Coolify worker (tools/vps-worker) with Dockerfile + docker-compose - CLI commands, content extractor, pubmed extractor, google url resolver - 89 PHPUnit tests passing
227 lines
7.4 KiB
PHP
227 lines
7.4 KiB
PHP
<?php
|
|
declare( strict_types=1 );
|
|
/**
|
|
* HTTP transport for the OpenRouter chat-completions API.
|
|
*
|
|
* Encapsulates authentication, request construction, retry logic
|
|
* (429 back-off), and response parsing. Returns structured arrays
|
|
* with content, token usage, request ID, and reported cost.
|
|
*
|
|
* Called by Peptide_News_LLM::process_article() for each LLM task.
|
|
* Depends on WordPress HTTP API (wp_remote_post) and the Encryption helper.
|
|
*
|
|
* @since 2.5.0
|
|
* @see class-peptide-news-llm.php Orchestrator that drives the calls.
|
|
* @see class-peptide-news-llm-prompt-builder.php Builds the prompts sent here.
|
|
* @see class-peptide-news-encryption.php Decrypts the stored API key.
|
|
*/
|
|
class Peptide_News_LLM_Client {
|
|
|
|
/** @var int Maximum retries for rate-limited (429) requests. */
|
|
const MAX_RETRIES = 1;
|
|
|
|
/**
|
|
* Get the OpenRouter API URL from options with a hardcoded default.
|
|
*
|
|
* @return string
|
|
*/
|
|
public static function get_api_url(): string {
|
|
return get_option( 'peptide_news_llm_api_url', 'https://openrouter.ai/api/v1/chat/completions' );
|
|
}
|
|
|
|
/**
|
|
* Retrieve and decrypt the OpenRouter API key.
|
|
*
|
|
* Handles both encrypted (AES-256-CBC) and legacy plaintext values
|
|
* transparently via Peptide_News_Encryption::decrypt().
|
|
*
|
|
* @return string Decrypted API key or empty string.
|
|
*/
|
|
public static function get_api_key(): string {
|
|
$raw = get_option( 'peptide_news_openrouter_api_key', '' );
|
|
if ( empty( $raw ) ) {
|
|
return '';
|
|
}
|
|
if ( class_exists( 'Peptide_News_Encryption' ) ) {
|
|
return Peptide_News_Encryption::decrypt( $raw );
|
|
}
|
|
return $raw;
|
|
}
|
|
|
|
/**
|
|
* Validate an OpenRouter model ID format.
|
|
*
|
|
* Accepts patterns like: provider/model-name, provider/model-name:version
|
|
*
|
|
* @param string $model
|
|
* @return bool
|
|
*/
|
|
public static function is_valid_model( string $model ): bool {
|
|
return (bool) preg_match( '/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+(:[a-zA-Z0-9._-]+)?$/', $model );
|
|
}
|
|
|
|
/**
|
|
* Call the OpenRouter API with retry logic for 429 rate limits.
|
|
*
|
|
* Backward-compatible wrapper that returns only the content string.
|
|
* Internally delegates to call_with_usage().
|
|
*
|
|
* @param string $api_key
|
|
* @param string $model
|
|
* @param string $prompt
|
|
* @return string|\WP_Error The response text or an error.
|
|
*/
|
|
public static function call( string $api_key, string $model, string $prompt ) {
|
|
$result = self::call_with_usage( $api_key, $model, $prompt );
|
|
return $result['content'];
|
|
}
|
|
|
|
/**
|
|
* Call the OpenRouter API and return content + token usage data.
|
|
*
|
|
* Returns an array with 'content' (string|WP_Error), 'usage' (token counts),
|
|
* 'request_id' (for debugging), and 'cost' (if API reports it).
|
|
*
|
|
* Side effects: one outbound HTTP POST (15 s timeout), up to MAX_RETRIES
|
|
* additional retries on 429. Sleeps during back-off.
|
|
*
|
|
* @param string $api_key
|
|
* @param string $model
|
|
* @param string $prompt
|
|
* @return array{content: string|\WP_Error, usage: array, request_id: string, cost: float}
|
|
*/
|
|
public static function call_with_usage( string $api_key, string $model, string $prompt ): array {
|
|
$messages = array(
|
|
array(
|
|
'role' => 'user',
|
|
'content' => $prompt,
|
|
),
|
|
);
|
|
return self::call_messages( $api_key, $model, $messages, 300, 0.3 );
|
|
}
|
|
|
|
/**
|
|
* Call the OpenRouter API with structured message arrays and optional response_format (JSON schema).
|
|
*
|
|
* @param string $api_key The API key.
|
|
* @param string $model Model ID.
|
|
* @param array $messages Array of message arrays ('role', 'content').
|
|
* @param int $max_tokens Max completion tokens.
|
|
* @param float $temperature Sampling temperature.
|
|
* @param array $response_format Optional JSON schema format array.
|
|
* @return array{content: string|\WP_Error, usage: array, request_id: string, cost: float}
|
|
*/
|
|
public static function call_messages(
|
|
string $api_key,
|
|
string $model,
|
|
array $messages,
|
|
int $max_tokens = 800,
|
|
float $temperature = 0.3,
|
|
array $response_format = array()
|
|
): array {
|
|
$result = array(
|
|
'content' => '',
|
|
'usage' => array(),
|
|
'request_id' => '',
|
|
'cost' => 0.0,
|
|
);
|
|
|
|
$body = array(
|
|
'model' => $model,
|
|
'messages' => $messages,
|
|
'max_tokens' => $max_tokens,
|
|
'temperature' => $temperature,
|
|
);
|
|
|
|
if ( ! empty( $response_format ) ) {
|
|
$body['response_format'] = $response_format;
|
|
}
|
|
|
|
$retries = 0;
|
|
$backoff = 2; // Initial backoff in seconds.
|
|
|
|
while ( $retries <= self::MAX_RETRIES ) {
|
|
$response = wp_remote_post( self::get_api_url(), array(
|
|
'timeout' => 20,
|
|
'headers' => array(
|
|
'Authorization' => 'Bearer ' . $api_key,
|
|
'Content-Type' => 'application/json',
|
|
'HTTP-Referer' => home_url(),
|
|
'X-Title' => 'Peptide News Aggregator',
|
|
),
|
|
'body' => wp_json_encode( $body ),
|
|
) );
|
|
|
|
if ( is_wp_error( $response ) ) {
|
|
$result['content'] = $response;
|
|
return $result;
|
|
}
|
|
|
|
$status = wp_remote_retrieve_response_code( $response );
|
|
|
|
// Handle rate limiting with exponential backoff.
|
|
if ( 429 === $status && $retries < self::MAX_RETRIES ) {
|
|
$retry_after = wp_remote_retrieve_header( $response, 'retry-after' );
|
|
$wait = $retry_after ? min( (int) $retry_after, 30 ) : $backoff;
|
|
sleep( $wait );
|
|
++$retries;
|
|
$backoff *= 2;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
$data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
|
|
|
|
// Extract usage data from response regardless of success/failure.
|
|
if ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) {
|
|
$result['usage'] = array(
|
|
'prompt_tokens' => (int) ( $data['usage']['prompt_tokens'] ?? 0 ),
|
|
'completion_tokens' => (int) ( $data['usage']['completion_tokens'] ?? 0 ),
|
|
'total_tokens' => (int) ( $data['usage']['total_tokens'] ?? 0 ),
|
|
);
|
|
}
|
|
|
|
// Extract the request ID for debugging/reconciliation.
|
|
if ( is_array( $data ) && isset( $data['id'] ) ) {
|
|
$result['request_id'] = (string) $data['id'];
|
|
}
|
|
|
|
// OpenRouter may include generation cost in data.usage.total_cost.
|
|
if ( is_array( $data ) && isset( $data['usage']['total_cost'] ) ) {
|
|
$result['cost'] = (float) $data['usage']['total_cost'];
|
|
}
|
|
|
|
if ( $status < 200 || $status >= 300 ) {
|
|
$error_msg = 'HTTP ' . $status;
|
|
if ( is_array( $data ) && isset( $data['error'] ) && is_array( $data['error'] ) && isset( $data['error']['message'] ) ) {
|
|
$error_msg = $data['error']['message'];
|
|
}
|
|
$result['content'] = new \WP_Error( 'openrouter_error', $error_msg );
|
|
return $result;
|
|
}
|
|
|
|
// Validate the response structure thoroughly.
|
|
if ( ! is_array( $data ) ) {
|
|
$result['content'] = new \WP_Error( 'openrouter_invalid', 'Invalid JSON response from OpenRouter' );
|
|
return $result;
|
|
}
|
|
if ( ! isset( $data['choices'] ) || ! is_array( $data['choices'] ) || empty( $data['choices'] ) ) {
|
|
$result['content'] = new \WP_Error( 'openrouter_empty', 'No choices in OpenRouter response' );
|
|
return $result;
|
|
}
|
|
if ( ! isset( $data['choices'][0]['message'] ) || ! is_array( $data['choices'][0]['message'] ) ) {
|
|
$result['content'] = new \WP_Error( 'openrouter_empty', 'Malformed choice in OpenRouter response' );
|
|
return $result;
|
|
}
|
|
if ( ! isset( $data['choices'][0]['message']['content'] ) || '' === trim( $data['choices'][0]['message']['content'] ) ) {
|
|
$result['content'] = new \WP_Error( 'openrouter_empty', 'Empty content in OpenRouter response' );
|
|
return $result;
|
|
}
|
|
|
|
$result['content'] = trim( $data['choices'][0]['message']['content'] );
|
|
return $result;
|
|
}
|
|
}
|