From 04f09f5f2422a10b67e33178496ed09b8a6437a2 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 11 Jun 2026 04:20:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(cost):=20per-run=20cost=20governor=20?= =?UTF-8?q?=E2=80=94=20reserve-before-call=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE.md | 3 + CHANGELOG.md | 15 ++ includes/admin/class-review-queue.php | 13 + .../admin/class-settings-fields-extended.php | 10 + includes/class-activator.php | 1 + .../core/class-cost-ceiling-exception.php | 23 ++ includes/core/class-cost-governor.php | 239 ++++++++++++++++++ includes/core/class-image-pipeline.php | 18 ++ includes/core/class-pipeline-runner.php | 12 + .../providers/class-open-router-provider.php | 64 ++--- .../class-open-router-request-builder.php | 39 +++ templates/admin/review-queue.php | 56 +++- tests/unit/Core/CostGovernorTest.php | 221 ++++++++++++++++ 13 files changed, 667 insertions(+), 47 deletions(-) mode change 100755 => 100644 includes/admin/class-review-queue.php create mode 100644 includes/core/class-cost-ceiling-exception.php create mode 100644 includes/core/class-cost-governor.php mode change 100755 => 100644 includes/core/class-pipeline-runner.php mode change 100755 => 100644 templates/admin/review-queue.php create mode 100644 tests/unit/Core/CostGovernorTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 824f026..095390a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -180,6 +180,8 @@ prautoblogger/ │ │ ├── class-prompt-defaults-editorial.php # Canonical v1 bodies: editor.* / research.system / image.* (composer seam) │ │ ├── class-run-context.php # Per-process active run id (set by Cost_Tracker::set_run_id) │ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status) +│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE) +│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted) │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) │ │ ├── class-article-worker.php # Single-article generation (content + edit + publish) │ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs @@ -560,6 +562,7 @@ All prefixed with `prautoblogger_`: | `prautoblogger_niche_description` | Text description of the site's niche | | `prautoblogger_target_subreddits` | JSON array of subreddits to monitor | | `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD | +| `prautoblogger_per_run_cost_ceiling_usd` | v0.18.0 — per-run hard cost ceiling (USD); reserve-before-call enforced; snapshotted onto the runs row at run start; 0 = disabled. Default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` ($0.50) | | `prautoblogger_tone` | Content tone (informational, conversational, etc.) | | `prautoblogger_min_word_count` | Minimum article word count (default: 800) | | `prautoblogger_max_word_count` | Maximum article word count (default: 2000) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1939072..e559c35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,12 +43,27 @@ behavior change** to the Economy (single-pass) and multi-step publish paths. sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the Opik trace-context pattern). +- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously + only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case + estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger + with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the + image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle + to actuals after each response (a failed call releases its hold). Breach → run `halted` + (sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued + articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue. + New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`, + default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start, + 0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway + path behave exactly as before. + ### Changed - `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines. No behavior change: same prompts, models, params, span names, stage values. - `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only reporting query); the Cost_Tracker method remains as a delegating wrapper. +- `OpenRouter_Provider`'s API-key fetch + format validation moved to + `OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior. ### Fixed - ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always diff --git a/includes/admin/class-review-queue.php b/includes/admin/class-review-queue.php old mode 100755 new mode 100644 index 58a4785..e2e3b0b --- a/includes/admin/class-review-queue.php +++ b/includes/admin/class-review-queue.php @@ -51,9 +51,22 @@ class PRAutoBlogger_Review_Queue { $paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1; $query = $this->get_pending_posts( $paged ); + // v0.18.0 — runs halted by the per-run cost governor are routed + // here for human attention (they may have no draft post to list). + $halted_runs = $this->get_halted_runs(); + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/review-queue.php'; } + /** + * Runs halted by the per-run cost governor, newest first. + * + * @return array> Halted run rows (max 10). + */ + public function get_halted_runs(): array { + return PRAutoBlogger_Run_State::halted_runs( 10 ); + } + /** * Query generated draft posts awaiting review. * diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 805687e..ab0e5c4 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -113,6 +113,16 @@ class PRAutoBlogger_Settings_Fields_Extended { 'step' => '0.01', 'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ), ), + array( + 'id' => 'prautoblogger_per_run_cost_ceiling_usd', + 'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_schedule', + 'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD, + 'min' => 0, + 'step' => '0.01', + 'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ), + ), // ── Publishing ────────────────────────────────────────────── array( diff --git a/includes/class-activator.php b/includes/class-activator.php index ba2dce5..f42cb91 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -68,6 +68,7 @@ class PRAutoBlogger_Activator { 'prautoblogger_niche_description' => '', 'prautoblogger_target_subreddits' => '[]', 'prautoblogger_monthly_budget_usd' => 50.00, + 'prautoblogger_per_run_cost_ceiling_usd' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD, 'prautoblogger_tone' => 'informational', 'prautoblogger_min_word_count' => 800, 'prautoblogger_max_word_count' => 2000, diff --git a/includes/core/class-cost-ceiling-exception.php b/includes/core/class-cost-ceiling-exception.php new file mode 100644 index 0000000..4235c97 --- /dev/null +++ b/includes/core/class-cost-ceiling-exception.php @@ -0,0 +1,23 @@ + $messages Chat messages (prompt-size estimate). + * @param array $options Call options (max_tokens). + * @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned. + * @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling + * (the run is already halted when this throws). + */ + public static function open_chat_reservation( string $model, array $messages, array $options ): ?array { + return self::open_amount_reservation( + self::estimate_chat_cost( $model, $messages, $options ), + 'llm:' . $model + ); + } + + /** + * Reserve a pre-computed worst-case amount (e.g. a curl_multi image + * batch's summed estimate) for the process's current run. + * + * @param float $amount_usd Worst-case cost estimate in USD. + * @param string $context Short label for logs ('llm:', 'image_batch'). + * @return array{run_id: string, amount: float}|null Open reservation, or null when ungoverned. + * @throws PRAutoBlogger_Cost_Ceiling_Exception When the reservation would breach the ceiling. + */ + public static function open_amount_reservation( float $amount_usd, string $context ): ?array { + $run_id = PRAutoBlogger_Run_Context::current_run_id(); + if ( null === $run_id || '' === $run_id || $amount_usd <= 0 ) { + return null; + } + if ( ! PRAutoBlogger_Run_State::is_available() ) { + return null; // Half-migrated schema — degrade to historical behavior. + } + + PRAutoBlogger_Run_State::ensure_run( $run_id ); + $run = PRAutoBlogger_Run_State::get_run( $run_id ); + if ( null === $run ) { + return null; + } + if ( (float) $run['ceiling_usd'] <= 0 ) { + return null; // Ceiling disabled for this run. + } + + if ( self::reserve( $run_id, $amount_usd ) ) { + return array( + 'run_id' => $run_id, + 'amount' => $amount_usd, + ); + } + + self::on_breach( $run_id, $amount_usd, $context ); + return null; // Unreachable — on_breach always throws. + } + + /** + * Settle an open reservation to the call's actual cost: the hold is + * released and the actual amount moves to settled_usd. + * + * @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op). + * @param float $actual_usd Actual cost of the call. + * @return void + */ + public static function settle( ?array $reservation, float $actual_usd ): void { + if ( null === $reservation || ! PRAutoBlogger_Run_State::is_available() ) { + return; + } + global $wpdb; + $table = PRAutoBlogger_Run_State::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} + SET reserved_usd = GREATEST(reserved_usd - %f, 0), + settled_usd = settled_usd + %f, + updated_at = %s + WHERE run_id = %s", + $reservation['amount'], + max( $actual_usd, 0 ), + current_time( 'mysql' ), + $reservation['run_id'] + ) + ); + } + + /** + * Release an open reservation without settling cost (failed call). + * + * @param array{run_id: string, amount: float}|null $reservation Reservation from open_*(), or null (no-op). + * @return void + */ + public static function release( ?array $reservation ): void { + self::settle( $reservation, 0.0 ); + } + + /** + * Worst-case cost estimate for a chat call: estimated prompt tokens + * (message chars / 4) plus the call's max_tokens completion budget, + * priced via the #18 pricing chain (hardcoded table -> cached model + * registry -> conservative fallback). + * + * @param string $model Model identifier. + * @param array $messages Chat messages. + * @param array $options Call options. + * @return float Estimated worst-case USD cost. + */ + public static function estimate_chat_cost( string $model, array $messages, array $options ): float { + $chars = 0; + foreach ( $messages as $message ) { + $chars += strlen( (string) ( $message['content'] ?? '' ) ); + } + $prompt_tokens = (int) ceil( $chars / self::CHARS_PER_TOKEN ); + $completion_tokens = (int) ( $options['max_tokens'] ?? self::DEFAULT_MAX_COMPLETION_TOKENS ); + + return ( new PRAutoBlogger_OpenRouter_Pricing() )->estimate_cost( $model, $prompt_tokens, $completion_tokens ); + } + + /** + * Atomic conditional reservation — the #10 discipline. One UPDATE + * whose WHERE clause enforces `settled + reserved + estimate <= + * ceiling` AND that the run is still open; affected-rows tells us who + * won. Concurrent reservers serialize on the row lock. + * + * @param string $run_id Run UUID. + * @param float $amount Estimate to reserve. + * @return bool True when the reservation was written. + */ + private static function reserve( string $run_id, float $amount ): bool { + global $wpdb; + $table = PRAutoBlogger_Run_State::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} + SET reserved_usd = reserved_usd + %f, updated_at = %s + WHERE run_id = %s + AND status IN ('pending','running') + AND (reserved_usd + settled_usd + %f) <= ceiling_usd", + $amount, + current_time( 'mysql' ), + $run_id, + $amount + ) + ); + return 1 === (int) $updated; + } + + /** + * Handle a failed reservation: halt the run (sticky), record the + * overage, log, throw. The Review Queue lists halted runs; chained + * queue jobs abort via the run-status check. + * + * @param string $run_id Run UUID. + * @param float $amount The estimate that failed to reserve. + * @param string $context Short label for logs. + * @return void + * @throws PRAutoBlogger_Cost_Ceiling_Exception Always. + */ + private static function on_breach( string $run_id, float $amount, string $context ): void { + $run = PRAutoBlogger_Run_State::get_run( $run_id ); + $ceiling = null !== $run ? (float) $run['ceiling_usd'] : 0.0; + $held = null !== $run ? (float) $run['reserved_usd'] + (float) $run['settled_usd'] : 0.0; + $overage = max( ( $held + $amount ) - $ceiling, 0.0 ); + + PRAutoBlogger_Run_State::mark_status( $run_id, 'halted' ); + PRAutoBlogger_Run_State::record_overage( $run_id, $overage ); + + $message = sprintf( + /* translators: 1: blocked amount USD, 2: call context, 3: committed USD, 4: ceiling USD, 5: run id. */ + __( 'Per-run cost ceiling reached: reserving $%1$.4f for %2$s would push the run to $%3$.4f against a $%4$.2f ceiling. Run %5$s halted and routed to the Review Queue.', 'prautoblogger' ), + $amount, + $context, + $held + $amount, + $ceiling, + $run_id + ); + + PRAutoBlogger_Logger::instance()->error( $message, 'cost-governor' ); + + throw new PRAutoBlogger_Cost_Ceiling_Exception( $message ); + } +} diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index e0e528b..186c4dc 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -125,6 +125,16 @@ class PRAutoBlogger_Image_Pipeline { return $result; } + // v0.18.0 — per-run governor: reserve the curl_multi batch's SUMMED + // worst-case estimate before dispatch (no-op outside a governed + // run). A breach halts the run and skips the whole batch. + try { + $batch_reservation = PRAutoBlogger_Cost_Governor::open_amount_reservation( $estimated_cost, 'image_batch' ); + } catch ( PRAutoBlogger_Cost_Ceiling_Exception $e ) { + $result['errors'][] = $e->getMessage(); + return $result; + } + // Build all prompts upfront (LLM calls happen here, sequentially). $article_prompt = $this->prompt_builder->build_article_prompt( $article_data ); $batch_requests = array( @@ -151,11 +161,19 @@ class PRAutoBlogger_Image_Pipeline { try { $batch_results = $this->provider->generate_image_batch( $batch_requests ); } catch ( \Throwable $e ) { + PRAutoBlogger_Cost_Governor::release( $batch_reservation ); PRAutoBlogger_Logger::instance()->error( 'Batch generation failed: ' . $e->getMessage(), 'image_pipeline' ); $result['errors'][] = $e->getMessage(); return $result; } + // Settle the batch reservation to the actual per-image costs. + $actual_batch_cost = 0.0; + foreach ( $batch_results as $batch_entry ) { + $actual_batch_cost += (float) ( $batch_entry['cost_usd'] ?? 0 ); + } + PRAutoBlogger_Cost_Governor::settle( $batch_reservation, $actual_batch_cost ); + // Single-shot retry for any slot the provider flagged as NSFW-blocked. if ( PRAutoBlogger_Image_NSFW_Retry::is_enabled() ) { ( new PRAutoBlogger_Image_NSFW_Retry( $this->provider, $this->prompt_builder ) ) diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php old mode 100755 new mode 100644 index 436c242..736a4b8 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -119,6 +119,18 @@ class PRAutoBlogger_Pipeline_Runner { return; } + // v0.18.0 — a run halted by the per-run cost governor (or failed by + // the reaper) must not dispatch its remaining queued articles. + $run_status = PRAutoBlogger_Run_State::get_status( $queue['run_id'] ); + if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Run %s is %s. Aborting remaining queued articles.', $queue['run_id'], $run_status ), + 'pipeline' + ); + $this->finish_queue(); + return; + } + // Persist the consumed queue BEFORE generating so the status poller // cannot re-schedule a cron event for the same idea. Without this, // the DB still holds the old queue during the ~90s generation window, diff --git a/includes/providers/class-open-router-provider.php b/includes/providers/class-open-router-provider.php index 217fb3c..ee216c9 100644 --- a/includes/providers/class-open-router-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -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 ); - } } diff --git a/includes/providers/class-open-router-request-builder.php b/includes/providers/class-open-router-request-builder.php index 72e3203..8bda525 100644 --- a/includes/providers/class-open-router-request-builder.php +++ b/includes/providers/class-open-router-request-builder.php @@ -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; + } } diff --git a/templates/admin/review-queue.php b/templates/admin/review-queue.php old mode 100755 new mode 100644 index 04b31be..3d2cd95 --- a/templates/admin/review-queue.php +++ b/templates/admin/review-queue.php @@ -18,6 +18,37 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp

