mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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:
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user