PR Vision v0.2.3: UI-editable provider API key, encrypted + write-only

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
This commit is contained in:
peptiderepo
2026-06-15 03:08:54 +00:00
parent 28e3ce6576
commit 67499506ba
19 changed files with 1183 additions and 221 deletions

View File

@@ -0,0 +1,185 @@
<?php
/**
* Symmetric encryption/decryption helper for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Symmetric encryption/decryption using libsodium (preferred) or OpenSSL AES-256-GCM.
*
* Provides a narrow, single-purpose interface: encrypt() / decrypt(). The
* encryption key is always supplied by the caller (PRV_Key_Store derives it
* from WP salts). No key material is persisted here.
*
* Algorithm selection:
* - libsodium XSalsa20-Poly1305 (sodium_crypto_secretbox): used when the
* sodium extension is available (PHP 7.2+ default). Nonce = 24 bytes (random).
* - OpenSSL AES-256-GCM fallback: used when sodium is unavailable. IV = 12 bytes
* (random); 16-byte GCM auth tag appended to ciphertext.
*
* Storage format (both algorithms): hex(nonce_or_iv) : hex(ciphertext[+tag])
* The algorithm is inferred from nonce length at decrypt time.
*
* Security invariants:
* - Plaintext is NEVER stored, logged, or returned beyond the caller.
* - Each encrypt() call uses a fresh random nonce/IV.
* - Decryption silently returns '' on any failure (no info leak via exception).
*
* Who triggers: PRV_Key_Store — all encryption/decryption delegated here.
* Dependencies: sodium_crypto_secretbox (optional), openssl_encrypt (fallback).
*
* @see class-prv-key-store.php -- Calls encrypt() / decrypt().
* @package PrVision
*/
class PRV_Crypto_Helper {
/**
* Separator between hex-encoded nonce/IV and ciphertext in stored format.
*/
const SEPARATOR = ':';
/**
* Encrypt plaintext using libsodium or OpenSSL fallback.
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw binary encryption key.
*
* @return string hex(nonce):hex(ciphertext), or empty string on error.
*/
public static function encrypt( string $plaintext, string $enc_key ): string {
if ( function_exists( 'sodium_crypto_secretbox' ) ) {
return self::encrypt_sodium( $plaintext, $enc_key );
}
return self::encrypt_openssl( $plaintext, $enc_key );
}
/**
* Decrypt a stored ciphertext produced by encrypt().
*
* Algorithm is inferred from nonce length: sodium nonce = 24 bytes (48 hex).
*
* @param string $stored Encoded ciphertext (hex(nonce):hex(ciphertext)).
* @param string $enc_key 32-byte raw binary encryption key.
*
* @return string Plaintext, or empty string on any error.
*/
public static function decrypt( string $stored, string $enc_key ): string {
$sep_pos = strpos( $stored, self::SEPARATOR );
if ( false === $sep_pos ) {
return '';
}
$nonce_hex = substr( $stored, 0, $sep_pos );
$cipher_hex = substr( $stored, $sep_pos + 1 );
if ( '' === $nonce_hex || '' === $cipher_hex ) {
return '';
}
// Infer algorithm from nonce length: sodium = 24 bytes (48 hex chars).
$nonce_byte_count = strlen( $nonce_hex ) / 2;
if ( function_exists( 'sodium_crypto_secretbox_open' )
&& SODIUM_CRYPTO_SECRETBOX_NONCEBYTES === (int) $nonce_byte_count ) {
return self::decrypt_sodium( $nonce_hex, $cipher_hex, $enc_key );
}
// OpenSSL path: IV = 12 bytes (24 hex), 16-byte tag appended.
return self::decrypt_openssl( $nonce_hex, $cipher_hex, $enc_key );
}
// ── Private algorithm implementations ─────────────────────────────────
/**
* Encrypt using libsodium secretbox (XSalsa20-Poly1305).
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw key.
*
* @return string hex(nonce):hex(ciphertext), or empty on error.
*/
private static function encrypt_sodium( string $plaintext, string $enc_key ): string {
try {
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
$ciphertext = sodium_crypto_secretbox( $plaintext, $nonce, $enc_key );
return bin2hex( $nonce ) . self::SEPARATOR . bin2hex( $ciphertext );
} catch ( \Throwable $e ) {
return '';
}
}
/**
* Decrypt using libsodium secretbox.
*
* @param string $nonce_hex Hex-encoded 24-byte nonce.
* @param string $cipher_hex Hex-encoded ciphertext with Poly1305 MAC.
* @param string $enc_key 32-byte raw key.
*
* @return string Plaintext, or empty on failure.
*/
private static function decrypt_sodium( string $nonce_hex, string $cipher_hex, string $enc_key ): string {
try {
$nonce = hex2bin( $nonce_hex );
$ciphertext = hex2bin( $cipher_hex );
if ( false === $nonce || false === $ciphertext ) {
return '';
}
$result = sodium_crypto_secretbox_open( $ciphertext, $nonce, $enc_key );
return ( false === $result ) ? '' : $result;
} catch ( \Throwable $e ) {
return '';
}
}
/**
* Encrypt using OpenSSL AES-256-GCM (sodium fallback).
*
* Appends the 16-byte GCM auth tag to the ciphertext before hex-encoding,
* so decrypt() can split it without a separate field.
*
* @param string $plaintext Value to encrypt.
* @param string $enc_key 32-byte raw key.
*
* @return string hex(iv):hex(ciphertext+tag), or empty on error.
*/
private static function encrypt_openssl( string $plaintext, string $enc_key ): string {
if ( ! function_exists( 'openssl_encrypt' ) ) {
return '';
}
$iv = random_bytes( 12 ); // GCM standard IV length.
$tag = '';
$enc = openssl_encrypt( $plaintext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $enc || '' === $tag ) {
return '';
}
return bin2hex( $iv ) . self::SEPARATOR . bin2hex( $enc . $tag );
}
/**
* Decrypt using OpenSSL AES-256-GCM.
*
* Splits off the 16-byte auth tag appended by encrypt_openssl().
*
* @param string $iv_hex Hex-encoded 12-byte IV.
* @param string $cipher_hex Hex-encoded ciphertext with 16-byte GCM tag appended.
* @param string $enc_key 32-byte raw key.
*
* @return string Plaintext, or empty on failure.
*/
private static function decrypt_openssl( string $iv_hex, string $cipher_hex, string $enc_key ): string {
if ( ! function_exists( 'openssl_decrypt' ) ) {
return '';
}
$iv = hex2bin( $iv_hex );
$cipher_raw = hex2bin( $cipher_hex );
if ( false === $iv || false === $cipher_raw || strlen( $cipher_raw ) < 16 ) {
return '';
}
$tag = substr( $cipher_raw, -16 );
$ciphertext = substr( $cipher_raw, 0, -16 );
$result = openssl_decrypt( $ciphertext, 'aes-256-gcm', $enc_key, OPENSSL_RAW_DATA, $iv, $tag );
return ( false === $result ) ? '' : $result;
}
}

