mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
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:
+2
-2
@@ -9,7 +9,7 @@ use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, ConversionMode, ExecutionStrategy,
|
||||
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 super::{
|
||||
@@ -105,7 +105,7 @@ pub(crate) async fn materialize_local_same_format_provider_candidate_attempts(
|
||||
)
|
||||
.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 affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::ai_pipeline::transport::{
|
||||
use crate::ai_pipeline::{
|
||||
collect_control_headers, ConversionMode, ExecutionStrategy, PlannerAppState,
|
||||
};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::clock::current_unix_ms;
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||
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,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local same-format decision failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
|
||||
};
|
||||
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};
|
||||
|
||||
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;
|
||||
|
||||
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 affinity_remembered = false;
|
||||
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,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local gemini files failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::ai_pipeline::{
|
||||
resolve_local_decision_execution_runtime_auth_context, GatewayControlDecision,
|
||||
};
|
||||
use crate::ai_pipeline::{GatewayAuthApiKeySnapshot, PlannerAppState};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::clock::{current_unix_ms, current_unix_secs};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -148,7 +148,7 @@ async fn materialize_local_video_create_candidate_attempts(
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.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 affinity_remembered = false;
|
||||
|
||||
@@ -220,7 +220,7 @@ pub(super) async fn mark_skipped_local_video_candidate(
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local video decision failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::ai_pipeline::{
|
||||
GatewayControlDecision,
|
||||
};
|
||||
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 super::{
|
||||
@@ -145,7 +145,7 @@ pub(super) async fn materialize_local_standard_candidate_attempts(
|
||||
)
|
||||
.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 affinity_remembered = false;
|
||||
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::ai_pipeline::transport::{
|
||||
};
|
||||
use crate::ai_pipeline::{collect_control_headers, ConversionMode, ExecutionStrategy};
|
||||
use crate::ai_pipeline::{LocalResolvedOAuthRequestAuth, PlannerAppState};
|
||||
use crate::clock::current_unix_secs;
|
||||
use crate::clock::current_unix_ms;
|
||||
use crate::{
|
||||
append_execution_contract_fields_to_value, AppState, GatewayControlSyncDecisionResponse,
|
||||
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,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local standard decision failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ use crate::ai_pipeline::planner::candidate_affinity::{
|
||||
};
|
||||
use crate::ai_pipeline::GatewayAuthApiKeySnapshot;
|
||||
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};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -57,7 +57,7 @@ pub(crate) async fn mark_skipped_local_openai_chat_candidate(
|
||||
candidate_id,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local openai chat decision failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
@@ -77,7 +77,7 @@ pub(crate) async fn materialize_local_openai_chat_candidate_attempts(
|
||||
input.required_capabilities.as_ref(),
|
||||
)
|
||||
.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 affinity_remembered = false;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::ai_pipeline::{
|
||||
GatewayControlDecision,
|
||||
};
|
||||
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 super::LocalOpenAiCliSpec;
|
||||
@@ -152,7 +152,7 @@ pub(crate) async fn materialize_local_openai_cli_candidate_attempts(
|
||||
)
|
||||
.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 affinity_remembered = false;
|
||||
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,
|
||||
input.required_capabilities.as_ref(),
|
||||
skip_reason,
|
||||
current_unix_secs(),
|
||||
current_unix_ms(),
|
||||
"gateway local openai cli decision failed to persist skipped candidate",
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -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(ttl_pricing) = tier.get("cache_ttl_pricing").and_then(|v| v.as_array()) {
|
||||
for ttl_entry in ttl_pricing {
|
||||
let ttl_limit = ttl_entry
|
||||
if let Some(ttl_entry) = ttl_pricing.iter().find(|entry| {
|
||||
entry
|
||||
.get("ttl_minutes")
|
||||
.and_then(as_f64)
|
||||
.unwrap_or_default();
|
||||
if cache_ttl_minutes <= ttl_limit {
|
||||
if let Some(value) = ttl_entry.get(ttl_value_key) {
|
||||
return Ok((
|
||||
value.clone(),
|
||||
false,
|
||||
Some((matched_index.unwrap_or(0), tier)),
|
||||
));
|
||||
}
|
||||
.map(|value| value == cache_ttl_minutes)
|
||||
.unwrap_or(false)
|
||||
}) {
|
||||
if let Some(value) = ttl_entry.get(ttl_value_key).filter(|value| !value.is_null()) {
|
||||
return Ok((
|
||||
value.clone(),
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,4 +264,142 @@ mod tests {
|
||||
assert!(result.actual_total_cost > 0.0);
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,19 +237,12 @@
|
||||
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<template v-if="hasTierCacheCreationSplitPricing(tier)">
|
||||
<span v-if="getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') !== null">
|
||||
缓存创建(5min) ${{ formatPrice(getTierCachePriceForTTL(tier, 5, 'cache_creation_price_per_1m') || 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 v-if="getTierActiveCacheCreationDisplay(tier)">
|
||||
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
|
||||
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
|
||||
</span>
|
||||
<span v-if="tier.cache_read_price_per_1m">
|
||||
缓存读取 ${{ formatPrice(tier.cache_read_price_per_1m) }}/M
|
||||
<span v-if="shouldShowCacheReadPrice(tier)">
|
||||
缓存读取 ${{ formatPrice(getTierActiveCacheReadPrice(tier) ?? 0) }}/M
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,7 +254,7 @@
|
||||
<div class="flex items-center flex-1">
|
||||
<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-xs font-mono">${{ (detail.cost?.input || detail.input_cost || 0).toFixed(6) }}</span>
|
||||
<span class="text-xs font-mono">${{ effectiveInputCost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
@@ -270,15 +263,15 @@
|
||||
<div class="flex items-center flex-1">
|
||||
<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-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 class="flex items-center">
|
||||
<div class="flex items-center flex-1">
|
||||
<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-xs font-mono">${{ (detail.cache_creation_cost || 0).toFixed(6) }}</span>
|
||||
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ totalCacheCreationTokens }}</span>
|
||||
<span class="text-xs font-mono">${{ effectiveCacheCreationCost.toFixed(6) }}</span>
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
@@ -287,7 +280,7 @@
|
||||
<div class="flex items-center flex-1">
|
||||
<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-xs font-mono">${{ (detail.cache_read_cost || 0).toFixed(6) }}</span>
|
||||
<span class="text-xs font-mono">${{ effectiveCacheReadCost.toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 缓存创建 5m/1h 细分 -->
|
||||
@@ -323,10 +316,10 @@
|
||||
</div>
|
||||
<div class="rounded-lg p-3 bg-primary/5 border border-primary/30 space-y-2">
|
||||
<div
|
||||
v-if="detail.price_per_request"
|
||||
v-if="effectivePricePerRequest > 0"
|
||||
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 class="flex items-center">
|
||||
<div class="flex items-center flex-1">
|
||||
@@ -753,10 +746,55 @@ type CacheTTLPriceEntry = {
|
||||
}
|
||||
|
||||
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_read_price_per_1m?: number | 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 autoRefreshing = ref(false)
|
||||
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>
|
||||
})
|
||||
|
||||
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 {
|
||||
return Boolean(flag) || hasContent(data)
|
||||
}
|
||||
@@ -1022,6 +1076,136 @@ const priceSourceLabel = computed(() => {
|
||||
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,使用它;否则用历史价格构建单阶梯
|
||||
const displayTiers = computed(() => {
|
||||
@@ -1035,10 +1219,11 @@ const displayTiers = computed(() => {
|
||||
// 否则用历史价格构建单阶梯(无上限)
|
||||
return [{
|
||||
up_to: null,
|
||||
input_price_per_1m: detail.value.input_price_per_1m || 0,
|
||||
output_price_per_1m: detail.value.output_price_per_1m || 0,
|
||||
cache_creation_price_per_1m: detail.value.cache_creation_price_per_1m,
|
||||
cache_read_price_per_1m: detail.value.cache_read_price_per_1m
|
||||
input_price_per_1m: effectiveInputPricePer1M.value ?? 0,
|
||||
output_price_per_1m: effectiveOutputPricePer1M.value ?? 0,
|
||||
cache_creation_price_per_1m: effectiveCacheCreationPricePer1M.value,
|
||||
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
|
||||
}
|
||||
|
||||
const snapshotTierIndex = getNestedNumber(billingSnapshot.value, 'tier_index')
|
||||
if (snapshotTierIndex !== null) {
|
||||
return Math.max(0, Math.trunc(snapshotTierIndex))
|
||||
}
|
||||
|
||||
// 单阶梯时默认是第0阶
|
||||
return 0
|
||||
})
|
||||
@@ -1064,9 +1254,9 @@ const currentTier = computed<PricingTierLike | null>(() => {
|
||||
const cacheCreationSummaryText = computed(() => {
|
||||
if (!detail.value) return '0'
|
||||
|
||||
const total = detail.value.cache_creation_input_tokens || 0
|
||||
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
|
||||
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
|
||||
const total = totalCacheCreationTokens.value
|
||||
const cache5m = cacheCreationInputTokens5m.value
|
||||
const cache1h = cacheCreationInputTokens1h.value
|
||||
|
||||
if (cache5m <= 0 && cache1h <= 0) {
|
||||
return formatNumber(total)
|
||||
@@ -1091,8 +1281,8 @@ const cacheCreationSplitRows = computed(() => {
|
||||
cost: number | null
|
||||
}> = []
|
||||
|
||||
const cache5m = detail.value.cache_creation_input_tokens_5m || 0
|
||||
const cache1h = detail.value.cache_creation_input_tokens_1h || 0
|
||||
const cache5m = cacheCreationInputTokens5m.value
|
||||
const cache1h = cacheCreationInputTokens1h.value
|
||||
|
||||
if (cache5m > 0) {
|
||||
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 cacheCreation = detail.value.cache_creation_input_tokens || 0
|
||||
const cacheCreation = totalCacheCreationTokens.value
|
||||
const cacheRead = detail.value.cache_read_input_tokens || 0
|
||||
return input + cacheCreation + cacheRead
|
||||
})
|
||||
@@ -1138,11 +1328,10 @@ const _totalInputContext = computed(() => {
|
||||
// Token 费用总计
|
||||
const tokenCostTotal = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
const inputCost = detail.value.cost?.input || detail.value.input_cost || 0
|
||||
const outputCost = detail.value.cost?.output || detail.value.output_cost || 0
|
||||
const cacheCreationCost = detail.value.cache_creation_cost || 0
|
||||
const cacheReadCost = detail.value.cache_read_cost || 0
|
||||
return inputCost + outputCost + cacheCreationCost + cacheReadCost
|
||||
return effectiveInputCost.value
|
||||
+ effectiveOutputCost.value
|
||||
+ effectiveCacheCreationCost.value
|
||||
+ effectiveCacheReadCost.value
|
||||
})
|
||||
|
||||
// 按次计费费用(非视频任务时)
|
||||
@@ -1150,7 +1339,7 @@ const perRequestCost = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
// 视频任务的 request_cost 实际上是视频费用,不算按次
|
||||
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
|
||||
const inputTokens = detail.value.tokens?.input || detail.value.input_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
|
||||
return (inputTokens + outputTokens + cacheCreation + cacheRead) > 0 || tokenCostTotal.value > 0
|
||||
})
|
||||
@@ -1208,12 +1397,25 @@ function getTierCachePriceForTTL(
|
||||
|
||||
if (ttlPricing.length === 0) return fallback
|
||||
|
||||
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) >= ttlMinutes)
|
||||
|| ttlPricing[ttlPricing.length - 1]
|
||||
const matched = ttlPricing.find((entry) => Number(entry.ttl_minutes || 0) === ttlMinutes)
|
||||
const price = toFiniteNumber(matched?.[priceKey])
|
||||
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 {
|
||||
const ttlPricing = Array.isArray(tier?.cache_ttl_pricing) ? tier.cache_ttl_pricing : []
|
||||
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(
|
||||
ttlMinutes: number,
|
||||
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)
|
||||
}
|
||||
|
||||
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' {
|
||||
if (!detail.value) {
|
||||
if (['request-headers', 'request-body'].includes(tab)) return 'provider'
|
||||
|
||||
Reference in New Issue
Block a user