diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ed20d80..a0fbaff 100755
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f2366e..55fbaa4 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/includes/class-psa-admin.php b/includes/class-psa-admin.php
index fc6a442..0e19f52 100755
--- a/includes/class-psa-admin.php
+++ b/includes/class-psa-admin.php
@@ -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 {
- $
+ $
+ 0 ) : ?>
+ ⚠ includes estimates
+
+
@@ -512,11 +517,24 @@ class PSA_Admin {
-
+ token_source ) && 'estimated' === $log->token_source ); ?>
+
>
| created_at ); ?> |
model ); ?> |
- total_tokens ) ); ?> |
- $estimated_cost_usd, 4 ) ); ?> |
+
+
+ ~total_tokens ) ); ?>
+
+ total_tokens ) ); ?>
+
+ |
+
+
+ ~$estimated_cost_usd, 4 ) ); ?>
+
+ $estimated_cost_usd, 4 ) ); ?>
+
+ |
request_type ); ?> |
peptide_name ); ?> |
success ) ? '✓' : '✗'; ?> |
diff --git a/includes/class-psa-cost-tracker.php b/includes/class-psa-cost-tracker.php
index 261d88c..8853252 100755
--- a/includes/class-psa-cost-tracker.php
+++ b/includes/class-psa-cost-tracker.php
@@ -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 );
diff --git a/includes/class-psa-openrouter.php b/includes/class-psa-openrouter.php
index bd085d3..5c9c6bc 100755
--- a/includes/class-psa-openrouter.php
+++ b/includes/class-psa-openrouter.php
@@ -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;
}
/**
diff --git a/peptide-search-ai.php b/peptide-search-ai.php
index d5921b4..f9f6826 100755
--- a/peptide-search-ai.php
+++ b/peptide-search-ai.php
@@ -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__ );