From e6e3f8554208dd178b062cc4719779efaf067f01 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 11 Jun 2026 05:31:57 +0000 Subject: [PATCH] fix(cost): resolve snapshot-suffixed model IDs to base pricing before conservative estimate OpenRouter sometimes returns a dated snapshot ID as the response model (request deepseek/deepseek-v4-flash -> response model deepseek/deepseek-v4-flash-20260423). The pricing chain missed the registry on those IDs and booked the conservative $10/$30 per-M estimate: today's prod single-pass run logged $0.582 against a true flash-tier cost of roughly 1-2 cents. Inflated actuals eat the Cost_Governor per-run ceiling (default $0.50; today's run would have halted) and the monthly $5 budget gate (~9 days to self-block). Fix at the single pricing seam (OpenRouter_Pricing::estimate_cost): on a lookup miss, strip a trailing 8-digit 20-prefixed -YYYYMMDD snapshot suffix and retry the hardcoded table + cached models list once with the base ID. The conservative fallback is unchanged when the base ID is also unknown. Governor reserve/settle and generation-log booking all price through this seam, so every path survives snapshot IDs. Tests: snapshot ID resolves to the base price when only the base ID is registered; an unknown ID without a suffix still gets the conservative estimate; a non-date trailing digit run (foo-202612345) is never stripped. Agent-Session: pipeline-v2-phase1-pricing-fix-20260611 --- CHANGELOG.md | 6 ++ .../providers/class-open-router-pricing.php | 93 ++++++++++++++++--- .../unit/Providers/OpenRouterPricingTest.php | 85 +++++++++++++++++ 3 files changed, 171 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a50ebb1..b36119f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,12 @@ behavior change** to the Economy (single-pass) and multi-step publish paths. columns when the columns are missing (a cron run right after deploy no longer loses cost rows), and the db-version self-heal now also runs on cron requests (`init` + `wp_doing_cron()`), not only on `admin_init`. +- OpenRouter pricing now resolves dated snapshot model IDs returned by the API + (e.g. response model `deepseek/deepseek-v4-flash-20260423` for a + `deepseek/deepseek-v4-flash` request) to the base model's price before the + conservative-estimate fallback applies. Such responses were previously booked + at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) — + phantom spend that ate the per-run cost ceiling and the monthly budget gate. ## [0.17.0] - 2026-06-11 diff --git a/includes/providers/class-open-router-pricing.php b/includes/providers/class-open-router-pricing.php index 664a7b0..8718e65 100644 --- a/includes/providers/class-open-router-pricing.php +++ b/includes/providers/class-open-router-pricing.php @@ -132,7 +132,9 @@ class PRAutoBlogger_OpenRouter_Pricing { /** * Estimate the cost of an API call in USD. * - * Uses hardcoded pricing first, falls back to cached model data. + * Uses hardcoded pricing first, falls back to cached model data. IDs + * carrying a date-snapshot suffix that miss both sources are retried + * once as their base ID (see resolve_pricing()). * * @param string $model Model identifier. * @param int $prompt_tokens Input tokens. @@ -146,18 +148,7 @@ class PRAutoBlogger_OpenRouter_Pricing { return 0.0; } - $pricing = self::MODEL_PRICING[ $model ] ?? null; - - // Fall back to cached model list pricing. - if ( null === $pricing ) { - $models = $this->get_available_models(); - foreach ( $models as $m ) { - if ( $m['id'] === $model && isset( $m['pricing'] ) ) { - $pricing = $m['pricing']; - break; - } - } - } + $pricing = $this->resolve_pricing( $model ); if ( null === $pricing ) { // Unknown model — use a conservative estimate. @@ -172,6 +163,82 @@ class PRAutoBlogger_OpenRouter_Pricing { + ( $completion_tokens * $pricing['completion'] / 1000000 ); } + /** + * Resolve pricing for a model ID, normalizing dated snapshot IDs. + * + * Tries the exact ID first. On a miss, strips a trailing date-snapshot + * suffix and retries once with the base ID: OpenRouter responses can + * name a dated snapshot of the requested model (e.g. request + * 'deepseek/deepseek-v4-flash', response model + * 'deepseek/deepseek-v4-flash-20260423') while pricing sources only + * list the base ID. Every cost path (governor reserve/settle and + * generation-log booking) prices through this seam, so all of them + * survive snapshot IDs instead of booking the conservative estimate. + * + * Side effects: may fetch the models list over HTTP on cache miss + * (via get_available_models()). + * + * @param string $model Model ID as requested, or as returned by the API. + * + * @return array{prompt: float, completion: float}|null Per-million USD pricing, or null when unknown. + */ + private function resolve_pricing( string $model ): ?array { + $pricing = $this->lookup_pricing( $model ); + if ( null !== $pricing ) { + return $pricing; + } + + $base = $this->strip_snapshot_suffix( $model ); + if ( $base !== $model ) { + return $this->lookup_pricing( $base ); + } + + return null; + } + + /** + * Look up one exact model ID: hardcoded table first, then cached model list. + * + * Side effects: may fetch the models list over HTTP on cache miss + * (via get_available_models()). + * + * @param string $model Model ID (exact match, no normalization). + * + * @return array{prompt: float, completion: float}|null Per-million USD pricing, or null on miss. + */ + private function lookup_pricing( string $model ): ?array { + $pricing = self::MODEL_PRICING[ $model ] ?? null; + if ( null !== $pricing ) { + return $pricing; + } + + foreach ( $this->get_available_models() as $m ) { + if ( $m['id'] === $model && isset( $m['pricing'] ) ) { + return $m['pricing']; + } + } + + return null; + } + + /** + * Strip a trailing date-snapshot suffix (e.g. '-20260423') from a model ID. + * + * Only a dash followed by an 8-digit, 20-prefixed date at the very end + * is treated as a snapshot suffix. Any other trailing digit run (e.g. + * 'foo-202612345') is part of the model name and left untouched. + * + * Side effects: none — pure string transformation. + * + * @param string $model Model ID. + * + * @return string Base model ID, or the input unchanged when no snapshot suffix is present. + */ + private function strip_snapshot_suffix( string $model ): string { + $base = preg_replace( '/-20\d{6}$/', '', $model ); + return is_string( $base ) ? $base : $model; + } + /** * Get the decrypted API key from options. * diff --git a/tests/unit/Providers/OpenRouterPricingTest.php b/tests/unit/Providers/OpenRouterPricingTest.php index 81a2973..bb8b3bd 100644 --- a/tests/unit/Providers/OpenRouterPricingTest.php +++ b/tests/unit/Providers/OpenRouterPricingTest.php @@ -177,4 +177,89 @@ class OpenRouterPricingTest extends BaseTestCase { // At least some models should be present. $this->assertGreaterThan( 0, count( $models ) ); } + + /** + * Test a snapshot-suffixed response model ID resolves to the base model's price. + * + * OpenRouter sometimes returns a dated snapshot ID as the response model + * (e.g. 'deepseek/deepseek-v4-flash-20260423' for a + * 'deepseek/deepseek-v4-flash' request). Pricing must strip the date + * suffix and bill at the base model's rate, not the conservative fallback. + */ + public function test_estimate_cost_snapshot_suffix_resolves_to_base_model_price(): void { + Functions\when( 'get_transient' )->alias( + function ( string $key ) { + if ( 'prautoblogger_openrouter_models' === $key ) { + return [ + [ + 'id' => 'deepseek/deepseek-v4-flash', + 'name' => 'DeepSeek V4 Flash', + 'context_length' => 131072, + 'pricing' => [ 'prompt' => 0.05, 'completion' => 0.20 ], + ], + ]; + } + return false; + } + ); + + $pricing = new \PRAutoBlogger_OpenRouter_Pricing(); + + $base_cost = $pricing->estimate_cost( 'deepseek/deepseek-v4-flash', 1000, 500 ); + $snapshot_cost = $pricing->estimate_cost( 'deepseek/deepseek-v4-flash-20260423', 1000, 500 ); + + // Billed exactly like the base model: (1000 * 0.05 + 500 * 0.20) / 1M. + $this->assertSame( $base_cost, $snapshot_cost ); + $this->assertEqualsWithDelta( 0.00015, $snapshot_cost, 1e-12 ); + + // And not at the conservative fallback rate (10/30 per M = 0.025 here). + $this->assertNotEquals( 0.025, $snapshot_cost ); + } + + /** + * Test an unknown model with no date suffix still gets the conservative estimate. + */ + public function test_estimate_cost_unknown_model_without_suffix_uses_conservative_estimate(): void { + $pricing = new \PRAutoBlogger_OpenRouter_Pricing(); + $cost = $pricing->estimate_cost( 'unknown/fake-model', 1000, 500 ); + + // Conservative fallback: (1000 * $10 + 500 * $30) / 1M. + $this->assertEqualsWithDelta( 0.025, $cost, 1e-12 ); + } + + /** + * Test a trailing digit run that is not an 8-digit date is never stripped. + * + * 'foo-202612345' has nine trailing digits — even with 'foo' and 'foo-2' + * registered, no stripping may occur and the conservative estimate applies. + */ + public function test_estimate_cost_non_date_suffix_is_not_stripped(): void { + Functions\when( 'get_transient' )->alias( + function ( string $key ) { + if ( 'prautoblogger_openrouter_models' === $key ) { + return [ + [ + 'id' => 'foo', + 'name' => 'Foo', + 'context_length' => 8192, + 'pricing' => [ 'prompt' => 0.01, 'completion' => 0.02 ], + ], + [ + 'id' => 'foo-2', + 'name' => 'Foo 2', + 'context_length' => 8192, + 'pricing' => [ 'prompt' => 0.01, 'completion' => 0.02 ], + ], + ]; + } + return false; + } + ); + + $pricing = new \PRAutoBlogger_OpenRouter_Pricing(); + $cost = $pricing->estimate_cost( 'foo-202612345', 1000, 500 ); + + // Nine trailing digits is not a snapshot date — conservative estimate applies. + $this->assertEqualsWithDelta( 0.025, $cost, 1e-12 ); + } }