feat(cost): per-run cost governor — reserve-before-call enforcement
Pipeline v2 Phase 1, commit 3/4. Cost_Governor reserves each call's worst-case estimate (prompt chars/4 + max_tokens via the #18 pricing chain) against the run ledger row with one atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch, then settles to actuals after each response; failed calls release their hold. Integrated inside OpenRouter_Provider::send_chat_completion so EVERY text-LLM call path is guarded with zero call-site changes; the image curl_multi batch reserves its summed estimate in Image_Pipeline before generate_image_batch. Breach: run halted (sticky), overage recorded, Cost_Ceiling_Exception aborts the un-dispatched call, process_next_queued_article aborts the remaining queued articles of halted/failed runs, and the Review Queue shows a 'Halted runs' notice (runs halted pre-post have no draft to list). Ceiling = prautoblogger_per_run_cost_ceiling_usd setting (default constant $0.50, snapshotted at run start, 0 = disabled). Standalone no-run calls, the monthly cap, and the AI Gateway path are unchanged. Under-300: provider API-key fetch+validation moved verbatim to OpenRouter_Request_Builder::resolve_api_key(). Agent-Session: pipeline-v2-phase1-engineer-20260612
This commit is contained in:
@@ -11,7 +11,9 @@ declare(strict_types=1);
|
||||
* separate provider integrations.
|
||||
*
|
||||
* Triggered by: Content_Analyzer, Content_Generator, Chief_Editor, Metrics_Collector.
|
||||
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(), PRAutoBlogger_OpenRouter_Pricing.
|
||||
* Dependencies: PRAutoBlogger_Encryption (for API key decryption), wp_remote_post(),
|
||||
* PRAutoBlogger_OpenRouter_Pricing, PRAutoBlogger_Cost_Governor (per-run
|
||||
* reserve-before-call guard around every chat completion).
|
||||
*
|
||||
* @see interface-llm-provider.php — Interface this class implements.
|
||||
* @see class-open-router-config.php — Config helpers (base URL, cache TTL).
|
||||
@@ -51,30 +53,14 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
* }
|
||||
*
|
||||
* @throws \RuntimeException On API error after retries exhausted.
|
||||
* @throws PRAutoBlogger_Cost_Ceiling_Exception When the per-run cost governor blocks
|
||||
* the call (run already halted).
|
||||
*/
|
||||
public function send_chat_completion( array $messages, string $model, array $options = array() ): array {
|
||||
$api_key = $this->get_api_key();
|
||||
if ( '' === $api_key ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate key format — OpenRouter keys start with "sk-or-".
|
||||
// A key that decrypts to garbage (e.g. after a salt change) won't match.
|
||||
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Decrypted API key has unexpected format (prefix="%s", len=%d). Re-enter your key in settings.',
|
||||
substr( $api_key, 0, 6 ),
|
||||
strlen( $api_key )
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter API key appears corrupted (unexpected format). Please re-enter your key in PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
// Fetch + format-validate the key (moved to Request_Builder in
|
||||
// v0.18.0 for the 300-line cap; behavior unchanged — throws the
|
||||
// same messages on missing/corrupted keys).
|
||||
$api_key = ( new PRAutoBlogger_OpenRouter_Request_Builder() )->resolve_api_key();
|
||||
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
@@ -101,6 +87,12 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
);
|
||||
}
|
||||
|
||||
// v0.18.0 — per-run cost governor: reserve the worst-case estimate
|
||||
// BEFORE any dispatch. Throws (and the run is already halted) when
|
||||
// the reservation would breach the run ceiling; returns null when
|
||||
// this process is not inside a governed run (historical behavior).
|
||||
$reservation = PRAutoBlogger_Cost_Governor::open_chat_reservation( $model, $messages, $options );
|
||||
|
||||
$last_error = '';
|
||||
|
||||
$config = new PRAutoBlogger_OpenRouter_Config();
|
||||
@@ -191,8 +183,14 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
);
|
||||
}
|
||||
|
||||
// Success — parse response.
|
||||
return $parser->parse_success( $data, $model );
|
||||
// Success — parse response, settle the reservation to actuals.
|
||||
$parsed = $parser->parse_success( $data, $model );
|
||||
PRAutoBlogger_Cost_Governor::settle(
|
||||
$reservation,
|
||||
$this->estimate_cost( $model, (int) $parsed['prompt_tokens'], (int) $parsed['completion_tokens'] )
|
||||
);
|
||||
$reservation = null;
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
// All retries exhausted.
|
||||
@@ -207,6 +205,9 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
} finally {
|
||||
// Always clean up the cURL filter to avoid leaking into other requests.
|
||||
remove_action( 'http_api_curl', $curl_auth_filter, 99 );
|
||||
// Release any reservation still open (failure/exception path) so
|
||||
// a failed call does not permanently hold its worst-case estimate.
|
||||
PRAutoBlogger_Cost_Governor::release( $reservation );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,17 +276,4 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In
|
||||
public function validate_credentials_detailed(): array {
|
||||
return ( new PRAutoBlogger_OpenRouter_Validator() )->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decrypted API key from options.
|
||||
*
|
||||
* @return string Decrypted API key, or empty string if not set.
|
||||
*/
|
||||
private function get_api_key(): string {
|
||||
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||
if ( '' === $encrypted ) {
|
||||
return '';
|
||||
}
|
||||
return PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,43 @@ class PRAutoBlogger_OpenRouter_Request_Builder {
|
||||
add_action( 'http_api_curl', $curl_auth_filter, 99, 3 );
|
||||
return $curl_auth_filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, decrypt, and format-validate the OpenRouter API key.
|
||||
*
|
||||
* Moved from OpenRouter_Provider in v0.18.0 (300-line cap); the
|
||||
* messages and behavior are unchanged.
|
||||
*
|
||||
* @return string Validated plaintext API key.
|
||||
* @throws \RuntimeException When the key is missing or has an
|
||||
* unexpected format (corrupted decryption).
|
||||
*/
|
||||
public function resolve_api_key(): string {
|
||||
$encrypted = get_option( 'prautoblogger_openrouter_api_key', '' );
|
||||
$api_key = '' === $encrypted ? '' : PRAutoBlogger_Encryption::decrypt( $encrypted );
|
||||
|
||||
if ( '' === $api_key ) {
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
// Validate key format — OpenRouter keys start with "sk-or-".
|
||||
// A key that decrypts to garbage (e.g. after a salt change) won't match.
|
||||
if ( 0 !== strpos( $api_key, 'sk-or-' ) ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Decrypted API key has unexpected format (prefix="%s", len=%d). Re-enter your key in settings.',
|
||||
substr( $api_key, 0, 6 ),
|
||||
strlen( $api_key )
|
||||
),
|
||||
'openrouter'
|
||||
);
|
||||
throw new \RuntimeException(
|
||||
__( 'OpenRouter API key appears corrupted (unexpected format). Please re-enter your key in PRAutoBlogger → Settings.', 'prautoblogger' )
|
||||
);
|
||||
}
|
||||
|
||||
return $api_key;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user