diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index cd970eb..1898608 100644 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -69,7 +69,9 @@ class PRAutoBlogger_Cost_Tracker { /** * Log an API call with its token usage and estimated cost. * - * Side effects: database insert into prab_generation_log. + * Side effects: database insert into prab_generation_log. Consumes the + * Request_Recorder stash (v0.20.0 / B1) into the row's request_json so + * every chat call's input is persisted (retention-pruned after R days). * * @param int|null $post_id WordPress post ID (null during generation, set later). * @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map). @@ -117,6 +119,7 @@ class PRAutoBlogger_Cost_Tracker { 'estimated_cost' => $cost, 'response_status' => $response_status, 'error_message' => $error_message, + 'request_json' => PRAutoBlogger_Request_Recorder::consume(), 'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), 'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ), 'created_at' => current_time( 'mysql' ), diff --git a/includes/core/class-request-recorder.php b/includes/core/class-request-recorder.php new file mode 100644 index 0000000..777b40c --- /dev/null +++ b/includes/core/class-request-recorder.php @@ -0,0 +1,73 @@ + $body Request body from Request_Builder::build_body(). + * @return void + */ + public static function record( array $body ): void { + $encoded = wp_json_encode( $body ); + self::$request_json = is_string( $encoded ) ? $encoded : null; + } + + /** + * Return the stashed request body and clear the slot (consume-once). + * + * @return string|null JSON request body, or null when nothing is stashed. + */ + public static function consume(): ?string { + $json = self::$request_json; + self::$request_json = null; + return $json; + } + + /** + * Clear the stash (tests / defensive teardown). + * + * @return void + */ + public static function clear(): void { + self::$request_json = null; + } +} diff --git a/includes/providers/class-open-router-provider.php b/includes/providers/class-open-router-provider.php index 7685cdf..6a489ab 100644 --- a/includes/providers/class-open-router-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -78,6 +78,13 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In // reasoning token cap + completion headroom — lives in the builder. $body = $builder->build_body( $messages, $model, $options ); + // v0.20.0 (B1): stash the outgoing request body so the next + // log_api_call() persists it to generation_log.request_json. + // Only the BODY is recorded -- Authorization lives exclusively in + // build_headers()' return and can never reach the recorder. + // Recorded BEFORE dispatch so error/timeout rows carry it too. + PRAutoBlogger_Request_Recorder::record( $body ); + // Whether the outgoing request has reasoning enabled — drives the // empty-completion retry decision after parse. $reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true ); diff --git a/tests/unit/Core/CostTrackerRequestJsonTest.php b/tests/unit/Core/CostTrackerRequestJsonTest.php new file mode 100644 index 0000000..c37014d --- /dev/null +++ b/tests/unit/Core/CostTrackerRequestJsonTest.php @@ -0,0 +1,181 @@ +insert(). + * + * @var array> + */ + private array $inserted = array(); + + protected function setUp(): void { + parent::setUp(); + $this->inserted = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->insert_id = 7; + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + // Capture generation_log rows only — the Logger also + // inserts (event_log) when e.g. Pricing warns on an + // unknown model; those rows are not under test. + if ( false !== strpos( (string) $table, 'generation_log' ) ) { + $this->inserted[] = (array) $row; + } + return 1; + } + ); + $this->stub_get_option( array() ); + \PRAutoBlogger_Request_Recorder::clear(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Request_Recorder::clear(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * log_api_call() persists the recorder stash into request_json. + */ + public function test_log_api_call_persists_recorded_request_json(): void { + \PRAutoBlogger_Request_Recorder::record( + array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'draft prompt' ) ), + ) + ); + + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'draft', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 100, + 500 + ); + + $this->assertCount( 1, $this->inserted ); + $row = $this->inserted[0]; + $this->assertArrayHasKey( 'request_json', $row ); + $decoded = json_decode( (string) $row['request_json'], true ); + $this->assertSame( 'draft prompt', $decoded['messages'][0]['content'] ); + } + + /** + * Consume-once isolation: a second log row written with no new send + * in between must get NULL, never the previous call's body. + */ + public function test_second_log_row_without_new_record_gets_null(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $tracker->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 ); + $tracker->log_api_call( null, 'polish', 'OpenRouter', 'm1', 10, 10 ); + + $this->assertCount( 2, $this->inserted ); + $this->assertNotNull( $this->inserted[0]['request_json'] ); + $this->assertNull( $this->inserted[1]['request_json'] ); + } + + /** + * Historical behavior preserved: nothing recorded -> request_json NULL. + */ + public function test_log_api_call_without_stash_writes_null(): void { + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'analysis', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 10, + 10 + ); + + $this->assertCount( 1, $this->inserted ); + $this->assertNull( $this->inserted[0]['request_json'] ); + } + + /** + * The persisted payload can never contain credential material — the + * provider records only build_body() output (headers are separate). + * Belt-and-braces audit on the exact column value. + */ + public function test_persisted_request_json_contains_no_auth_header(): void { + $this->stub_get_option( array() ); + $builder = new \PRAutoBlogger_OpenRouter_Request_Builder(); + \PRAutoBlogger_Request_Recorder::record( + $builder->build_body( + array( array( 'role' => 'user', 'content' => 'x' ) ), + 'google/gemini-2.5-flash-lite', + array( 'max_tokens' => 100 ) + ) + ); + + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'draft', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 10, + 10 + ); + + $json = (string) $this->inserted[0]['request_json']; + $this->assertStringNotContainsString( 'Authorization', $json ); + $this->assertStringNotContainsString( 'Bearer', $json ); + $this->assertStringNotContainsString( 'sk-or-', $json ); + } + + /** + * The half-migrated-schema legacy retry (v0.18.0 pattern) keeps + * request_json — the column predates the audit columns (v1.1.0 + * schema) and exists on every site. + */ + public function test_legacy_schema_retry_keeps_request_json(): void { + $this->inserted = array(); + $wpdb = $this->create_mock_wpdb(); + $wpdb->insert_id = 7; + $calls = 0; + $wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) use ( &$calls ) { + if ( false === strpos( (string) $table, 'generation_log' ) ) { + return 1; // Logger/event_log noise — not under test. + } + ++$calls; + $this->inserted[] = (array) $row; + return 1 === $calls ? false : 1; // First insert fails (missing v0.18.0 columns). + } + ); + $GLOBALS['wpdb'] = $wpdb; + + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) ); + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 ); + + $this->assertCount( 2, $this->inserted ); + $retry_row = $this->inserted[1]; + $this->assertArrayNotHasKey( 'agent_role', $retry_row ); + $this->assertArrayNotHasKey( 'prompt_version', $retry_row ); + $this->assertArrayHasKey( 'request_json', $retry_row ); + $this->assertNotNull( $retry_row['request_json'] ); + } +} diff --git a/tests/unit/Core/RequestRecorderTest.php b/tests/unit/Core/RequestRecorderTest.php new file mode 100644 index 0000000..891b805 --- /dev/null +++ b/tests/unit/Core/RequestRecorderTest.php @@ -0,0 +1,120 @@ + 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'hi' ) ), + ) + ); + + $first = \PRAutoBlogger_Request_Recorder::consume(); + $this->assertIsString( $first ); + $decoded = json_decode( $first, true ); + $this->assertSame( 'google/gemini-2.5-flash-lite', $decoded['model'] ); + $this->assertSame( 'hi', $decoded['messages'][0]['content'] ); + + // Consume-once: the slot is cleared by the first read. + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * record() overwrites the previous stash — a stale body from an + * unlogged earlier call can never survive into the next dispatch. + */ + public function test_record_overwrites_previous_stash(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'first/model' ) ); + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'second/model' ) ); + + $decoded = json_decode( (string) \PRAutoBlogger_Request_Recorder::consume(), true ); + $this->assertSame( 'second/model', $decoded['model'] ); + } + + /** + * consume() with nothing recorded returns null (historical rows / + * non-chat log writes keep request_json NULL). + */ + public function test_consume_without_record_returns_null(): void { + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * clear() empties the slot. + */ + public function test_clear_empties_slot(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm' ) ); + \PRAutoBlogger_Request_Recorder::clear(); + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * The full record path for a body built by the real Request_Builder + * from poisoned options can never contain credential material: + * build_body() copies only whitelisted keys and headers are built + * separately, so the recorder's JSON has no Authorization shape. + */ + public function test_recorded_builder_body_never_contains_auth_material(): void { + $this->stub_get_option( array() ); + + $builder = new \PRAutoBlogger_OpenRouter_Request_Builder(); + $body = $builder->build_body( + array( array( 'role' => 'user', 'content' => 'write about BPC-157' ) ), + 'google/gemini-2.5-flash-lite', + array( + 'temperature' => 0.7, + 'max_tokens' => 4000, + // Hostile/accidental injection attempts — must all be dropped. + 'Authorization' => 'Bearer sk-or-secret-key-123', + 'headers' => array( 'Authorization' => 'Bearer sk-or-secret-key-123' ), + 'api_key' => 'sk-or-secret-key-123', + // Caller metadata — never sent upstream, never recorded. + 'stage' => 'draft', + 'prompt_key' => 'content.draft', + 'empty_retry' => true, + ) + ); + \PRAutoBlogger_Request_Recorder::record( $body ); + + $json = (string) \PRAutoBlogger_Request_Recorder::consume(); + $this->assertStringNotContainsString( 'Authorization', $json ); + $this->assertStringNotContainsString( 'Bearer', $json ); + $this->assertStringNotContainsString( 'sk-or-', $json ); + $this->assertStringNotContainsString( 'api_key', $json ); + $this->assertStringNotContainsString( 'empty_retry', $json ); + $this->assertStringNotContainsString( 'prompt_key', $json ); + + $decoded = json_decode( $json, true ); + $this->assertSame( + array( 'model', 'messages', 'temperature', 'max_tokens' ), + array_keys( $decoded ) + ); + } +}