mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 21:30:17 +08:00
5376ea042b
* i18n: update cache terminology across all languages
- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成
Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite
* feat(usage): add cache metrics to trend chart
- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens
This provides better visibility into cache usage patterns over time.
* fix(usage): fix timezone handling in datetime picker
- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input
This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.
* feat(usage): add auto-refresh for usage statistics
- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab
This keeps usage data fresh without manual refresh.
* fix(proxy): improve usage logging and cache token parsing
- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics
This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.
* style(rust): use inline format args in format! macros
- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider
Fixes clippy::uninlined_format_args warnings.
* feat(proxy): enhance provider router logging
- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability
This improves debugging of provider selection and failover behavior.
* feat(database): update model pricing data
- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes
* fix(usage): correct Gemini output token calculation
Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.
- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic
* fix(usage): correct cache token billing and add Codex format auto-detection
- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking
* fix(proxy): improve takeover detection with live config check
- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status
* refactor(usage): simplify model pricing lookup by removing suffix fallback
Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.
* feat(database): add Chinese AI model pricing data
Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)
Also fix test case to use correct model ID and remove invalid currency column.
* refactor(proxy): improve header forwarding with blacklist approach
Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.
* fix(proxy): bypass timeout and retry configs when failover is disabled
When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:
- create_forwarder: passes 0 for all timeout/retry params when failover
is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
idle_timeout when failover is disabled
This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.
* fix(proxy): handle zero value input in failover config fields
* refactor(proxy): remove retry logic and add enabled check for failover
* refactor(proxy): distinguish circuit-open from no-provider errors
* Align usage stats to sliding windows
* feat(proxy): add body and header filtering for upstream requests
* feat(proxy): enable transparent passthrough for headers
- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required
* feat(proxy): extract session ID from client requests for logging
- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
195 lines
6.2 KiB
Rust
195 lines
6.2 KiB
Rust
//! Cost Calculator - 计算 API 请求成本
|
||
//!
|
||
//! 使用高精度 Decimal 类型避免浮点数精度问题
|
||
|
||
use super::parser::TokenUsage;
|
||
use rust_decimal::Decimal;
|
||
use std::str::FromStr;
|
||
|
||
/// 成本明细
|
||
#[derive(Debug, Clone)]
|
||
pub struct CostBreakdown {
|
||
pub input_cost: Decimal,
|
||
pub output_cost: Decimal,
|
||
pub cache_read_cost: Decimal,
|
||
pub cache_creation_cost: Decimal,
|
||
pub total_cost: Decimal,
|
||
}
|
||
|
||
/// 模型定价信息
|
||
#[derive(Debug, Clone)]
|
||
pub struct ModelPricing {
|
||
pub input_cost_per_million: Decimal,
|
||
pub output_cost_per_million: Decimal,
|
||
pub cache_read_cost_per_million: Decimal,
|
||
pub cache_creation_cost_per_million: Decimal,
|
||
}
|
||
|
||
/// 成本计算器
|
||
pub struct CostCalculator;
|
||
|
||
impl CostCalculator {
|
||
/// 计算请求成本
|
||
///
|
||
/// # 参数
|
||
/// - `usage`: Token 使用量
|
||
/// - `pricing`: 模型定价
|
||
/// - `cost_multiplier`: 成本倍数 (provider 自定义)
|
||
///
|
||
/// # 计算逻辑
|
||
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
|
||
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
|
||
/// - 这样避免缓存部分被重复计费
|
||
pub fn calculate(
|
||
usage: &TokenUsage,
|
||
pricing: &ModelPricing,
|
||
cost_multiplier: Decimal,
|
||
) -> CostBreakdown {
|
||
let million = Decimal::from(1_000_000);
|
||
|
||
// 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
|
||
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
|
||
|
||
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
|
||
/ million
|
||
* cost_multiplier;
|
||
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
|
||
/ million
|
||
* cost_multiplier;
|
||
let cache_read_cost =
|
||
Decimal::from(usage.cache_read_tokens) * pricing.cache_read_cost_per_million / million
|
||
* cost_multiplier;
|
||
let cache_creation_cost = Decimal::from(usage.cache_creation_tokens)
|
||
* pricing.cache_creation_cost_per_million
|
||
/ million
|
||
* cost_multiplier;
|
||
|
||
let total_cost = input_cost + output_cost + cache_read_cost + cache_creation_cost;
|
||
|
||
CostBreakdown {
|
||
input_cost,
|
||
output_cost,
|
||
cache_read_cost,
|
||
cache_creation_cost,
|
||
total_cost,
|
||
}
|
||
}
|
||
|
||
/// 尝试计算成本,如果模型未知则返回 None
|
||
pub fn try_calculate(
|
||
usage: &TokenUsage,
|
||
pricing: Option<&ModelPricing>,
|
||
cost_multiplier: Decimal,
|
||
) -> Option<CostBreakdown> {
|
||
pricing.map(|p| Self::calculate(usage, p, cost_multiplier))
|
||
}
|
||
}
|
||
|
||
impl ModelPricing {
|
||
/// 从字符串创建定价信息
|
||
pub fn from_strings(
|
||
input: &str,
|
||
output: &str,
|
||
cache_read: &str,
|
||
cache_creation: &str,
|
||
) -> Result<Self, rust_decimal::Error> {
|
||
Ok(Self {
|
||
input_cost_per_million: Decimal::from_str(input)?,
|
||
output_cost_per_million: Decimal::from_str(output)?,
|
||
cache_read_cost_per_million: Decimal::from_str(cache_read)?,
|
||
cache_creation_cost_per_million: Decimal::from_str(cache_creation)?,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_cost_calculation() {
|
||
let usage = TokenUsage {
|
||
input_tokens: 1000,
|
||
output_tokens: 500,
|
||
cache_read_tokens: 200,
|
||
cache_creation_tokens: 100,
|
||
model: None,
|
||
};
|
||
|
||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0.3", "3.75").unwrap();
|
||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||
|
||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||
|
||
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
|
||
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
|
||
// output: 500 * 15.0 / 1M = 0.0075
|
||
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
|
||
// cache_read: 200 * 0.3 / 1M = 0.00006
|
||
assert_eq!(cost.cache_read_cost, Decimal::from_str("0.00006").unwrap());
|
||
// cache_creation: 100 * 3.75 / 1M = 0.000375
|
||
assert_eq!(
|
||
cost.cache_creation_cost,
|
||
Decimal::from_str("0.000375").unwrap()
|
||
);
|
||
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
|
||
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_cost_multiplier() {
|
||
let usage = TokenUsage {
|
||
input_tokens: 1000,
|
||
output_tokens: 0,
|
||
cache_read_tokens: 0,
|
||
cache_creation_tokens: 0,
|
||
model: None,
|
||
};
|
||
|
||
let pricing = ModelPricing::from_strings("3.0", "15.0", "0", "0").unwrap();
|
||
let multiplier = Decimal::from_str("1.5").unwrap();
|
||
|
||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||
|
||
// input: 1000 * 3.0 / 1M * 1.5 = 0.0045
|
||
assert_eq!(cost.input_cost, Decimal::from_str("0.0045").unwrap());
|
||
assert_eq!(cost.total_cost, Decimal::from_str("0.0045").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn test_unknown_model_handling() {
|
||
let usage = TokenUsage {
|
||
input_tokens: 1000,
|
||
output_tokens: 500,
|
||
cache_read_tokens: 0,
|
||
cache_creation_tokens: 0,
|
||
model: None,
|
||
};
|
||
|
||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||
let cost = CostCalculator::try_calculate(&usage, None, multiplier);
|
||
|
||
assert!(cost.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_decimal_precision() {
|
||
let usage = TokenUsage {
|
||
input_tokens: 1,
|
||
output_tokens: 1,
|
||
cache_read_tokens: 1,
|
||
cache_creation_tokens: 1,
|
||
model: None,
|
||
};
|
||
|
||
let pricing = ModelPricing::from_strings("0.075", "0.3", "0.01875", "0.075").unwrap();
|
||
let multiplier = Decimal::from_str("1.0").unwrap();
|
||
|
||
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
|
||
|
||
// 验证高精度计算
|
||
assert!(cost.total_cost > Decimal::ZERO);
|
||
assert!(cost.total_cost.to_string().len() > 2); // 确保保留了小数位
|
||
}
|
||
}
|