fix: cost tracker $0.00 bug — add token estimation fallback for DeepSeek (v4.4.3)

DeepSeek models on OpenRouter omit the usage object, causing all token
counts and costs to log as zero. Added character-based estimation
fallback (~3.75 chars/token), token_source column to distinguish
measured vs estimated data, and admin UI indicators (amber warnings,
~ prefix on estimates). Updated DeepSeek/Qwen pricing table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-14 08:32:30 +07:00
parent 52a83439b1
commit 5d5019bda3
6 changed files with 138 additions and 12 deletions

View File

@@ -321,6 +321,27 @@ Migration for existing peptides via admin "Migrate Existing Peptides to Categori
| `psa_directory_card_extras` | Filter | Allows adding HTML to directory cards (e.g., observation count badge). |
| `psa_rest_compound_data` | Filter | Allows enriching REST compound data before response. Receives `$data`, `$post_id`. |
### API Logs Table (`wp_psa_api_logs`)
Custom table created via `dbDelta` on plugin activation. Tracks every OpenRouter API call for cost monitoring and budget enforcement.
| Column | Type | Description |
|--------|------|-------------|
| `id` | BIGINT AUTO_INCREMENT | Primary key |
| `created_at` | DATETIME | Timestamp of API call |
| `provider` | VARCHAR(50) | API provider (default: `openrouter`) |
| `model` | VARCHAR(100) | Model identifier (e.g., `deepseek/deepseek-v3.2`) |
| `prompt_tokens` | INT | Tokens in prompt (measured or estimated) |
| `completion_tokens` | INT | Tokens in completion (measured or estimated) |
| `total_tokens` | INT | Sum of prompt + completion tokens |
| `estimated_cost_usd` | DECIMAL(10,6) | Estimated cost in USD |
| `request_type` | VARCHAR(20) | `validation` or `generation` |
| `peptide_name` | VARCHAR(200) | Peptide being processed |
| `success` | TINYINT(1) | Whether the API call succeeded |
| `token_source` | VARCHAR(20) | `api` (measured from response), `estimated` (character-based fallback), or `none` (v4.4.3) |
**Why `token_source`:** Some OpenRouter models (notably DeepSeek) omit the `usage` object from API responses. When this happens, `PSA_OpenRouter` estimates tokens from character length (~3.75 chars/token). The `token_source` column lets the admin UI clearly distinguish measured vs. estimated data.
### Options (Plugin Settings)
Stored in `wp_options` table:

View File

@@ -5,6 +5,22 @@ All notable changes to the Peptide Search AI plugin will be documented in this f
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.4.3] - 2026-04-14
### Fixed
- **Cost tracker $0.00 bug**: DeepSeek models on OpenRouter omit the `usage` object from API responses, causing all token counts and costs to log as zero. Added character-based token estimation fallback (~3.75 chars/token for scientific English) so spend is always tracked.
### Added
- `token_source` column in `psa_api_logs` table distinguishing measured (`api`) vs. estimated (`estimated`) token counts. Migrated via `dbDelta` on plugin activation.
- DeepSeek and Qwen model pricing in `PSA_Cost_Tracker::estimate_cost()`: `deepseek/deepseek-v3.2`, `deepseek/deepseek-chat`, `deepseek/deepseek-r1`, `qwen/qwen3.6-plus:free`.
- `PSA_Cost_Tracker::get_monthly_estimated_count()` method for querying how many API calls used estimates in a given month.
- Admin UI indicators for estimated data: amber warning icon on monthly summary ("includes estimates"), `~` prefix on estimated token/cost values in the log table, and tooltips explaining the estimation method.
- Diagnostic logging of raw `usage` field from OpenRouter for models that report zero tokens (temporary — remove after confirming).
### Changed
- `PSA_OpenRouter::send_request_once()` now passes `token_source` to cost tracker alongside token counts.
- Version bump to 4.4.3.
## [4.3.0] - Unreleased
### Added

View File