+ +
+

+ + +

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + 0 && '' !== $bulk_type ) : ?>

@@ -86,7 +117,10 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp - have_posts() ) : $query->the_post(); ?> + have_posts() ) : + $query->the_post(); + ?> 1 ) : $current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1; echo '

'; - echo wp_kses_post( paginate_links( [ - 'base' => add_query_arg( 'paged', '%#%' ), - 'format' => '', - 'current' => $current_page, - 'total' => $total_pages, - 'prev_text' => '«', - 'next_text' => '»', - ] ) ); + echo wp_kses_post( + paginate_links( + array( + 'base' => add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $current_page, + 'total' => $total_pages, + 'prev_text' => '«', + 'next_text' => '»', + ) + ) + ); echo '
'; endif; ?> diff --git a/tests/unit/Core/CostGovernorTest.php b/tests/unit/Core/CostGovernorTest.php new file mode 100644 index 0000000..9dd5dcf --- /dev/null +++ b/tests/unit/Core/CostGovernorTest.php @@ -0,0 +1,221 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + + // prepare() passes SQL through with args appended for inspection. + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + + \PRAutoBlogger_Run_Context::clear(); + \PRAutoBlogger_Run_State::flush_cache(); + + $this->stub_get_option( + array( + 'prautoblogger_per_run_cost_ceiling_usd' => 0.50, + 'prautoblogger_log_level' => 'error', + ) + ); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_Context::clear(); + \PRAutoBlogger_Run_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Outside any run, the governor must not govern (historical behavior). + */ + public function test_no_run_context_means_ungoverned(): void { + $reservation = \PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' ); + $this->assertNull( $reservation ); + } + + /** + * Estimate = (chars/4 prompt tokens + max_tokens completion) priced by + * the pricing chain. With an unknown model the chain falls back to its + * conservative default pricing, which is still a positive number. + */ + public function test_estimate_chat_cost_is_positive_and_scales_with_max_tokens(): void { + $messages = array( + array( + 'role' => 'user', + 'content' => str_repeat( 'a', 400 ), // 100 estimated prompt tokens. + ), + ); + $small = \PRAutoBlogger_Cost_Governor::estimate_chat_cost( + 'unknown/model-x', + $messages, + array( 'max_tokens' => 100 ) + ); + $large = \PRAutoBlogger_Cost_Governor::estimate_chat_cost( + 'unknown/model-x', + $messages, + array( 'max_tokens' => 4000 ) + ); + $this->assertGreaterThan( 0.0, $small ); + $this->assertGreaterThan( $small, $large ); + } + + /** + * A winning conditional UPDATE (affected rows = 1) opens a reservation + * carrying the run id and amount. + */ + public function test_successful_reservation(): void { + \PRAutoBlogger_Run_Context::set_run_id( 'run-ok' ); + + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'get_row' )->willReturn( + array( + 'run_id' => 'run-ok', + 'status' => 'running', + 'ceiling_usd' => '0.50', + 'reserved_usd' => '0.00', + 'settled_usd' => '0.00', + 'overage_usd' => '0.00', + ) + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + $reservation = \PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' ); + + $this->assertIsArray( $reservation ); + $this->assertSame( 'run-ok', $reservation['run_id'] ); + $this->assertSame( 0.10, $reservation['amount'] ); + + $reserve_sql = implode( "\n", $this->queries ); + $this->assertStringContainsString( 'reserved_usd + settled_usd', $reserve_sql ); + $this->assertStringContainsString( "status IN ('pending','running')", $reserve_sql ); + } + + /** + * A losing conditional UPDATE (affected rows = 0) halts the run, + * records the overage, and throws Cost_Ceiling_Exception. + */ + public function test_breach_halts_run_and_throws(): void { + \PRAutoBlogger_Run_Context::set_run_id( 'run-over' ); + + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'get_row' )->willReturn( + array( + 'run_id' => 'run-over', + 'status' => 'running', + 'ceiling_usd' => '0.50', + 'reserved_usd' => '0.10', + 'settled_usd' => '0.38', + 'overage_usd' => '0.00', + ) + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + // The reserve UPDATE loses; status/overage UPDATEs win. + return false !== strpos( (string) $sql, 'reserved_usd + ' ) ? 0 : 1; + } + ); + $this->wpdb->method( 'insert' )->willReturn( 1 ); // Logger event row. + + try { + \PRAutoBlogger_Cost_Governor::open_amount_reservation( 0.10, 'test' ); + $this->fail( 'Expected PRAutoBlogger_Cost_Ceiling_Exception.' ); + } catch ( \PRAutoBlogger_Cost_Ceiling_Exception $e ) { + $this->assertStringContainsString( 'run-over', $e->getMessage() ); + } + + $all_sql = implode( "\n", $this->queries ); + $this->assertStringContainsString( "status = %s", $all_sql ); + $this->assertStringContainsString( 'halted', $all_sql ); + $this->assertStringContainsString( 'overage_usd', $all_sql ); + } + + /** + * Ceiling 0 on the run row disables per-run governance for that run. + */ + public function test_zero_ceiling_is_ungoverned(): void { + \PRAutoBlogger_Run_Context::set_run_id( 'run-free' ); + + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'get_row' )->willReturn( + array( + 'run_id' => 'run-free', + 'status' => 'running', + 'ceiling_usd' => '0', + 'reserved_usd' => '0.00', + 'settled_usd' => '0.00', + 'overage_usd' => '0.00', + ) + ); + $this->wpdb->method( 'query' )->willReturn( 1 ); + + $this->assertNull( \PRAutoBlogger_Cost_Governor::open_amount_reservation( 99.0, 'test' ) ); + } + + /** + * Settle writes the release + actuals UPDATE; null reservations no-op. + */ + public function test_settle_and_release(): void { + \PRAutoBlogger_Cost_Governor::settle( null, 1.0 ); // Must not touch $wpdb. + + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + \PRAutoBlogger_Cost_Governor::settle( + array( + 'run_id' => 'run-ok', + 'amount' => 0.10, + ), + 0.002 + ); + + $sql = implode( "\n", $this->queries ); + $this->assertStringContainsString( 'GREATEST(reserved_usd - %f, 0)', $sql ); + $this->assertStringContainsString( 'settled_usd = settled_usd + %f', $sql ); + } +}