P0 security: - PRV_Gateway_Client: remove response body from RuntimeException (HTTP status only) - PRV_Capture_Writer: allowlist-only write (prompt/response text, never headers/auth) - Mandatory capture order: can_afford → probe → persist_result → update_row_cost → write_meta → capture_io - capture_io last and wrapped in swallowing try/catch New tables (SCHEMA_VERSION 3): - prv_call_meta: cost/metadata per call, kept indefinitely - prv_call_io: rendered prompt + raw response, pruned on retention window (default 90d) New classes: - PRV_Call_Meta_Table, PRV_Call_Io_Table: table schema + dbDelta - PRV_Capture_Writer: metadata + I/O write; security allowlist enforced - PRV_Prune_Cron: daily prv_daily_prune cron deletes aged prv_call_io rows only - PRV_Cost_Rollup_Query: MTD summary, model breakdown, drill-down, projection - PRV_Call_Log_Query: paginated + filtered call-log query + per-call I/O fetch - PRV_Costs_Page + PRV_Costs_Renderer: Costs admin sub-page (cap progress, bento, model table, drill-down) - PRV_Call_Log_Page + PRV_Call_Log_Table_Renderer: Call Log admin sub-page (filter bar, paginated table, WCAG AA pagination) - PRV_Call_Drawer_Renderer: non-modal complementary drawer (5 states: Normal/Pruned/Legacy/Failed/Loading) - PRV_Call_Drawer_Script: inline JS for drawer (300-line split from renderer) - PRV_Call_Detail_Ajax: wp_ajax_prv_call_detail AJAX handler - PRV_Admin_Page_Css: CSS provider (300-line split from PRV_Admin_Page) - PRV_Probe_Run_Executor: inner probe loop (300-line split from PRV_Probe_Runner) Updated: - PRV_Probe_Result: tokens_in/tokens_out params + getters - Both providers: report tokens_in/tokens_out from usage.prompt_tokens/completion_tokens - PRV_Probe_Runner: slimmed to lock+UUID+delegation; injects PRV_Capture_Writer - PRV_Config: get_io_retention_days(); PRV_IO_RETENTION_DEFAULT_DAYS constant - PRV_Upgrader/Activator/Deactivator/Plugin: register new tables, cron, pages, AJAX - Settings: retention days field + save in controller - uninstall.php: DROP both new tables, clear prv_daily_prune cron - Subnav 4-tab strip (Dashboard | Costs | Call Log | Settings) on all pages - ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONVENTIONS.md updated Tests: - test-capture-writer.php: write_meta, write_io, P0 security (401 record has no key/Bearer/sk-or-) - test-cost-audit.php: MTD reconciliation, projection, prune, forced capture_io exception, table drops Agent-Session: peptiderepo-geomonitor-v0.3.0-cost-audit
190 lines
6.1 KiB
PHP
190 lines
6.1 KiB
PHP
<?php
|
|
/**
|
|
* Generic OpenRouter provider for GPT-search and Gemini-search models.
|
|
*
|
|
* @package PrVision
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Generic OpenRouter provider — parameterised for GPT-search and Gemini-search.
|
|
*
|
|
* One class handles any OpenRouter-routed model. The model identifier is set
|
|
* at construction time; the runner instantiates one provider per model from
|
|
* PRV_Config::get_models().
|
|
*
|
|
* API key is resolved through PRV_Key_Store::get_key() (constant → admin
|
|
* option → none), never read directly from the constant.
|
|
*
|
|
* Who triggers: PRV_Probe_Runner via PRV_Probe_Provider interface.
|
|
* PRV_Key_Test_Ajax (probe_with_key for test path).
|
|
* Dependencies: PRV_Key_Store, PRV_Gateway_Client, PRV_Citation_Detector,
|
|
* PRV_Probe_Result.
|
|
*
|
|
* @see interface-prv-probe-provider.php — Interface this implements.
|
|
* @see class-prv-key-store.php — Single key resolver.
|
|
* @see class-prv-gateway-client.php — Shared HTTP + retry logic.
|
|
* @see class-prv-citation-detector.php — Domain extraction + detection.
|
|
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
|
|
* @package PrVision
|
|
*/
|
|
class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
|
|
|
|
/**
|
|
* Estimated cost per probe call in USD (conservative cross-model default).
|
|
*/
|
|
const ESTIMATED_COST_PER_PROBE = 0.003;
|
|
|
|
/**
|
|
* OpenRouter model identifier (e.g. "openai/gpt-4o-search-preview").
|
|
*
|
|
* @var string
|
|
*/
|
|
private string $model;
|
|
|
|
/**
|
|
* HTTP gateway client.
|
|
*
|
|
* @var PRV_Gateway_Client
|
|
*/
|
|
private PRV_Gateway_Client $gateway;
|
|
|
|
/**
|
|
* Citation domain detector.
|
|
*
|
|
* @var PRV_Citation_Detector
|
|
*/
|
|
private PRV_Citation_Detector $detector;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param string $model OpenRouter model identifier.
|
|
* @param PRV_Gateway_Client|null $gateway Injected for testing.
|
|
* @param PRV_Citation_Detector|null $detector Injected for testing.
|
|
*/
|
|
public function __construct( string $model, ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
|
|
$this->model = $model;
|
|
$this->gateway = $gateway ?? new PRV_Gateway_Client();
|
|
$this->detector = $detector ?? new PRV_Citation_Detector();
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*
|
|
* Resolves the key via PRV_Key_Store (constant → option → none).
|
|
*
|
|
* @param string $query The search query to probe.
|
|
*
|
|
* @return PRV_Probe_Result
|
|
* @throws \RuntimeException On permanent API failure or no key configured.
|
|
*/
|
|
public function probe( string $query ): PRV_Probe_Result {
|
|
$api_key = PRV_Key_Store::get_key();
|
|
if ( '' === $api_key ) {
|
|
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception, not HTML
|
|
throw new \RuntimeException( 'PRV_OPENROUTER_API_KEY is not configured. Set it in wp-config.php or via Settings.' );
|
|
}
|
|
return $this->probe_with_key( $query, $api_key );
|
|
}
|
|
|
|
/**
|
|
* Probe using a caller-supplied key (used by PRV_Key_Test_Ajax).
|
|
*
|
|
* Accepts the key as a parameter so the test path can call this without
|
|
* re-resolving from the store. The key is passed only to the gateway
|
|
* (as an Authorization header); it is never logged, stored, or returned.
|
|
*
|
|
* @param string $query The search query to probe.
|
|
* @param string $api_key Plaintext API key (server-side only; never exposed).
|
|
*
|
|
* @return PRV_Probe_Result
|
|
* @throws \RuntimeException On permanent API failure.
|
|
*/
|
|
public function probe_with_key( string $query, string $api_key ): PRV_Probe_Result {
|
|
$body = array(
|
|
'model' => $this->model,
|
|
'messages' => array(
|
|
array(
|
|
'role' => 'user',
|
|
'content' => $query,
|
|
),
|
|
),
|
|
'max_tokens' => 512,
|
|
);
|
|
|
|
$data = $this->gateway->post_to_gateway( 'openrouter', $body, $api_key );
|
|
|
|
return $this->parse_response( $data );
|
|
}
|
|
|
|
/**
|
|
* Parse the OpenRouter response into a PRV_Probe_Result.
|
|
*
|
|
* Attempts to extract citations from:
|
|
* 1. `annotations` array (some models include structured source references).
|
|
* 2. URLs found inline in the response text (regex fallback).
|
|
*
|
|
* @param array<string, mixed> $data Decoded API response.
|
|
*
|
|
* @return PRV_Probe_Result
|
|
*/
|
|
public function parse_response( array $data ): PRV_Probe_Result {
|
|
$content = '';
|
|
if ( isset( $data['choices'][0]['message']['content'] ) ) {
|
|
$content = (string) $data['choices'][0]['message']['content'];
|
|
}
|
|
$raw_excerpt = mb_substr( $content, 0, 500 );
|
|
|
|
// 1. Prefer structured annotations when present.
|
|
$raw_citations = array();
|
|
if ( isset( $data['choices'][0]['message']['annotations'] ) && is_array( $data['choices'][0]['message']['annotations'] ) ) {
|
|
foreach ( $data['choices'][0]['message']['annotations'] as $ann ) {
|
|
if ( isset( $ann['url_citation']['url'] ) ) {
|
|
$raw_citations[] = $ann['url_citation']['url'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Regex fallback: extract URLs from response text.
|
|
if ( empty( $raw_citations ) && '' !== $content ) {
|
|
preg_match_all( '#https?://[^\s\)\]\"\'<>]+#i', $content, $matches );
|
|
$raw_citations = $matches[0] ?? array();
|
|
}
|
|
|
|
$domains = $this->detector->parse_domains( $raw_citations );
|
|
$cited = $this->detector->is_cited( $domains );
|
|
$our_position = $this->detector->get_our_position( $domains );
|
|
|
|
// Cost estimation from usage data.
|
|
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
|
|
if ( isset( $data['usage']['total_tokens'] ) ) {
|
|
// Rough blended rate ~$2/1M tokens across GPT-4o and Gemini Flash.
|
|
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000002;
|
|
}
|
|
|
|
// Token counts for per-call attribution.
|
|
$tokens_in = isset( $data['usage']['prompt_tokens'] ) ? (int) $data['usage']['prompt_tokens'] : null;
|
|
$tokens_out = isset( $data['usage']['completion_tokens'] ) ? (int) $data['usage']['completion_tokens'] : null;
|
|
|
|
return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd, $tokens_in, $tokens_out );
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function get_name(): string {
|
|
return 'OpenRouter/' . $this->model;
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*
|
|
* True when any source (constant or admin option) provides a non-empty key.
|
|
*/
|
|
public function is_configured(): bool {
|
|
return '' !== PRV_Key_Store::get_key();
|
|
}
|
|
}
|