View File

@@ -0,0 +1,213 @@
<?php
/**
* Renderer for the Provider API Key manager card on the Settings page.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the write-only Provider API Key manager card.
*
* Outputs only the key SOURCE (constant / admin / not set) and the
* last-run API status. The stored key value is NEVER included in any
* output: not in HTML, not in JS, not in data attributes.
*
* Three states:
* - SOURCE_CONSTANT: constant defined → lock the input, show note.
* - SOURCE_OPTION: admin key stored → show "Set via admin" + Replace/Remove.
* - SOURCE_NONE: no key → show "Not set" + Set form.
*
* The form submits to admin-post.php (prv_key_set or prv_key_remove),
* nonce-protected. The password input renders empty on every page load.
*
* Who triggers: PRV_Settings_Renderer::render_key_manager_card().
* Dependencies: PRV_Key_Store, PRV_Settings_Page (nonce constants).
*
* @see class-prv-key-store.php -- Source detection.
* @see class-prv-settings-renderer.php -- Calls render().
* @see class-prv-settings-page.php -- Nonce constants + AJAX action.
* @package PrVision
*/
class PRV_Key_Manager_Renderer {
/**
* Render the full Provider API Key card.
*
* Side effects: Outputs HTML directly. Contains no key value.
*
* @return void
*/
public function render(): void {
$source = PRV_Key_Store::get_source();
$is_const = PRV_Key_Store::SOURCE_CONSTANT === $source;
$is_set_opt = PRV_Key_Store::SOURCE_OPTION === $source;
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
$last_check = (string) get_option( 'prv_api_key_last_check', '' );
echo '<div class="prv-card" id="prv-key-card">';
echo '<h2>' . esc_html__( 'Provider API Key', 'pr-vision' ) . '</h2>';
$this->render_status_badge( $source, $status, $last_check );
$this->render_key_form( $is_const, $is_set_opt );
$this->render_test_button( $source );
echo '</div>';
}
/**
* Render the read-only source + health status badge.
*
* @param string $source One of PRV_Key_Store::SOURCE_* constants.
* @param string $api_status Last stored API status (ok/failed/unknown).
* @param string $last_check ISO date of last status update.
*
* @return void
*/
private function render_status_badge( string $source, string $api_status, string $last_check ): void {
echo '<div class="prv-field">';
if ( PRV_Key_Store::SOURCE_CONSTANT === $source ) {
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Set via wp-config (takes precedence)', 'pr-vision' );
echo '</span>';
} elseif ( PRV_Key_Store::SOURCE_OPTION === $source ) {
echo '<span class="prv-api-status prv-api-ok" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Set via admin', 'pr-vision' );
echo '</span>';
} else {
echo '<span class="prv-api-status prv-api-undef" id="prv-key-source-badge">';
echo '&#9679; ' . esc_html__( 'Not set', 'pr-vision' );
echo '</span>';
}
// Last-run health state.
if ( '' !== $last_check && PRV_Key_Store::SOURCE_NONE !== $source ) {
if ( 'failed' === $api_status ) {
echo '&nbsp;<span class="prv-api-status prv-api-fail">' . esc_html__( 'Last run: failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
} elseif ( 'ok' === $api_status ) {
echo '&nbsp;<span class="prv-api-status prv-api-ok">' . esc_html__( 'Last run: OK', 'pr-vision' ) . '</span>';
}
}
echo '</div>';
}
/**
* Render the set/replace/remove key form.
*
* The password input always renders empty. When constant is set, the
* input is disabled with an explanatory note.
*
* @param bool $is_const Whether the constant is currently defined.
* @param bool $is_set_opt Whether an admin-stored (option) key exists.
*
* @return void
*/
private function render_key_form( bool $is_const, bool $is_set_opt ): void {
$form_url = esc_url( admin_url( 'admin-post.php' ) );
echo '<form method="post" action="' . $form_url . '" id="prv-key-set-form" autocomplete="off">';
echo '<input type="hidden" name="action" value="prv_key_set">';
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
echo '<div class="prv-field" style="margin-top:12px">';
echo '<label class="prv-label" for="prv_api_key">';
echo esc_html( $is_set_opt
? __( 'Replace key (leave empty to keep current)', 'pr-vision' )
: __( 'Set key', 'pr-vision' )
);
echo '</label>';
$disabled = $is_const ? ' disabled aria-disabled="true"' : '';
// Input always renders empty — never outputs stored key.
echo '<input type="password" id="prv_api_key" name="prv_api_key" '
. 'class="prv-input" style="max-width:420px" '
. 'value="" autocomplete="new-password" '
. 'placeholder="' . esc_attr( $is_const ? __( 'Managed via wp-config — cannot override', 'pr-vision' ) : __( 'sk-or-…', 'pr-vision' ) ) . '"'
. $disabled . '>';
if ( $is_const ) {
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">'
. esc_html__( 'PRV_OPENROUTER_API_KEY is defined in wp-config.php and takes precedence. Remove it from wp-config to manage the key here.', 'pr-vision' )
. '</p>';
}
echo '</div>';
if ( ! $is_const ) {
echo '<button type="submit" class="prv-btn prv-btn-primary" style="margin-top:4px">'
. esc_html( $is_set_opt ? __( 'Replace key', 'pr-vision' ) : __( 'Set key', 'pr-vision' ) )
. '</button>';
}
echo '</form>';
// Remove key form (only when admin option is set, not constant).
if ( $is_set_opt && ! $is_const ) {
echo '<form method="post" action="' . $form_url . '" id="prv-key-remove-form" style="margin-top:8px">';
echo '<input type="hidden" name="action" value="prv_key_remove">';
wp_nonce_field( PRV_Settings_Page::NONCE_KEY, 'prv_nonce' );
echo '<button type="submit" class="prv-btn prv-btn-danger" '
. 'onclick="return confirm(\'' . esc_js( __( 'Remove the stored API key? The next probe run will fail unless PRV_OPENROUTER_API_KEY is set in wp-config.php.', 'pr-vision' ) ) . '\')">'
. esc_html__( 'Remove key', 'pr-vision' )
. '</button>';
echo '</form>';
}
}
/**
* Render the Test key button and aria-live result area.
*
* Only shown when a key source is available. The test sends one cheap
* probe via AJAX and reports valid/invalid without leaking the key.
*
* @param string $source Current key source.
*
* @return void
*/
private function render_test_button( string $source ): void {
if ( PRV_Key_Store::SOURCE_NONE === $source ) {
return;
}
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_KEY_TEST );
$ajax_url = admin_url( 'admin-ajax.php' );
echo '<div style="margin-top:12px;display:flex;align-items:center;gap:12px">';
echo '<button type="button" id="prv-test-key-btn" class="prv-btn prv-btn-ghost" '
. 'data-ajax-url="' . esc_url( $ajax_url ) . '" '
. 'data-nonce="' . esc_attr( $nonce ) . '">'
. esc_html__( 'Test key', 'pr-vision' )
. '</button>';
echo '<span id="prv-test-key-result" aria-live="polite" role="status" style="font-size:13px"></span>';
echo '</div>';
// Inline JS — only a button wirer, no key value in JS.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<script>(function(){
var btn=document.getElementById("prv-test-key-btn");
if(!btn)return;
btn.addEventListener("click",function(){
var res=document.getElementById("prv-test-key-result");
btn.disabled=true;btn.setAttribute("aria-busy","true");
if(res){res.textContent="Testing…";}
fetch(btn.dataset.ajaxUrl,{
method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},
body:new URLSearchParams({action:"prv_test_key",prv_nonce:btn.dataset.nonce})
}).then(function(r){return r.json();}).then(function(d){
btn.disabled=false;btn.removeAttribute("aria-busy");
if(d.success){
if(res){res.textContent="✓ "+(d.data?d.data.message:"Key valid");res.style.color="var(--prv-lime)";}
}else{
if(res){res.textContent="✗ "+(d.data?d.data.message:"Key invalid");res.style.color="var(--prv-red-chip)";}
}
}).catch(function(){
btn.disabled=false;btn.removeAttribute("aria-busy");
if(res){res.textContent="Request error";res.style.color="var(--prv-red-chip)";}
});
});
})();</script>';
}
}

