fix: 修正时间戳精度、TTL 定价匹配逻辑及账单快照展示

- 将 created_at_unix_ms 从 current_unix_secs 改为 current_unix_ms,修正候选尝试和跳过记录的时间戳精度
- formula_engine: TTL 定价从「<=上限」模糊匹配改为精确匹配,null 值改为回退到基础价格而非透传
- 新增 ttl_pricing_requires_exact_match 和 ttl_pricing_null_value_falls_back_to_base_tier_value 测试用例
- service: 新增 5min/1h cache TTL 的端到端计费验证测试
- RequestDetailDrawer: 优先从 billing_snapshot 读取已解析的价格和费用,正确展示当前 TTL 对应的缓存创建/读取价格,修正输入/输出/缓存费用列的数据来源
This commit is contained in:
fawney19
2026-04-10 18:50:59 +08:00
parent 010ab127e2
commit d5b8583d6b
11 changed files with 542 additions and 76 deletions

View File

@@ -9,7 +9,7 @@ use crate::ai_pipeline::{
resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy, resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy,
GatewayControlDecision, PlannerAppState, GatewayControlDecision, PlannerAppState,
}; };
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError}; use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
use super::{ use super::{
@@ -105,7 +105,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;
for (candidate_index, candidate) in candidates.into_iter().enumerate() { for (candidate_index, candidate) in candidates.into_iter().enumerate() {

View File

@@ -21,7 +21,7 @@ use crate::ai_pipeline::transport::{
use crate::ai_pipeline::{ use crate::ai_pipeline::{
collect_control_headers, ConversionMode, ExecutionStrategy, PlannerAppState, collect_control_headers, ConversionMode, ExecutionStrategy, PlannerAppState,
}; };
use crate::clock::current_unix_secs; use crate::clock::current_unix_ms;
use crate::{ use crate::{
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse, append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
@@ -365,7 +365,7 @@ pub(super) async fn mark_skipped_local_same_format_provider_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local same-format decision failed to persist skipped candidate", "gateway local same-format decision failed to persist skipped candidate",
) )
.await; .await;

View File

@@ -11,7 +11,7 @@ use crate::ai_pipeline::{
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision, resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
}; };
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState}; use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::{AppState, GatewayError}; use crate::{AppState, GatewayError};
pub(super) const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat"; pub(super) const GEMINI_FILES_CANDIDATE_API_FORMAT: &str = "gemini:chat";
@@ -102,7 +102,7 @@ pub(super) async fn materialize_local_gemini_files_candidate_attempts(
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;
for (candidate_index, candidate) in candidates.into_iter().enumerate() { for (candidate_index, candidate) in candidates.into_iter().enumerate() {
@@ -174,7 +174,7 @@ pub(super) async fn mark_skipped_local_gemini_files_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local gemini files failed to persist skipped candidate", "gateway local gemini files failed to persist skipped candidate",
) )
.await; .await;

View File

@@ -12,7 +12,7 @@ use crate::ai_pipeline::{
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision, resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
}; };
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState}; use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::AppState; use crate::AppState;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -148,7 +148,7 @@ async fn materialize_local_video_create_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;
@@ -220,7 +220,7 @@ pub(super) async fn mark_skipped_local_video_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local video decision failed to persist skipped candidate", "gateway local video decision failed to persist skipped candidate",
) )
.await; .await;

View File

