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:
@@ -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) |
|
||||
|
||||
15
CHANGELOG.md
15
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
|
||||
|
||||
13
includes/admin/class-review-queue.php
Executable file → Normal file
13
includes/admin/class-review-queue.php
Executable file → Normal file
@@ -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<int, array<string, mixed>> Halted run rows (max 10).
|
||||
*/
|
||||
public function get_halted_runs(): array {
|
||||
return PRAutoBlogger_Run_State::halted_runs( 10 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Query generated draft posts awaiting review.
|
||||
*
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
includes/core/class-cost-ceiling-exception.php
Normal file
23
includes/core/class-cost-ceiling-exception.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Thrown when a reserve-before-call would push a run past its cost ceiling.
|
||||
*
|
||||
* What: Dedicated exception type so call paths can distinguish "this run
|
||||
* was halted by the per-run cost governor" from generic API
|
||||
* failures. By the time it is thrown the governor has already
|
||||
* marked the run `halted`, recorded the overage on the runs row,
|
||||
* and logged — catchers must NOT dispatch the call they were about
|
||||
* to make, and chained jobs abort via the run-status check in
|
||||
* Pipeline_Runner::process_next_queued_article().
|
||||
* Who triggers it: PRAutoBlogger_Cost_Governor on a failed reservation.
|
||||
* Dependencies: none (extends RuntimeException).
|
||||
*
|
||||
* @see core/class-cost-governor.php — Sole thrower.
|
||||
* @see core/class-pipeline-runner.php — Aborts queued articles of halted runs.
|
||||
*/
|
||||
class PRAutoBlogger_Cost_Ceiling_Exception extends \RuntimeException {
|
||||
}
|
||||
239
includes/core/class-cost-governor.php
Normal file
239
includes/core/class-cost-governor.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Per-run cost governor — reserve-before-call against the run ledger.
|
||||
*
|
||||
* What: Net-new enforcement (the monthly Cost_Tracker cap only bounds the
|
||||
* month; `get_current_run_cost()` was an unguarded accumulator).
|
||||
* Before any governed call dispatches, its worst-case cost estimate
|
||||
* is atomically added to the run's `reserved_usd` via a single
|
||||
* conditional UPDATE — the same atomic-conditional-write discipline
|
||||
* as PRAutoBlogger_Generation_Lock (#10) — so concurrent writers
|
||||
* (curl_multi fan-outs, chained jobs) cannot slip past the ceiling
|
||||
* between check and call. Batches reserve their SUMMED estimate
|
||||
* before dispatch. After each response the reservation settles to
|
||||
* the actual cost, so a cheap call never holds its worst case.
|
||||
* Breach: the run is marked `halted` (sticky), the overage is
|
||||
* recorded on the runs row, the Review Queue surfaces it, and a
|
||||
* Cost_Ceiling_Exception aborts the un-dispatched call. Standalone
|
||||
* calls outside any run (no run context) are not run-governed —
|
||||
* exactly the historical behavior; the monthly cap and the
|
||||
* Cloudflare AI Gateway path are untouched everywhere.
|
||||
* Who triggers it: PRAutoBlogger_OpenRouter_Provider::send_chat_completion
|
||||
* (every text-LLM call path), PRAutoBlogger_Image_Pipeline (image
|
||||
* curl_multi batch).
|
||||
* Dependencies: Run_Context (which run is this process in),
|
||||
* Run_State (ledger row + halt), OpenRouter_Pricing (#18 chain),
|
||||
* WordPress $wpdb, Logger.
|
||||
*
|
||||
* @see class-generation-lock.php — The atomic-write pattern this mirrors.
|
||||
* @see core/class-run-state.php — The ledger row reserved against.
|
||||
* @see providers/class-open-router-provider.php — Text-call integration point.
|
||||
* @see core/class-image-pipeline.php — Batch reservation integration point.
|
||||
* @see ARCHITECTURE.md #21 — Design rationale.
|
||||
*/
|
||||
class PRAutoBlogger_Cost_Governor {
|
||||
|
||||
/**
|
||||
* Rough chars-per-token divisor for prompt-size estimation. An internal
|
||||
* estimator detail (NOT a behavior setting — the enforced ceiling is
|
||||
* the prautoblogger_per_run_cost_ceiling_usd SETTING).
|
||||
*/
|
||||
private const CHARS_PER_TOKEN = 4;
|
||||
|
||||
/**
|
||||
* Worst-case completion tokens assumed when a caller sets no
|
||||
* max_tokens. Every current call site sets one; this is belt-and-braces.
|
||||
*/
|
||||
private const DEFAULT_MAX_COMPLETION_TOKENS = 4096;
|
||||
|
||||
/**
|
||||
* Reserve the worst-case cost of one chat-completion call for the
|
||||
* process's current run. Call before dispatch.
|
||||
*
|
||||
* Returns null (call proceeds ungoverned, exactly as pre-v0.18.0) when
|
||||
* this process is not inside a run, the run row/ledger is unavailable
|
||||
* (half-migrated schema), or the run's ceiling is 0/disabled.
|
||||
*
|
||||
* @param string $model Model identifier.
|
||||
* @param array<int, array{role: string, content: string}> $messages Chat messages (prompt-size estimate).
|
||||
* @param array<string, mixed> $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:<model>', '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<int, array{role: string, content: string}> $messages Chat messages.
|
||||
* @param array<string, mixed> $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 );
|
||||
}
|
||||
}
|
||||
@@ -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 ) )
|
||||
|
||||
12
includes/core/class-pipeline-runner.php
Executable file → Normal file
12
includes/core/class-pipeline-runner.php
Executable file → Normal file
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
56
templates/admin/review-queue.php
Executable file → Normal file
56
templates/admin/review-queue.php
Executable file → Normal file
@@ -18,6 +18,37 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
<div class="wrap prautoblogger-review-queue">
|
||||
<h1><?php esc_html_e( 'PRAutoBlogger — Review Queue', 'prautoblogger' ); ?></h1>
|
||||
|
||||
<?php if ( ! empty( $halted_runs ) ) : ?>
|
||||
<div class="notice notice-error">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Runs halted by the per-run cost ceiling', 'prautoblogger' ); ?></strong>
|
||||
<?php esc_html_e( '— these runs were stopped before exceeding their budget and need human review. Raise the ceiling in Settings or discard the run.', 'prautoblogger' ); ?>
|
||||
</p>
|
||||
<table class="widefat striped" style="margin-bottom: 8px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></th>
|
||||
<th><?php esc_html_e( 'Halted at', 'prautoblogger' ); ?></th>
|
||||
<th><?php esc_html_e( 'Spent + held (USD)', 'prautoblogger' ); ?></th>
|
||||
<th><?php esc_html_e( 'Ceiling (USD)', 'prautoblogger' ); ?></th>
|
||||
<th><?php esc_html_e( 'Blocked overage (USD)', 'prautoblogger' ); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ( $halted_runs as $halted_run ) : ?>
|
||||
<tr>
|
||||
<td><code><?php echo esc_html( (string) $halted_run['run_id'] ); ?></code></td>
|
||||
<td><?php echo esc_html( (string) ( $halted_run['finished_at'] ?? $halted_run['updated_at'] ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format( (float) $halted_run['settled_usd'] + (float) $halted_run['reserved_usd'], 4 ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format( (float) $halted_run['ceiling_usd'], 2 ) ); ?></td>
|
||||
<td><?php echo esc_html( number_format( (float) $halted_run['overage_usd'], 4 ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $bulk_done > 0 && '' !== $bulk_type ) : ?>
|
||||
<div class="notice notice-success is-dismissible">
|
||||
<p>
|
||||
@@ -86,7 +117,10 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
|
||||
<?php
|
||||
while ( $query->have_posts() ) :
|
||||
$query->the_post();
|
||||
?>
|
||||
<?php
|
||||
$post_id = get_the_ID();
|
||||
$verdict = get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
|
||||
@@ -139,14 +173,18 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
if ( $total_pages > 1 ) :
|
||||
$current_page = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||
echo '<div class="prautoblogger-pagination">';
|
||||
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 '</div>';
|
||||
endif;
|
||||
?>
|
||||
|
||||
221
tests/unit/Core/CostGovernorTest.php
Normal file
221
tests/unit/Core/CostGovernorTest.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cost_Governor.
|
||||
*
|
||||
* Locks the reserve-before-call contract: estimates use prompt-size +
|
||||
* max_tokens against the pricing chain; reservations are atomic
|
||||
* conditional UPDATEs; standalone (no-run) calls are ungoverned; a breach
|
||||
* halts the run and throws Cost_Ceiling_Exception; settle releases the
|
||||
* hold and books actuals.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class CostGovernorTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Captured SQL passed through $wpdb->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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user