View File

@@ -0,0 +1,186 @@
<?php
/**
* PR Vision API key resolver and encrypted-at-rest key store.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Resolves and stores the OpenRouter API key with precedence and encryption.
*
* Precedence order (first match wins):
* 1. `PRV_OPENROUTER_API_KEY` wp-config constant (highest — most secure).
* 2. Encrypted admin option `prv_provider_key_enc`.
* 3. Empty string (no key configured).
*
* Encryption is delegated to PRV_Crypto_Helper (libsodium XSalsa20-Poly1305
* preferred; OpenSSL AES-256-GCM fallback). The encryption key is derived
* from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-
* specific, never stored.
*
* Security invariants:
* - Plaintext key NEVER stored in any option, transient, log, or echoed.
* - Decryption occurs only at resolve time, server-side only.
* - Ciphertext purged on uninstall via the `prv_` wildcard DELETE.
*
* Who triggers: PRV_Perplexity_Provider, PRV_OpenRouter_Provider,
* PRV_Key_Test_Ajax.
* Dependencies: PRV_Crypto_Helper, wp_salt(), update_option(),
* delete_option(), get_option().
*
* @see class-prv-crypto-helper.php -- Low-level encrypt/decrypt.
* @see class-prv-perplexity-provider.php -- Calls get_key().
* @see class-prv-openrouter-provider.php -- Calls get_key().
* @see class-prv-key-test-ajax.php -- Calls get_key() for validation.
* @see class-prv-settings-renderer.php -- Calls get_source() for UI status.
* @see class-prv-settings-controller.php -- Calls store_key() / clear_key().
* @see ARCHITECTURE.md -- §Key management.
* @package PrVision
*/
class PRV_Key_Store {
/**
* WP option name for the encrypted key ciphertext.
*/
const OPTION_ENC = 'prv_provider_key_enc';
/**
* Source constant: key comes from wp-config constant.
*/
const SOURCE_CONSTANT = 'constant';
/**
* Source constant: key comes from the encrypted admin option.
*/
const SOURCE_OPTION = 'option';
/**
* Source constant: no key is configured.
*/
const SOURCE_NONE = 'none';
/**
* Resolve the API key following the precedence chain.
*
* Returns the plaintext key for server-side use ONLY. Callers MUST NOT
* pass this value to any client-facing surface (page, REST, log, exception).
*
* @return string Plaintext API key, or empty string when none is configured.
*/
public static function get_key(): string {
// 1. wp-config constant wins when defined and non-empty.
if ( self::constant_is_set() ) {
return (string) constant( 'PRV_OPENROUTER_API_KEY' );
}
// 2. Encrypted admin option.
$enc = get_option( self::OPTION_ENC, '' );
if ( is_string( $enc ) && '' !== $enc ) {
$enc_key = self::derive_encryption_key();
if ( '' !== $enc_key ) {
$decrypted = PRV_Crypto_Helper::decrypt( $enc, $enc_key );
if ( '' !== $decrypted ) {
return $decrypted;
}
}
}
return '';
}
/**
* Determine the source of the resolved key.
*
* Returns one of: SOURCE_CONSTANT, SOURCE_OPTION, SOURCE_NONE.
* Safe for UI output — never returns the key value itself.
*
* @return string One of the SOURCE_* class constants.
*/
public static function get_source(): string {
if ( self::constant_is_set() ) {
return self::SOURCE_CONSTANT;
}
$enc = get_option( self::OPTION_ENC, '' );
if ( is_string( $enc ) && '' !== $enc ) {
return self::SOURCE_OPTION;
}
return self::SOURCE_NONE;
}
/**
* Encrypt and store a new API key.
*
* Encrypts via PRV_Crypto_Helper and stores only the ciphertext. The
* plaintext is never persisted. Caller is responsible for capability and
* nonce checks before calling this method.
*
* Side effects: Writes to wp_options (`prv_provider_key_enc`).
*
* @param string $plaintext_key The raw API key to store.
*
* @return bool True on success, false if encryption fails or key is empty.
*/
public static function store_key( string $plaintext_key ): bool {
if ( '' === $plaintext_key ) {
return false;
}
$enc_key = self::derive_encryption_key();
if ( '' === $enc_key ) {
return false;
}
$enc = PRV_Crypto_Helper::encrypt( $plaintext_key, $enc_key );
if ( '' === $enc ) {
return false;
}
update_option( self::OPTION_ENC, $enc, false );
return true;
}
/**
* Delete the stored encrypted key (reverts to constant or none).
*
* Side effects: Deletes `prv_provider_key_enc` from wp_options.
*
* @return void
*/
public static function clear_key(): void {
delete_option( self::OPTION_ENC );
}
/**
* True when the PRV_OPENROUTER_API_KEY constant is defined and non-empty.
*
* @return bool
*/
public static function constant_is_set(): bool {
return defined( 'PRV_OPENROUTER_API_KEY' )
&& '' !== (string) constant( 'PRV_OPENROUTER_API_KEY' );
}
/**
* Derive a 32-byte raw binary encryption key from WP salts via SHA-256.
*
* Uses AUTH_KEY + SECURE_AUTH_KEY when defined; falls back to wp_salt().
* The derived key is site-specific and never persisted.
*
* @return string 32-byte raw binary key, or empty string when salts unavailable.
*/
public static function derive_encryption_key(): string {
$salt = '';
if ( defined( 'AUTH_KEY' ) && defined( 'SECURE_AUTH_KEY' ) ) {
$salt = AUTH_KEY . SECURE_AUTH_KEY;
} elseif ( function_exists( 'wp_salt' ) ) {
// wp_salt() is available after WP loads; safe in plugin context.
$salt = wp_salt( 'auth' ) . wp_salt( 'secure_auth' );
}
if ( '' === $salt ) {
return '';
}
return hash( 'sha256', $salt, true ); // Raw binary — exactly 32 bytes.
}
}