@@ -17,7 +17,7 @@ use crate::ai_pipeline::{
GatewayControlDecision, GatewayControlDecision,
}; };
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState}; use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError}; use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
use super::{ use super::{
@@ -145,7 +145,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;
for (candidate_index, candidate) in candidates.into_iter().enumerate() { for (candidate_index, candidate) in candidates.into_iter().enumerate() {

View File

@@ -14,7 +14,7 @@ use crate::ai_pipeline::transport::{
}; };
use crate::ai_pipeline::{collect_control_headers, ConversionMode, ExecutionStrategy}; use crate::ai_pipeline::{collect_control_headers, ConversionMode, ExecutionStrategy};
use crate::ai_pipeline::{LocalResolvedOAuthRequestAuth, PlannerAppState}; use crate::ai_pipeline::{LocalResolvedOAuthRequestAuth, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::current_unix_ms;
use crate::{ use crate::{
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse, append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION, EXECUTION_RUNTIME_STREAM_DECISION_ACTION, EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
@@ -390,7 +390,7 @@ pub(super) async fn mark_skipped_local_standard_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local standard decision failed to persist skipped candidate", "gateway local standard decision failed to persist skipped candidate",
) )
.await; .await;

View File

@@ -12,7 +12,7 @@ use crate::ai_pipeline::planner::candidate_affinity::{
}; };
use crate::ai_pipeline::GatewayAuthApiKeySnapshot; use crate::ai_pipeline::GatewayAuthApiKeySnapshot;
use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, PlannerAppState}; use crate::ai_pipeline::{ConversionMode, ExecutionStrategy, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::{append_execution_contract_fields_to_value, AppState}; use crate::{append_execution_contract_fields_to_value, AppState};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -57,7 +57,7 @@ pub(crate) async fn mark_skipped_local_openai_chat_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local openai chat decision failed to persist skipped candidate", "gateway local openai chat decision failed to persist skipped candidate",
) )
.await; .await;
@@ -77,7 +77,7 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;

View File

@@ -18,7 +18,7 @@ use crate::ai_pipeline::{
GatewayControlDecision, GatewayControlDecision,
}; };
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState}; use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
use crate::clock::current_unix_secs; use crate::clock::{current_unix_ms, current_unix_secs};
use crate::{append_execution_contract_fields_to_value, AppState, GatewayError}; use crate::{append_execution_contract_fields_to_value, AppState, GatewayError};
use super::LocalOpenAiCliSpec; use super::LocalOpenAiCliSpec;
@@ -152,7 +152,7 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
) )
.await; .await;
let created_at_unix_ms = current_unix_secs(); let created_at_unix_ms = current_unix_ms();
let mut attempts = Vec::with_capacity(candidates.len()); let mut attempts = Vec::with_capacity(candidates.len());
let mut affinity_remembered = false; let mut affinity_remembered = false;
for (candidate_index, candidate) in candidates.into_iter().enumerate() { for (candidate_index, candidate) in candidates.into_iter().enumerate() {
@@ -314,7 +314,7 @@ pub(crate) async fn mark_skipped_local_openai_cli_candidate(
candidate_id, candidate_id,
input.required_capabilities.as_ref(), input.required_capabilities.as_ref(),
skip_reason, skip_reason,
current_unix_secs(), current_unix_ms(),
"gateway local openai cli decision failed to persist skipped candidate", "gateway local openai cli decision failed to persist skipped candidate",
) )
.await; .await;

View File

@@ -380,28 +380,21 @@ fn resolve_tiered(
if let (Some(cache_ttl_minutes), Some(ttl_value_key)) = (cache_ttl_minutes, ttl_value_key) { if let (Some(cache_ttl_minutes), Some(ttl_value_key)) = (cache_ttl_minutes, ttl_value_key) {
if let Some(ttl_pricing) = tier.get("cache_ttl_pricing").and_then(|v| v.as_array()) { if let Some(ttl_pricing) = tier.get("cache_ttl_pricing").and_then(|v| v.as_array()) {
for ttl_entry in ttl_pricing { if let Some(ttl_entry) = ttl_pricing.iter().find(|entry| {
let ttl_limit = ttl_entry entry
.get("ttl_minutes") .get("ttl_minutes")
.and_then(as_f64) .and_then(as_f64)
.unwrap_or_default(); .map(|value| value == cache_ttl_minutes)
if cache_ttl_minutes <= ttl_limit { .unwrap_or(false)
if let Some(value) = ttl_entry.get(ttl_value_key) { }) {
return Ok(( if let Some(value) = ttl_entry.get(ttl_value_key).filter(|value| !value.is_null()) {
value.clone(), return Ok((
false, value.clone(),
Some((matched_index.unwrap_or(0), tier)), false,
)); Some((matched_index.unwrap_or(0), tier)),
} ));
} }
} }
if let Some(value) = ttl_pricing.last().and_then(|v| v.get(ttl_value_key)) {
return Ok((
value.clone(),
false,
Some((matched_index.unwrap_or(0), tier)),
));
}
} }
} }
@@ -857,4 +850,82 @@ mod tests {
assert_eq!(result.status, FormulaEvaluationStatus::Incomplete); assert_eq!(result.status, FormulaEvaluationStatus::Incomplete);
assert_eq!(result.missing_required, vec!["input_tokens".to_string()]); assert_eq!(result.missing_required, vec!["input_tokens".to_string()]);
} }
#[test]
fn ttl_pricing_requires_exact_match() {
let engine = FormulaEngine::new();
let dimensions = BTreeMap::from([
("total_input_context".to_string(), serde_json::json!(22_562)),
("cache_ttl_minutes".to_string(), serde_json::json!(5)),
]);
let mappings = BTreeMap::from([(
"cache_creation_price_per_1m".to_string(),
serde_json::json!({
"source": "tiered",
"tier_key": "total_input_context",
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_creation_price_per_1m",
"tiers": [{
"up_to": null,
"value": 3.125,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0
}]
}],
"default": 0.0
}),
)]);
let result = engine
.evaluate(
"cache_creation_price_per_1m",
None,
Some(&dimensions),
Some(&mappings),
false,
)
.expect("tiered mapping should evaluate");
assert_eq!(result.cost, 3.125);
}
#[test]
fn ttl_pricing_null_value_falls_back_to_base_tier_value() {
let engine = FormulaEngine::new();
let dimensions = BTreeMap::from([
("total_input_context".to_string(), serde_json::json!(22_562)),
("cache_ttl_minutes".to_string(), serde_json::json!(60)),
]);
let mappings = BTreeMap::from([(
"cache_read_price_per_1m".to_string(),
serde_json::json!({
"source": "tiered",
"tier_key": "total_input_context",
"ttl_key": "cache_ttl_minutes",
"ttl_value_key": "cache_read_price_per_1m",
"tiers": [{
"up_to": null,
"value": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_read_price_per_1m": null
}]
}],
"default": 0.0
}),
)]);
let result = engine
.evaluate(
"cache_read_price_per_1m",
None,
Some(&dimensions),
Some(&mappings),
false,
)
.expect("tiered mapping should evaluate");
assert_eq!(result.cost, 0.25);
}
} }

