$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 ); } }