@@ -482,6 +482,7 @@ class PSA_Admin {
private static function render_usage_summary(): void {
$current_spend = PSA_Cost_Tracker::get_monthly_spend();
$current_tokens = PSA_Cost_Tracker::get_monthly_tokens();
$estimated_count = PSA_Cost_Tracker::get_monthly_estimated_count();
$budget = floatval( self::get_setting( 'monthly_budget', PSA_Config::DEFAULT_MONTHLY_BUDGET ) );
$budget_remaining = ( 0.0 === $budget ) ? __( 'Unlimited', 'peptide-search-ai' ) : '$' . number_format( max( 0, $budget - $current_spend ), 2 );
$recent_logs = PSA_Cost_Tracker::get_recent_logs( 20 );
@@ -490,7 +491,11 @@ class PSA_Admin {
<div style="background:#f5f5f5;padding:15px;border-radius:4px;margin-bottom:20px;">
<h3><?php esc_html_e( 'This Month', 'peptide-search-ai' ); ?></h3>
<p>
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?><br />
<strong><?php esc_html_e( 'Spend:', 'peptide-search-ai' ); ?></strong> $<?php echo esc_html( number_format( $current_spend, 2 ) ); ?>
<?php if ( $estimated_count > 0 ) : ?>
<span style="color:#b45309;font-size:12px;" title="<?php echo esc_attr( $estimated_count ); ?> API calls used character-based token estimates because the model did not report usage data">&#9888; includes estimates</span>
<?php endif; ?>
<br />
<strong><?php esc_html_e( 'Tokens:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( number_format( $current_tokens ) ); ?><br />
<strong><?php esc_html_e( 'Budget Remaining:', 'peptide-search-ai' ); ?></strong> <?php echo esc_html( $budget_remaining ); ?>
</p>
@@ -512,11 +517,24 @@ class PSA_Admin {
</thead>
<tbody>
<?php foreach ( $recent_logs as $log ) : ?>
<tr>
<?php $is_estimated = ( isset( $log->token_source ) && 'estimated' === $log->token_source ); ?>
<tr<?php echo $is_estimated ? ' style="color:#92400e;"' : ''; ?>>
<td><?php echo esc_html( $log->created_at ); ?></td>
<td><code><?php echo esc_html( $log->model ); ?></code></td>
<td><?php echo esc_html( number_format( $log->total_tokens ) ); ?></td>
<td>$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></td>
<td>
<?php if ( $is_estimated ) : ?>
<span title="Estimated from character length — model did not report token usage">~<?php echo esc_html( number_format( $log->total_tokens ) ); ?></span>
<?php else : ?>
<?php echo esc_html( number_format( $log->total_tokens ) ); ?>
<?php endif; ?>
</td>
<td>
<?php if ( $is_estimated ) : ?>
<span title="Cost estimated from approximate token count">~$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?></span>
<?php else : ?>
$<?php echo esc_html( number_format( $log->estimated_cost_usd, 4 ) ); ?>
<?php endif; ?>
</td>
<td><?php echo esc_html( $log->request_type ); ?></td>
<td><?php echo esc_html( $log->peptide_name ); ?></td>
<td><?php echo ( 1 === (int) $log->success ) ? '&#10003;' : '&#10007;'; ?></td>

View File

@@ -39,6 +39,7 @@ class PSA_Cost_Tracker {
request_type VARCHAR(20) NOT NULL,
peptide_name VARCHAR(200) DEFAULT '',
success TINYINT(1) NOT NULL DEFAULT 1,
token_source VARCHAR(20) NOT NULL DEFAULT 'api',
INDEX idx_created_at (created_at),
INDEX idx_request_type (request_type)
) $charset_collate;";
@@ -66,6 +67,7 @@ class PSA_Cost_Tracker {
* @type string $peptide_name Name of peptide being processed
* @type bool $success Whether API call succeeded
* @type float $estimated_cost_usd Cost (or 0 to auto-calculate)
* @type string $token_source 'api' (from usage response), 'estimated' (character-based), or 'none'
* }
* @return int|false Insert ID or false on error.
*/
@@ -84,13 +86,14 @@ class PSA_Cost_Tracker {
$peptide_name = $data['peptide_name'] ?? '';
$success = ! empty( $data['success'] ) ? 1 : 0;
$estimated_cost = floatval( $data['estimated_cost_usd'] ?? 0 );
$token_source = $data['token_source'] ?? 'api';
// Auto-sum tokens if not provided.
if ( 0 === $total_tokens && ( $prompt_tokens > 0 || $completion_tokens > 0 ) ) {
$total_tokens = $prompt_tokens + $completion_tokens;
}
// Auto-calculate cost if not provided.
// Auto-calculate cost if not provided and we have token data.
if ( 0 === $estimated_cost && $prompt_tokens > 0 ) {
$estimated_cost = self::estimate_cost( $model, $prompt_tokens, $completion_tokens );
}
@@ -105,9 +108,10 @@ class PSA_Cost_Tracker {
'request_type' => sanitize_text_field( $request_type ),
'peptide_name' => sanitize_text_field( $peptide_name ),
'success' => $success,
'token_source' => sanitize_text_field( $token_source ),
);
$insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d' );
$insert_format = array( '%s', '%s', '%d', '%d', '%d', '%f', '%s', '%s', '%d', '%s' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->insert( $table_name, $insert_data, $insert_format );
@@ -198,6 +202,45 @@ class PSA_Cost_Tracker {
return (int) $result;
}
/**
* Count how many API calls this month used character-based token estimates.
*
* Why: Lets the admin UI flag when reported costs include approximations
* so operators can distinguish measured spend from estimated spend.
*
* @param int|null $year Year (defaults to current year).
* @param int|null $month Month (defaults to current month).
* @return int Number of estimated rows this month.
*/
public static function get_monthly_estimated_count( ?int $year = null, ?int $month = null ): int {
global $wpdb;
if ( null === $year ) {
$year = (int) gmdate( 'Y' );
}
if ( null === $month ) {
$month = (int) gmdate( 'm' );
}
$table_name = $wpdb->prefix . 'psa_api_logs';
$month = max( 1, min( 12, (int) $month ) );
$start_date = gmdate( 'Y-m-01', mktime( 0, 0, 0, $month, 1, $year ) );
$end_date = gmdate( 'Y-m-t', mktime( 0, 0, 0, $month, 1, $year ) ) . ' 23:59:59';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$result = $wpdb->get_var(
$wpdb->prepare(
'SELECT COUNT(*) FROM ' . $table_name . ' WHERE created_at >= %s AND created_at <= %s AND token_source = %s', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name is safe, built from $wpdb->prefix.
$start_date,
$end_date,
'estimated'
)
);
return (int) $result;
}
/**
* Check if current month's budget has been exceeded.
* Budget of 0 means unlimited.
@@ -251,9 +294,16 @@ class PSA_Cost_Tracker {
*/
public static function estimate_cost( string $model, int $prompt_tokens, int $completion_tokens ): float {
// Known model pricing per 1 million tokens (input, output).
// Why: OpenRouter pricing varies by model; we maintain known rates here and
// fall back to a conservative default for unlisted models so cost tracking
// always produces a usable upper-bound estimate.
$pricing = array(
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
'google/gemini-2.5-flash' => array( 0.15, 0.60 ),
'google/gemini-2.0-flash-001' => array( 0.10, 0.40 ),
'deepseek/deepseek-v3.2' => array( 0.27, 1.10 ),
'deepseek/deepseek-chat' => array( 0.27, 1.10 ),
'deepseek/deepseek-r1' => array( 0.55, 2.19 ),
'qwen/qwen3.6-plus:free' => array( 0.00, 0.00 ),
);
$model = sanitize_text_field( $model );

View File

@@ -147,8 +147,28 @@ class PSA_OpenRouter {
}
// Log API call with token usage.
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$response_text = $body['choices'][0]['message']['content'];
$prompt_tokens = (int) ( $body['usage']['prompt_tokens'] ?? 0 );
$completion_tokens = (int) ( $body['usage']['completion_tokens'] ?? 0 );
$token_source = 'api';
// Diagnostic: log the raw usage structure once per model so we can verify
// whether the provider returns token counts. Remove after confirming.
if ( 0 === $prompt_tokens ) {
$usage_raw = isset( $body['usage'] ) ? wp_json_encode( $body['usage'] ) : 'missing';
error_log( 'PSA: OpenRouter usage field for model ' . $model . ': ' . substr( $usage_raw, 0, 300 ) );
}
// Fallback: estimate tokens from character length when the API reports zero.
// Why: Some OpenRouter models (e.g. deepseek) omit the usage object entirely.
// Approximation: ~3.75 characters per token for English scientific text.
if ( 0 === $prompt_tokens && 0 === $completion_tokens ) {
$prompt_tokens = (int) ceil( strlen( $prompt ) / 3.75 );
$completion_tokens = (int) ceil( strlen( $response_text ) / 3.75 );
$token_source = 'estimated';
error_log( 'PSA: Token usage estimated from character length for ' . $peptide_name . ': prompt=' . $prompt_tokens . ', completion=' . $completion_tokens );
}
PSA_Cost_Tracker::log_api_call(
array(
@@ -159,10 +179,11 @@ class PSA_OpenRouter {
'request_type' => $request_type,
'peptide_name' => $peptide_name,
'success' => true,
'token_source' => $token_source,
)
);
return $body['choices'][0]['message']['content'];
return $response_text;
}
/**

View File

@@ -3,7 +3,7 @@
* Plugin Name: Peptide Search AI
* Plugin URI: https://example.com/peptide-search-ai
* Description: Searchable peptide database with AI-powered auto-population and browsable directory.
* Version: 4.4.2
* Version: 4.4.3
* Author: Terence
* License: GPL v2 or later
* Text Domain: peptide-search-ai
@@ -15,7 +15,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'PSA_VERSION', '4.4.2' );
define( 'PSA_VERSION', '4.4.3' );
define( 'PSA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PSA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PSA_PLUGIN_FILE', __FILE__ );