View File

@@ -264,4 +264,142 @@ mod tests {
assert!(result.actual_total_cost > 0.0); assert!(result.actual_total_cost > 0.0);
assert_eq!(result.rate_multiplier, 0.5); assert_eq!(result.rate_multiplier, 0.5);
} }
#[test]
fn five_minute_cache_ttl_uses_base_cache_prices() {
let pricing = BillingModelPricingSnapshot {
provider_id: "provider-1".to_string(),
provider_billing_type: Some("pay_as_you_go".to_string()),
provider_api_key_id: Some("key-1".to_string()),
provider_api_key_rate_multipliers: None,
provider_api_key_cache_ttl_minutes: Some(5),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
global_model_config: None,
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 2.5,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.125,
"cache_read_price_per_1m": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0,
"cache_read_price_per_1m": null
}]
}]
})),
model_id: None,
model_provider_model_name: None,
model_config: None,
model_price_per_request: None,
model_tiered_pricing: None,
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "chat".to_string(),
api_format: None,
request_count: 1,
input_tokens: 1_000,
output_tokens: 10,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
cache_ttl_minutes: Some(5),
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_creation_price_per_1m"),
Some(&json!(3.125))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_read_price_per_1m"),
Some(&json!(0.25))
);
}
#[test]
fn one_hour_cache_ttl_keeps_base_cache_read_when_ttl_entry_omits_it() {
let pricing = BillingModelPricingSnapshot {
provider_id: "provider-1".to_string(),
provider_billing_type: Some("pay_as_you_go".to_string()),
provider_api_key_id: Some("key-1".to_string()),
provider_api_key_rate_multipliers: None,
provider_api_key_cache_ttl_minutes: Some(60),
global_model_id: "global-model-1".to_string(),
global_model_name: "gpt-5.4".to_string(),
global_model_config: None,
default_price_per_request: None,
default_tiered_pricing: Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 2.5,
"output_price_per_1m": 15.0,
"cache_creation_price_per_1m": 3.125,
"cache_read_price_per_1m": 0.25,
"cache_ttl_pricing": [{
"ttl_minutes": 60,
"cache_creation_price_per_1m": 5.0,
"cache_read_price_per_1m": null
}]
}]
})),
model_id: None,
model_provider_model_name: None,
model_config: None,
model_price_per_request: None,
model_tiered_pricing: None,
};
let result = BillingService::new()
.calculate(
&pricing,
&BillingUsageInput {
task_type: "chat".to_string(),
api_format: None,
request_count: 1,
input_tokens: 1_000,
output_tokens: 10,
cache_creation_tokens: 0,
cache_creation_ephemeral_5m_tokens: 0,
cache_creation_ephemeral_1h_tokens: 0,
cache_read_tokens: 100,
cache_ttl_minutes: Some(60),
},
)
.expect("billing should calculate");
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_creation_price_per_1m"),
Some(&json!(5.0))
);
assert_eq!(
result
.cost_result
.snapshot
.resolved_variables
.get("cache_read_price_per_1m"),
Some(&json!(0.25))
);
}
} }