View File

@@ -0,0 +1,94 @@
<?php
/**
* AJAX handler: Test the resolved API key validity.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AJAX handler for the "Test key" button in the Provider API Key section.
*
* Resolves the API key via PRV_Key_Store::get_key() and performs a single
* cheap probe. Reports valid/invalid without revealing the key value.
* Rate-limited to one test per 30 seconds.
*
* Who triggers: wp_ajax_prv_test_key (registered by PRV_Settings_Page).
* Dependencies: PRV_Key_Store, PRV_OpenRouter_Provider, PRV_Gateway_Client.
*
* @see class-prv-key-store.php -- Key resolution.
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
* @see class-prv-settings-renderer.php -- Renders the "Test key" button.
* @package PrVision
*/
class PRV_Key_Test_Ajax {
/**
* Rate-limit window in seconds.
*/
const RATE_LIMIT_SECONDS = 30;
/**
* Transient key for the rate-limit lock.
*/
const RATE_LIMIT_TRANSIENT = 'prv_key_test_lock';
/**
* Cheap probe query — one call, minimal tokens.
*/
private const PROBE_QUERY = 'what is BPC-157';
/**
* Handle the prv_test_key AJAX request.
*
* Verifies manage_options + nonce, rate-limits, resolves the key, probes,
* and returns JSON success/error without ever including the key value.
*
* Side effects: HTTP call to LLM provider; sets a rate-limit transient; exits.
*
* @return void
*/
public function handle(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) );
}
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, PRV_Settings_Page::NONCE_KEY_TEST ) ) {
wp_send_json_error( array( 'message' => 'Security check failed.' ) );
}
if ( false !== get_transient( self::RATE_LIMIT_TRANSIENT ) ) {
wp_send_json_error( array( 'message' => 'Rate limited — wait 30 s between tests.' ) );
}
set_transient( self::RATE_LIMIT_TRANSIENT, 1, self::RATE_LIMIT_SECONDS );
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
wp_send_json_error( array( 'message' => 'No API key configured.' ) );
}
$start = microtime( true );
$provider = new PRV_OpenRouter_Provider( 'openai/gpt-4o-mini', null, null );
try {
$provider->probe_with_key( self::PROBE_QUERY, $api_key );
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(
'valid' => true,
'latency_ms' => $ms,
'message' => sprintf( 'Key valid · %d ms', $ms ),
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array(
'valid' => false,
'message' => 'Key invalid or provider error — check key and try again.',
)
);
}
}
}

