Add PRV_Key_Store (resolver), PRV_Crypto_Helper (libsodium/OpenSSL), PRV_Key_Manager_Renderer (write-only Settings card), PRV_Key_Test_Ajax. Key resolver: PRV_OPENROUTER_API_KEY constant > prv_provider_key_enc option > none. All probe + test paths read key through PRV_Key_Store::get_key(). Security: - Ciphertext only in prv_provider_key_enc (hex nonce:hex ciphertext). - Enc key from SHA-256(AUTH_KEY+SECURE_AUTH_KEY) — never stored. - Plaintext never in options, logs, exceptions, page source, REST, or JS. - manage_options + nonce on set/remove/test. wp-config precedence enforced. - write-only UI: password input always renders empty; status text only. - uninstall.php prv_ wildcard DELETE covers prv_provider_key_enc. Files: 4 new (all ≤300 lines), 14 modified. Tests: test-key-store.php (encrypt/decrypt round-trip, wrong-key fails silently, resolver precedence, write-only render, Remove, constant wins). ARCHITECTURE/CONVENTIONS/CONTEXT/ CHANGELOG all updated in this commit. Agent-Session: 2026-06-15-pr-vision-v0.2.3-api-key-ui
155 lines
4.6 KiB
PHP
155 lines
4.6 KiB
PHP
<?php
|
|
/**
|
|
* Perplexity sonar provider — primary citation signal.
|
|
*
|
|
* @package PrVision
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Perplexity sonar provider — primary citation signal.
|
|
*
|
|
* Routes through the Cloudflare AI Gateway (openrouter path) because
|
|
* OpenRouter proxies Perplexity models. Perplexity's sonar returns
|
|
* an explicit `citations` array — the most faithful citation signal
|
|
* available (real web retrieval vs. training-time knowledge).
|
|
*
|
|
* 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 the PRV_Probe_Provider interface.
|
|
* 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.
|
|
* @see CONTEXT.md — "Perplexity sonar", "citations".
|
|
* @package PrVision
|
|
*/
|
|
class PRV_Perplexity_Provider implements PRV_Probe_Provider {
|
|
|
|
/**
|
|
* Model identifier passed to OpenRouter for Perplexity routing.
|
|
*/
|
|
const MODEL = 'perplexity/sonar';
|
|
|
|
/**
|
|
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
|
|
*/
|
|
const ESTIMATED_COST_PER_PROBE = 0.005;
|
|
|
|
/**
|
|
* 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 PRV_Gateway_Client|null $gateway Injected for testing; auto-created otherwise.
|
|
* @param PRV_Citation_Detector|null $detector Injected for testing; auto-created otherwise.
|
|
*/
|
|
public function __construct( ?PRV_Gateway_Client $gateway = null, ?PRV_Citation_Detector $detector = null ) {
|
|
$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.' );
|
|
}
|
|
|
|
$body = array(
|
|
'model' => self::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/Perplexity response into a PRV_Probe_Result.
|
|
*
|
|
* Side effects: None.
|
|
*
|
|
* @param array<string, mixed> $data Decoded API response.
|
|
*
|
|
* @return PRV_Probe_Result
|
|
*/
|
|
public function parse_response( array $data ): PRV_Probe_Result {
|
|
// Extract text excerpt from the first choice.
|
|
$content = '';
|
|
if ( isset( $data['choices'][0]['message']['content'] ) ) {
|
|
$content = (string) $data['choices'][0]['message']['content'];
|
|
}
|
|
$raw_excerpt = mb_substr( $content, 0, 500 );
|
|
|
|
// Perplexity returns citations at the top level of the response envelope.
|
|
$raw_citations = array();
|
|
if ( isset( $data['citations'] ) && is_array( $data['citations'] ) ) {
|
|
$raw_citations = $data['citations'];
|
|
}
|
|
|
|
$domains = $this->detector->parse_domains( $raw_citations );
|
|
$cited = $this->detector->is_cited( $domains );
|
|
$our_position = $this->detector->get_our_position( $domains );
|
|
|
|
// Cost from usage data when available; fall back to estimate.
|
|
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
|
|
if ( isset( $data['usage']['total_tokens'] ) ) {
|
|
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
|
|
}
|
|
|
|
return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd );
|
|
}
|
|
|
|
/**
|
|
* {@inheritDoc}
|
|
*/
|
|
public function get_name(): string {
|
|
return 'Perplexity sonar';
|
|
}
|
|
|
|
/**
|
|
* {@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();
|
|
}
|
|
}
|