View File

@@ -237,19 +237,12 @@
<div class="text-muted-foreground flex items-center gap-2 flex-wrap"> <div class="text-muted-foreground flex items-center gap-2 flex-wrap">
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span> <span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span> <span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
<template v-if="hasTierCacheCreationSplitPricing(tier)"> <span v-if="getTierActiveCacheCreationDisplay(tier)">
<span v-if="getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') !== null"> {{ getTierActiveCacheCreationDisplay(tier)?.label }}
缓存创建(5min) ${{ formatPrice(getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') || 0) }}/M ${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
</span>
<span v-if="getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') !== null">
缓存创建(1h) ${{ formatPrice(getTierCachePriceForTTL(tier, 60, 'cache_creation_price_per_1m') || 0) }}/M
</span>
</template>
<span v-else-if="tier.cache_creation_price_per_1m">
缓存创建 ${{ formatPrice(tier.cache_creation_price_per_1m) }}/M
</span> </span>
<span v-if="tier.cache_read_price_per_1m"> <span v-if="shouldShowCacheReadPrice(tier)">
缓存读取 ${{ formatPrice(tier.cache_read_price_per_1m) }}/M 缓存读取 ${{ formatPrice(getTierActiveCacheReadPrice(tier) ?? 0) }}/M
</span> </span>
</div> </div>
</div> </div>
@@ -261,7 +254,7 @@
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">输入</span> <span class="text-xs text-muted-foreground w-[56px]">输入</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ displayInputTokens }}</span>
<span class="text-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ effectiveInputCost.toFixed(6) }}</span>
</div> </div>
<Separator <Separator
orientation="vertical" orientation="vertical"
@@ -270,15 +263,15 @@
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">输出</span> <span class="text-xs text-muted-foreground w-[56px]">输出</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.tokens?.output || detail.output_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cost?.output || detail.output_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ effectiveOutputCost.toFixed(6) }}</span>
</div> </div>
</div> </div>
<!-- 缓存创建 缓存读取 --> <!-- 缓存创建 缓存读取 -->
<div class="flex items-center"> <div class="flex items-center">
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span> <span class="text-xs text-muted-foreground w-[56px]">{{ cacheCreationSplitRows.length > 0 ? '创建合计' : '缓存创建' }}</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_creation_input_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ totalCacheCreationTokens }}</span>
<span class="text-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ effectiveCacheCreationCost.toFixed(6) }}</span>
</div> </div>
<Separator <Separator
orientation="vertical" orientation="vertical"
@@ -287,7 +280,7 @@
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
<span class="text-xs text-muted-foreground w-[56px]">缓存读取</span> <span class="text-xs text-muted-foreground w-[56px]">缓存读取</span>
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_read_input_tokens || 0 }}</span> <span class="text-sm font-semibold font-mono flex-1 text-center">{{ detail.cache_read_input_tokens || 0 }}</span>
<span class="text-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span> <span class="text-xs font-mono">${{ effectiveCacheReadCost.toFixed(6) }}</span>
</div> </div>
</div> </div>
<!-- 缓存创建 5m/1h 细分 --> <!-- 缓存创建 5m/1h 细分 -->
@@ -323,10 +316,10 @@
</div> </div>
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2"> <div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
<div <div
v-if="detail.price_per_request" v-if="effectivePricePerRequest > 0"
class="flex items-center justify-end text-xs" class="flex items-center justify-end text-xs"
> >
<span class="text-muted-foreground">${{ detail.price_per_request.toFixed(6) }}/</span> <span class="text-muted-foreground">${{ effectivePricePerRequest.toFixed(6) }}/</span>
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<div class="flex items-center flex-1"> <div class="flex items-center flex-1">
@@ -753,10 +746,55 @@ type CacheTTLPriceEntry = {
} }
type PricingTierLike = { type PricingTierLike = {
up_to?: number | null
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null cache_read_price_per_1m?: number | null
cache_ttl_pricing?: CacheTTLPriceEntry[] | null cache_ttl_pricing?: CacheTTLPriceEntry[] | null
} }
type JsonRecord = Record<string, unknown>
function asRecord(value: unknown): JsonRecord | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as JsonRecord
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function getNestedValue(record: JsonRecord | null, ...path: string[]): unknown {
let current: unknown = record
for (const key of path) {
const object = asRecord(current)
if (!object) return null
current = object[key]
}
return current
}
function getNestedNumber(record: JsonRecord | null, ...path: string[]): number | null {
return toNumber(getNestedValue(record, ...path))
}
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
if (!Array.isArray(value)) return []
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is JsonRecord => entry !== null)
.map((entry) => ({
ttl_minutes: toNumber(entry.ttl_minutes),
cache_creation_price_per_1m: toNumber(entry.cache_creation_price_per_1m),
cache_read_price_per_1m: toNumber(entry.cache_read_price_per_1m),
}))
}
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null) const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false) const autoRefreshing = ref(false)
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden) const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
@@ -813,6 +851,22 @@ const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
return meta as Record<string, unknown> return meta as Record<string, unknown>
}) })
const billingSnapshot = computed<JsonRecord | null>(() =>
asRecord(traceRequestMetadata.value?.billing_snapshot),
)
const billingResolvedVariables = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.resolved_variables),
)
const billingCostBreakdown = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.cost_breakdown),
)
const billingTierInfo = computed<JsonRecord | null>(() =>
asRecord(billingSnapshot.value?.tier_info),
)
function hasBodyContent(flag: boolean | undefined, data: unknown): boolean { function hasBodyContent(flag: boolean | undefined, data: unknown): boolean {
return Boolean(flag) || hasContent(data) return Boolean(flag) || hasContent(data)
} }
@@ -1022,6 +1076,136 @@ const priceSourceLabel = computed(() => {
return '历史定价' return '历史定价'
}) })
const cacheCreationInputTokens5m = computed(() => {
if (!detail.value) return 0
return toNumber(detail.value.cache_creation_input_tokens_5m)
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_5m_input_tokens')
?? 0
})
const cacheCreationInputTokens1h = computed(() => {
if (!detail.value) return 0
return toNumber(detail.value.cache_creation_input_tokens_1h)
?? getNestedNumber(detail.value as unknown as JsonRecord, 'cache_creation_ephemeral_1h_input_tokens')
?? 0
})
const totalCacheCreationTokens = computed(() => {
if (!detail.value) return 0
const classified = cacheCreationInputTokens5m.value + cacheCreationInputTokens1h.value
const total = detail.value.cache_creation_input_tokens || 0
if (total === 0 && classified > 0) return classified
return total
})
const effectiveInputPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'input_price_per_1m')
?? toNumber(detail.value?.input_price_per_1m)
)
const effectiveOutputPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'output_price_per_1m')
?? toNumber(detail.value?.output_price_per_1m)
)
const effectiveCacheCreationPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'cache_creation_price_per_1m')
?? toNumber(detail.value?.cache_creation_price_per_1m)
)
const effectiveCacheReadPricePer1M = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'cache_read_price_per_1m')
?? toNumber(detail.value?.cache_read_price_per_1m)
)
const effectivePricePerRequest = computed(() =>
getNestedNumber(billingResolvedVariables.value, 'price_per_request')
?? toNumber(detail.value?.price_per_request)
?? 0,
)
const effectiveInputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'input_cost')
?? toNumber(detail.value?.input_cost)
?? 0,
)
const effectiveOutputCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'output_cost')
?? toNumber(detail.value?.output_cost)
?? 0,
)
const effectiveCacheCreationCost = computed(() => {
const snapshotCost = [
getNestedNumber(billingCostBreakdown.value, 'cache_creation_uncategorized_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_5m_cost'),
getNestedNumber(billingCostBreakdown.value, 'cache_creation_ephemeral_1h_cost'),
].reduce((sum, value) => sum + (value ?? 0), 0)
if (snapshotCost > 0) return snapshotCost
return toNumber(detail.value?.cache_creation_cost) ?? 0
})
const effectiveCacheReadCost = computed(() =>
getNestedNumber(billingCostBreakdown.value, 'cache_read_cost')
?? toNumber(detail.value?.cache_read_cost)
?? 0,
)
const effectiveRequestCost = computed(() => {
const snapshotCost = getNestedNumber(billingCostBreakdown.value, 'request_cost')
if (snapshotCost !== null) return snapshotCost
if (effectivePricePerRequest.value > 0) {
return toNumber(detail.value?.request_cost) ?? 0
}
return 0
})
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
if (tierPricing.length > 0) return tierPricing
const rows: CacheTTLPriceEntry[] = []
const cache5mCreationPrice = getNestedNumber(
billingResolvedVariables.value,
'cache_creation_ephemeral_5m_price_per_1m',
)
const cache1hCreationPrice = getNestedNumber(
billingResolvedVariables.value,
'cache_creation_ephemeral_1h_price_per_1m',
)
if (cache5mCreationPrice !== null) {
rows.push({
ttl_minutes: 5,
cache_creation_price_per_1m: cache5mCreationPrice,
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
})
}
if (cache1hCreationPrice !== null) {
rows.push({
ttl_minutes: 60,
cache_creation_price_per_1m: cache1hCreationPrice,
cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
})
}
return rows
})
const activeCacheTtlMinutes = computed(() => {
const snapshotTtl = getNestedNumber(billingSnapshot.value, 'resolved_dimensions', 'cache_ttl_minutes')
if (snapshotTtl !== null && snapshotTtl > 0) {
return Math.trunc(snapshotTtl)
}
if (cacheCreationInputTokens1h.value > 0 && cacheCreationInputTokens5m.value === 0) {
return 60
}
if (cacheCreationInputTokens5m.value > 0 && cacheCreationInputTokens1h.value === 0) {
return 5
}
return null
})
// 统一的阶梯显示数据 // 统一的阶梯显示数据
// 如果有 tiered_pricing使用它否则用历史价格构建单阶梯 // 如果有 tiered_pricing使用它否则用历史价格构建单阶梯
const displayTiers = computed(() => { const displayTiers = computed(() => {
@@ -1035,10 +1219,11 @@ const displayTiers = computed(() => {
// 否则用历史价格构建单阶梯(无上限) // 否则用历史价格构建单阶梯(无上限)
return [{ return [{
up_to: null, up_to: null,
input_price_per_1m: detail.value.input_price_per_1m || 0, input_price_per_1m: effectiveInputPricePer1M.value ?? 0,
output_price_per_1m: detail.value.output_price_per_1m || 0, output_price_per_1m: effectiveOutputPricePer1M.value ?? 0,
cache_creation_price_per_1m: detail.value.cache_creation_price_per_1m, cache_creation_price_per_1m: effectiveCacheCreationPricePer1M.value,
cache_read_price_per_1m: detail.value.cache_read_price_per_1m cache_read_price_per_1m: effectiveCacheReadPricePer1M.value,
cache_ttl_pricing: fallbackCacheTtlPricing.value,
}] }]
}) })
@@ -1051,6 +1236,11 @@ const currentTierIndex = computed(() => {
return detail.value.tiered_pricing.tier_index return detail.value.tiered_pricing.tier_index
} }
const snapshotTierIndex = getNestedNumber(billingSnapshot.value, 'tier_index')
if (snapshotTierIndex !== null) {
return Math.max(0, Math.trunc(snapshotTierIndex))
}
// 单阶梯时默认是第0阶 // 单阶梯时默认是第0阶
return 0 return 0
}) })
@@ -1064,9 +1254,9 @@ const currentTier = computed<PricingTierLike | null>(() => {
const cacheCreationSummaryText = computed(() => { const cacheCreationSummaryText = computed(() => {
if (!detail.value) return '0' if (!detail.value) return '0'
const total = detail.value.cache_creation_input_tokens || 0 const total = totalCacheCreationTokens.value
const cache5m = detail.value.cache_creation_input_tokens_5m || 0 const cache5m = cacheCreationInputTokens5m.value
const cache1h = detail.value.cache_creation_input_tokens_1h || 0 const cache1h = cacheCreationInputTokens1h.value
if (cache5m <= 0 && cache1h <= 0) { if (cache5m <= 0 && cache1h <= 0) {
return formatNumber(total) return formatNumber(total)
@@ -1091,8 +1281,8 @@ const cacheCreationSplitRows = computed(() => {
cost: number | null cost: number | null
}> = [] }> = []
const cache5m = detail.value.cache_creation_input_tokens_5m || 0 const cache5m = cacheCreationInputTokens5m.value
const cache1h = detail.value.cache_creation_input_tokens_1h || 0 const cache1h = cacheCreationInputTokens1h.value
if (cache5m > 0) { if (cache5m > 0) {
const pricePer1M = getActiveCachePriceForTTL(5, 'cache_creation_price_per_1m') const pricePer1M = getActiveCachePriceForTTL(5, 'cache_creation_price_per_1m')
@@ -1130,7 +1320,7 @@ const _totalInputContext = computed(() => {
// 否则手动计算 // 否则手动计算
const input = detail.value.tokens?.input || detail.value.input_tokens || 0 const input = detail.value.tokens?.input || detail.value.input_tokens || 0
const cacheCreation = detail.value.cache_creation_input_tokens || 0 const cacheCreation = totalCacheCreationTokens.value
const cacheRead = detail.value.cache_read_input_tokens || 0 const cacheRead = detail.value.cache_read_input_tokens || 0
return input + cacheCreation + cacheRead return input + cacheCreation + cacheRead
}) })
@@ -1138,11 +1328,10 @@ const _totalInputContext = computed(() => {
// Token 费用总计 // Token 费用总计
const tokenCostTotal = computed(() => { const tokenCostTotal = computed(() => {
if (!detail.value) return 0 if (!detail.value) return 0
const inputCost = detail.value.cost?.input || detail.value.input_cost || 0 return effectiveInputCost.value
const outputCost = detail.value.cost?.output || detail.value.output_cost || 0 + effectiveOutputCost.value
const cacheCreationCost = detail.value.cache_creation_cost || 0 + effectiveCacheCreationCost.value
const cacheReadCost = detail.value.cache_read_cost || 0 + effectiveCacheReadCost.value
return inputCost + outputCost + cacheCreationCost + cacheReadCost
}) })
// 按次计费费用(非视频任务时) // 按次计费费用(非视频任务时)
@@ -1150,7 +1339,7 @@ const perRequestCost = computed(() => {
if (!detail.value) return 0 if (!detail.value) return 0
// 视频任务的 request_cost 实际上是视频费用,不算按次 // 视频任务的 request_cost 实际上是视频费用,不算按次
if (detail.value.video_billing) return 0 if (detail.value.video_billing) return 0
return detail.value.request_cost || 0 return effectiveRequestCost.value
}) })
// 视频/图像/音频费用 // 视频/图像/音频费用
@@ -1167,7 +1356,7 @@ const hasTokenCost = computed(() => {
if (!detail.value) return false if (!detail.value) return false
const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0 const inputTokens = detail.value.tokens?.input || detail.value.input_tokens || 0
const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0 const outputTokens = detail.value.tokens?.output || detail.value.output_tokens || 0
const cacheCreation = detail.value.cache_creation_input_tokens || 0 const cacheCreation = totalCacheCreationTokens.value
const cacheRead = detail.value.cache_read_input_tokens || 0 const cacheRead = detail.value.cache_read_input_tokens || 0
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0 return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
}) })
@@ -1208,12 +1397,25 @@ function getTierCachePriceForTTL(
if (ttlPricing.length === 0) return fallback if (ttlPricing.length === 0) return fallback
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) >= ttlMinutes) const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes)
|| ttlPricing[ttlPricing.length - 1]
const price = toFiniteNumber(matched?.[priceKey]) const price = toFiniteNumber(matched?.[priceKey])
return price ?? fallback return price ?? fallback
} }
function getTierMatchedCachePricingEntry(
tier: PricingTierLike | null | undefined,
ttlMinutes: number,
): CacheTTLPriceEntry | null {
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing)
? tier.cache_ttl_pricing
.filter((entry): entry is CacheTTLPriceEntry => !!entry && typeof entry === 'object')
.sort((a, b) => Number(a.ttl_minutes || 0) - Number(b.ttl_minutes || 0))
: []
if (ttlPricing.length === 0) return null
return ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes) ?? null
}
function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefined): boolean { function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefined): boolean {
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : [] const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : []
return ttlPricing.some((entry) => return ttlPricing.some((entry) =>
@@ -1222,6 +1424,45 @@ function hasTierCacheCreationSplitPricing(tier: PricingTierLike | null | undefin
) )
} }
function formatCacheTtlLabel(ttlMinutes: number | null | undefined): string {
if (!ttlMinutes || ttlMinutes <= 0) return '缓存创建'
if (ttlMinutes >= 60) return '缓存创建(1h)'
if (ttlMinutes <= 5) return '缓存创建(5min)'
return `缓存创建(${ttlMinutes}min)`
}
function getTierActiveCacheCreationDisplay(
tier: PricingTierLike | null | undefined,
): { label: string, price: number } | null {
if (hasTierCacheCreationSplitPricing(tier)) {
const activeTtl = activeCacheTtlMinutes.value
if (activeTtl !== null) {
const matchedEntry = getTierMatchedCachePricingEntry(tier, activeTtl)
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_creation_price_per_1m')
if (matchedEntry && matchedPrice !== null) {
return {
label: formatCacheTtlLabel(matchedEntry.ttl_minutes),
price: matchedPrice,
}
}
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
if (fallbackPrice !== null) {
return {
label: formatCacheTtlLabel(activeTtl),
price: fallbackPrice,
}
}
}
}
const fallbackPrice = toFiniteNumber(tier?.cache_creation_price_per_1m)
if (fallbackPrice === null) return null
return {
label: '缓存创建',
price: fallbackPrice,
}
}
function getActiveCachePriceForTTL( function getActiveCachePriceForTTL(
ttlMinutes: number, ttlMinutes: number,
priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m', priceKey: 'cache_creation_price_per_1m' | 'cache_read_price_per_1m',
@@ -1235,6 +1476,22 @@ function getActiveCachePriceForTTL(
return toFiniteNumber(detail.value?.cache_read_price_per_1m) return toFiniteNumber(detail.value?.cache_read_price_per_1m)
} }
function getTierActiveCacheReadPrice(tier: PricingTierLike | null | undefined): number | null {
const activeTtl = activeCacheTtlMinutes.value
if (activeTtl !== null) {
const matchedPrice = getTierCachePriceForTTL(tier, activeTtl, 'cache_read_price_per_1m')
if (matchedPrice !== null) return matchedPrice
}
return toFiniteNumber(tier?.cache_read_price_per_1m)
?? effectiveCacheReadPricePer1M.value
?? null
}
function shouldShowCacheReadPrice(tier: PricingTierLike | null | undefined): boolean {
return getTierActiveCacheReadPrice(tier) !== null
}
function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' { function getDefaultDataSourceForTab(tab: string): 'client' | 'provider' {
if (!detail.value) { if (!detail.value) {
if (['request-headers', 'request-body'].includes(tab)) return 'provider' if (['request-headers', 'request-body'].includes(tab)) return 'provider'