View File

@@ -15,10 +15,14 @@ declare(strict_types=1);
* This is a point-in-time check (not a monitor) -- the UI labels it as such.
* Never displays the API key.
*
* Key is resolved via PRV_Key_Store::get_key() (constant → admin option → none).
*
* Who triggers: wp_ajax_prv_test_model (registered by PRV_Settings_Page).
* Dependencies: PRV_Model_Registry, PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
* Dependencies: PRV_Key_Store, PRV_Model_Registry, PRV_Perplexity_Provider,
* PRV_OpenRouter_Provider.
*
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
* @see class-prv-key-store.php -- Key resolution.
* @see class-prv-model-registry.php -- Provides model data by id.
* @package PrVision
*/
@@ -62,17 +66,23 @@ class PRV_Model_Test_Ajax {
}
set_transient( $lock_key, 1, self::RATE_LIMIT_SECONDS );
// Resolve key through the store — never bypass to a direct constant read.
$api_key = PRV_Key_Store::get_key();
if ( '' === $api_key ) {
wp_send_json_error( array( 'message' => 'API key not configured — set it in wp-config.php or via Settings → Provider API Key.' ) );
}
$start = microtime( true );
$provider = ( 'perplexity/sonar' === $slug )
? new PRV_Perplexity_Provider()
: new PRV_OpenRouter_Provider( $slug );
if ( ! $provider->is_configured() ) {
wp_send_json_error( array( 'message' => 'API key not configured (check wp-config.php).' ) );
}
try {
$provider->probe( 'what is BPC-157' );
if ( $provider instanceof PRV_OpenRouter_Provider ) {
$provider->probe_with_key( 'what is BPC-157', $api_key );
} else {
$provider->probe( 'what is BPC-157' );
}
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(

View File

@@ -8,24 +8,31 @@
declare(strict_types=1);
/**
* Handles the four settings POST actions: save config, and model CRUD.
* Handles the six settings POST actions: save config, model CRUD, key set/remove.
*
* Extracted from PRV_Settings_Page to keep each file under the 300-line
* limit. PRV_Settings_Page registers all hooks and delegates the four
* admin_post_* handlers here.
* limit. PRV_Settings_Page registers all hooks and delegates the handlers here.
*
* All actions require: current_user_can('manage_options') + valid nonce
* (enforced by PRV_Settings_Page::require_admin_nonce(), passed in via
* the $nonce_cb callable).
*
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron,
* PRV_Model_Registry.
* Key security invariants:
* - handle_key_set() reads the raw key from POST, encrypts it via PRV_Key_Store,
* and immediately drops the reference. The plaintext is never stored in an
* option, logged, or echoed.
* - handle_key_remove() calls PRV_Key_Store::clear_key() -- no plaintext involved.
* - The key value is NEVER included in any redirect URL or notice.
*
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron, PRV_Model_Registry,
* PRV_Key_Store.
*
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* @see class-prv-key-store.php -- Encryption and storage.
* @see class-prv-model-registry.php -- CRUD for prv_models.
* @see class-prv-config-version.php -- Config-change versioning.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @package PrVision
*/
class PRV_Settings_Controller {
@@ -64,7 +71,7 @@ class PRV_Settings_Controller {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_SAVE );
$old_cadence = PRV_Config::get_cadence();
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$budget = isset( $_POST['prv_monthly_budget_usd'] )
? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) )
: PRV_DEFAULT_MONTHLY_BUDGET_USD;
@@ -118,6 +125,71 @@ class PRV_Settings_Controller {
exit;
}
/**
* Handle set/replace API key POST.
*
* Reads the raw key from POST, encrypts it via PRV_Key_Store, then
* drops the plaintext reference. Empty submission (no-op) is silently
* ignored. The key value NEVER appears in the redirect URL or notices.
*
* Side effects: Stores encrypted key in prv_provider_key_enc; redirects.
*
* @return void
*/
public function handle_key_set(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$raw_key = isset( $_POST['prv_api_key'] )
? sanitize_text_field( wp_unslash( (string) $_POST['prv_api_key'] ) )
: '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
$result_arg = 'prv_key_unchanged';
if ( '' !== $raw_key ) {
$stored = PRV_Key_Store::store_key( $raw_key );
$result_arg = $stored ? 'prv_key_set' : 'prv_key_error';
// Explicitly unset to prevent the plaintext lingering in memory.
unset( $raw_key );
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
$result_arg => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle remove API key POST.
*
* Clears the encrypted option, reverting to constant or none.
*
* Side effects: Deletes prv_provider_key_enc; redirects.
*
* @return void
*/
public function handle_key_remove(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_KEY );
PRV_Key_Store::clear_key();
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_key_removed' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle Add model POST.
*
@@ -127,7 +199,7 @@ class PRV_Settings_Controller {
*/
public function handle_model_add(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) );
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
@@ -158,7 +230,7 @@ class PRV_Settings_Controller {
*/
public function handle_model_update(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$enabled = ! empty( $_POST['prv_model_enabled'] );
@@ -196,7 +268,7 @@ class PRV_Settings_Controller {
*/
public function handle_model_remove(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above.
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $id ) {

View File

@@ -11,21 +11,21 @@ declare(strict_types=1);
* PR Vision Settings admin page.
*
* Registers "PR Vision > Settings" sub-menu, dispatches POST actions to
* PRV_Settings_Controller, handles Run-now and AJAX test-model, and
* delegates all HTML rendering to PRV_Settings_Renderer.
* PRV_Settings_Controller, handles Run-now and AJAX test-model/test-key,
* and delegates all HTML rendering to PRV_Settings_Renderer.
*
* All actions require: current_user_can('manage_options') + valid nonce.
*
* Who triggers: PRV_Plugin::init() -- is_admin() guard.
* Dependencies: PRV_Settings_Controller, PRV_Settings_Renderer,
* PRV_Model_Test_Ajax, PRV_Probe_Runner, PRV_Run_Lock.
* PRV_Model_Test_Ajax, PRV_Key_Test_Ajax, PRV_Probe_Runner,
* PRV_Run_Lock.
*
* @see class-prv-settings-controller.php -- POST handler implementations.
* @see class-prv-settings-renderer.php -- Renders the HTML.
* @see class-prv-model-test-ajax.php -- AJAX test handler.
* @see class-prv-model-registry.php -- CRUD for prv_models.
* @see class-prv-config-version.php -- Config-change versioning.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @see class-prv-model-test-ajax.php -- AJAX test-model handler.
* @see class-prv-key-test-ajax.php -- AJAX test-key handler.
* @see class-prv-key-store.php -- Key storage actions.
* @package PrVision
*/
class PRV_Settings_Page {
@@ -45,10 +45,16 @@ class PRV_Settings_Page {
/** Nonce action for Test model AJAX. */
const NONCE_TEST = 'prv_model_test';
/** Nonce action for key set/remove POST actions. */
const NONCE_KEY = 'prv_key_action';
/** Nonce action for Test key AJAX. */
const NONCE_KEY_TEST = 'prv_key_test';
/**
* Register WordPress admin hooks.
*
* POST handlers for save/model-CRUD are delegated to PRV_Settings_Controller,
* POST handlers for save/model-CRUD/key are delegated to PRV_Settings_Controller,
* which receives a nonce-verification callback bound to this page.
*
* Side effects: Adds admin_menu, admin_post, and wp_ajax actions.
@@ -67,8 +73,11 @@ class PRV_Settings_Page {
add_action( 'admin_post_prv_model_add', array( $ctrl, 'handle_model_add' ) );
add_action( 'admin_post_prv_model_update', array( $ctrl, 'handle_model_update' ) );
add_action( 'admin_post_prv_model_remove', array( $ctrl, 'handle_model_remove' ) );
add_action( 'admin_post_prv_key_set', array( $ctrl, 'handle_key_set' ) );
add_action( 'admin_post_prv_key_remove', array( $ctrl, 'handle_key_remove' ) );
add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) );
add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) );
add_action( 'wp_ajax_prv_test_key', array( $this, 'handle_test_key' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
@@ -157,6 +166,17 @@ class PRV_Settings_Page {
( new PRV_Model_Test_Ajax() )->handle();
}
/**
* Dispatch the AJAX test-key request to PRV_Key_Test_Ajax.
*
* Side effects: Exits via PRV_Key_Test_Ajax::handle().
*
* @return void
*/
public function handle_test_key(): void {
( new PRV_Key_Test_Ajax() )->handle();
}
/**
* Render the settings page.
*
@@ -175,7 +195,7 @@ class PRV_Settings_Page {
* Verify admin capability and nonce; wp_die on failure.
*
* Called directly for Run-now, and passed as a callback to
* PRV_Settings_Controller for the four POST handlers.
* PRV_Settings_Controller for all POST handlers.
*
* @param string $action Nonce action.
*

View File

@@ -11,15 +11,18 @@ declare(strict_types=1);
* Renders the PR Vision Settings admin page (dark "Assay" theme).
*
* Coordinates the page sections: styles, notices, model manager card
* (via PRV_Model_Manager_Table), secondary config form, sticky save-bar,
* and inline JS. All output is escaped at the point of emission.
* (via PRV_Model_Manager_Table), secondary config form, provider API key
* card (via PRV_Key_Manager_Renderer), sticky save-bar, and inline JS.
* All output is escaped at the point of emission.
*
* Who triggers: PRV_Settings_Page::render_page().
* Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock,
* PRV_Config_Version, PRV_Model_Manager_Table.
* PRV_Config_Version, PRV_Model_Manager_Table,
* PRV_Key_Manager_Renderer.
*
* @see class-prv-settings-page.php -- Instantiates and calls render().
* @see class-prv-model-manager-table.php -- Renders the model table.
* @see class-prv-settings-page.php -- Instantiates and calls render().
* @see class-prv-model-manager-table.php -- Renders the model table.
* @see class-prv-key-manager-renderer.php -- Renders the key card.
* @package PrVision
*/
class PRV_Settings_Renderer {
@@ -40,6 +43,7 @@ class PRV_Settings_Renderer {
( new PRV_Model_Manager_Table() )->render();
echo '</div>';
$this->render_config_form();
( new PRV_Key_Manager_Renderer() )->render();
$this->render_save_bar();
$this->render_scripts();
echo '</div>';
@@ -108,7 +112,7 @@ class PRV_Settings_Renderer {
}
/**
* Render POST-redirect notices (saved, run done, model CRUD, lock).
* Render POST-redirect notices (saved, run done, model CRUD, key actions, lock).
*
* @return void
*/
@@ -118,10 +122,10 @@ class PRV_Settings_Renderer {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Settings saved.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_run_done'] ) ) {
$p = absint( $_GET['prv_probed'] ?? 0 );
$s = absint( $_GET['prv_skipped'] ?? 0 );
$trn = ! empty( $_GET['prv_truncated'] );
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
$p = absint( $_GET['prv_probed'] ?? 0 );
$s = absint( $_GET['prv_skipped'] ?? 0 );
$trn = ! empty( $_GET['prv_truncated'] );
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
echo '<div class="prv-notice prv-notice-success">' . esc_html( $msg ) . '</div>';
}
if ( ! empty( $_GET['prv_run_locked'] ) ) {
@@ -136,11 +140,20 @@ class PRV_Settings_Renderer {
if ( ! empty( $_GET['prv_model_removed'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model removed.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_key_set'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key saved.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_key_removed'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'API key removed.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_key_error'] ) ) {
echo '<div class="prv-notice prv-notice-warning">' . esc_html__( 'Could not save API key — encryption failed. Check server logs.', 'pr-vision' ) . '</div>';
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Render the secondary config form.
* Render the secondary config form (cadence, budget, peptides, intents).
*
* @return void
*/
@@ -154,7 +167,6 @@ class PRV_Settings_Renderer {
$this->render_projected_cost();
$this->render_peptides_field();
$this->render_intents_field();
$this->render_api_key_status();
echo '</div></form>';
}
@@ -233,25 +245,6 @@ class PRV_Settings_Renderer {
echo '<textarea class="prv-input" id="prv_prompt_intents_text" name="prv_prompt_intents_text" rows="4" data-scoring-relevant="1">' . esc_textarea( implode( "\n", $intents ) ) . '</textarea></div>';
}
/**
* Render the read-only API-key three-state status.
*
* @return void
*/
private function render_api_key_status(): void {
$defined = defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== constant( 'PRV_OPENROUTER_API_KEY' );
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
echo '<div class="prv-field"><div class="prv-section-label">' . esc_html__( 'API Key Status (read-only)', 'pr-vision' ) . '</div>';
if ( ! $defined ) {
echo '<span class="prv-api-status prv-api-undef">&#9679; ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . '</span>';
} elseif ( 'failed' === $status ) {
echo '<span class="prv-api-status prv-api-fail">&#9679; ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
} else {
echo '<span class="prv-api-status prv-api-ok">&#9679; ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . '</span>';
}
echo '</div>';
}
/**
* Render the sticky save-bar with scoring-change warning and Run now.
*

View File

@@ -14,21 +14,19 @@ declare(strict_types=1);
* at construction time; the runner instantiates one provider per model from
* PRV_Config::get_models().
*
* GPT-search (openai/gpt-4o-search-preview) and Gemini-search
* (google/gemini-2.0-flash-001) do not return a structured citations array
* via OpenRouter — citations are parsed from URLs embedded in the response
* text or from any 'annotations' key the model may include.
*
* Required WP-config constant:
* PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
* 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.
* Dependencies: PRV_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
* 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.
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
* @package PrVision
*/
class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
@@ -75,14 +73,36 @@ class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
/**
* {@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( string $query ): PRV_Probe_Result {
$api_key = $this->resolve_api_key();
public function probe_with_key( string $query, string $api_key ): PRV_Probe_Result {
$body = array(
'model' => $this->model,
'messages' => array(
@@ -156,22 +176,10 @@ class PRV_OpenRouter_Provider implements PRV_Probe_Provider {
/**
* {@inheritDoc}
*
* True when any source (constant or admin option) provides a non-empty key.
*/
public function is_configured(): bool {
return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
}
/**
* Retrieve the OpenRouter API key from wp-config constants.
*
* @return string
* @throws \RuntimeException When the constant is not defined or empty.
*/
private function resolve_api_key(): string {
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
}
return PRV_OPENROUTER_API_KEY;
return '' !== PRV_Key_Store::get_key();
}
}

View File

@@ -15,16 +15,18 @@ declare(strict_types=1);
* an explicit `citations` array — the most faithful citation signal
* available (real web retrieval vs. training-time knowledge).
*
* Required WP-config constant:
* PRV_OPENROUTER_API_KEY — OpenRouter key (sk-or-…)
* 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_Gateway_Client, PRV_Citation_Detector, PRV_Probe_Result.
* 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.
* @see ARCHITECTURE.md — §Provider implementations, §Key management.
* @see CONTEXT.md — "Perplexity sonar", "citations".
* @package PrVision
*/
@@ -37,7 +39,6 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/**
* Estimated cost per probe call in USD (sonar is ~$0.005 / 1k tokens).
* Used by the ledger's can_afford() pre-check. Actual cost settled post-call.
*/
const ESTIMATED_COST_PER_PROBE = 0.005;
@@ -69,16 +70,19 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/**
* {@inheritDoc}
*
* Sends the query as a user message to perplexity/sonar via OpenRouter.
* Parses the citations[] array from the OpenRouter response envelope.
* 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.
* @throws \RuntimeException On permanent API failure or no key configured.
*/
public function probe( string $query ): PRV_Probe_Result {
$api_key = $this->resolve_api_key();
$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,
@@ -126,7 +130,6 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
// Cost from usage data when available; fall back to estimate.
$cost_usd = self::ESTIMATED_COST_PER_PROBE;
if ( isset( $data['usage']['total_tokens'] ) ) {
// Sonar: ~$1 per 1M input, $1 per 1M output tokens. Avg ~$0.001/1k.
$cost_usd = (float) $data['usage']['total_tokens'] * 0.000001;
}
@@ -142,22 +145,10 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider {
/**
* {@inheritDoc}
*
* True when any source (constant or admin option) provides a non-empty key.
*/
public function is_configured(): bool {
return defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== PRV_OPENROUTER_API_KEY;
}
/**
* Retrieve the OpenRouter API key from wp-config constants.
*
* @return string The API key.
* @throws \RuntimeException When the constant is not defined or empty.
*/
private function resolve_api_key(): string {
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) || '' === PRV_OPENROUTER_API_KEY ) {
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
throw new \RuntimeException( __( 'PRV_OPENROUTER_API_KEY constant is not defined. Add it to wp-config.php.', 'pr-vision' ) );
}
return PRV_OPENROUTER_API_KEY;
return '' !== PRV_Key_Store::get_key();
}
}