mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 13:10:21 +08:00
Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714
This commit is contained in:
@@ -100,10 +100,15 @@ pub(crate) async fn execution_plan_balance_capacity_rejection(
|
||||
let Some(auth_context) = decision.auth_context.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if auth_context.api_key_is_standalone || auth_context.local_rejection.is_some() {
|
||||
if auth_context.local_rejection.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if auth_context.api_key_is_standalone {
|
||||
validate_execution_plan_pricing_configuration_for_plan(state, plan, report_context).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(available_usd) = available_balance_capacity_usd(state, auth_context).await? else {
|
||||
validate_execution_plan_pricing_configuration_for_plan(state, plan, report_context).await?;
|
||||
return Ok(None);
|
||||
};
|
||||
match estimate_execution_plan_cost_upper_bound_usd(state, plan, report_context).await? {
|
||||
@@ -124,6 +129,27 @@ pub(crate) async fn execution_plan_balance_capacity_rejection(
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_execution_plan_pricing_configuration_for_plan(
|
||||
state: &AppState,
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
report_context: Option<&serde_json::Value>,
|
||||
) -> Result<(), GatewayError> {
|
||||
let model_id = report_context_string_field(report_context, "model_id");
|
||||
let global_model_name = report_context_string_field(report_context, "global_model_name");
|
||||
let requested_processing_tier =
|
||||
aether_data_contracts::repository::usage::extract_provider_service_tier_from_body(
|
||||
plan.body.json_body.as_ref(),
|
||||
);
|
||||
validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state,
|
||||
plan,
|
||||
model_id,
|
||||
global_model_name,
|
||||
requested_processing_tier.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn available_balance_capacity_usd(
|
||||
state: &AppState,
|
||||
auth_context: &GatewayControlAuthContext,
|
||||
@@ -169,28 +195,61 @@ async fn estimate_execution_plan_cost_upper_bound_usd(
|
||||
report_context: Option<&serde_json::Value>,
|
||||
) -> Result<Option<f64>, GatewayError> {
|
||||
let api_format = crate::ai_serving::normalize_api_format_alias(&plan.provider_api_format);
|
||||
let body_json = plan.body.json_body.as_ref();
|
||||
let requested_processing_tier =
|
||||
aether_data_contracts::repository::usage::extract_provider_service_tier_from_body(
|
||||
body_json,
|
||||
);
|
||||
let model_id = report_context_string_field(report_context, "model_id");
|
||||
let global_model_name = report_context_string_field(report_context, "global_model_name");
|
||||
let Some(task_type) = authorization_task_type(&api_format, report_context) else {
|
||||
validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state,
|
||||
plan,
|
||||
model_id,
|
||||
global_model_name,
|
||||
requested_processing_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(body_json) = plan.body.json_body.as_ref() else {
|
||||
let Some(body_json) = body_json else {
|
||||
validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state,
|
||||
plan,
|
||||
model_id,
|
||||
global_model_name,
|
||||
requested_processing_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
};
|
||||
if !openai_request_input_is_self_contained(&api_format, body_json) {
|
||||
validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state,
|
||||
plan,
|
||||
model_id,
|
||||
global_model_name,
|
||||
requested_processing_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
}
|
||||
let input_tokens = json_token_count_upper_bound(body_json);
|
||||
let Ok(input_tokens) = i64::try_from(input_tokens) else {
|
||||
validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state,
|
||||
plan,
|
||||
model_id,
|
||||
global_model_name,
|
||||
requested_processing_tier.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let max_output_tokens = max_output_tokens_from_request(body_json)
|
||||
.map(|value| value.saturating_mul(output_choice_count_upper_bound(&api_format, body_json)))
|
||||
.and_then(|value| i64::try_from(value).ok());
|
||||
let requested_processing_tier =
|
||||
aether_data_contracts::repository::usage::extract_provider_service_tier_from_body(Some(
|
||||
body_json,
|
||||
));
|
||||
let model_id = report_context_string_field(report_context, "model_id");
|
||||
let global_model_name = report_context_string_field(report_context, "global_model_name");
|
||||
let cache_ttl_minutes =
|
||||
aether_data_contracts::repository::usage::resolve_provider_cache_ttl_minutes(
|
||||
Some(&api_format),
|
||||
@@ -262,6 +321,55 @@ async fn calculate_execution_plan_cost_upper_bound(
|
||||
requested_processing_tier: Option<&str>,
|
||||
cache_ttl_minutes: Option<i64>,
|
||||
) -> Result<Option<f64>, GatewayError> {
|
||||
let context =
|
||||
load_execution_plan_billing_context(state, plan, model_id, global_model_name).await?;
|
||||
let Some(context) = context else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut estimate =
|
||||
aether_billing::BillingAuthorizationEstimateInput::new(task_type, input_tokens);
|
||||
estimate.api_format = Some(api_format.to_string());
|
||||
estimate.requested_processing_tier = requested_processing_tier.map(ToOwned::to_owned);
|
||||
estimate.cache_ttl_minutes = cache_ttl_minutes;
|
||||
estimate.max_output_tokens = max_output_tokens;
|
||||
aether_billing::BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(
|
||||
&aether_billing::BillingModelPricingSnapshot::from(context),
|
||||
&estimate,
|
||||
)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn validate_execution_plan_pricing_for_unavailable_estimate(
|
||||
state: &AppState,
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
model_id: Option<&str>,
|
||||
global_model_name: Option<&str>,
|
||||
requested_processing_tier: Option<&str>,
|
||||
) -> Result<(), GatewayError> {
|
||||
if model_id.is_none() && global_model_name.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let _permit = state.acquire_auth_snapshot_load_gate().await?;
|
||||
let Some(context) =
|
||||
load_execution_plan_billing_context(state, plan, model_id, global_model_name).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
aether_billing::BillingModelPricingSnapshot::from(context)
|
||||
.validate_authorization_pricing_configuration(requested_processing_tier)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
async fn load_execution_plan_billing_context(
|
||||
state: &AppState,
|
||||
plan: &aether_contracts::ExecutionPlan,
|
||||
model_id: Option<&str>,
|
||||
global_model_name: Option<&str>,
|
||||
) -> Result<
|
||||
Option<aether_data_contracts::repository::billing::StoredBillingModelContext>,
|
||||
GatewayError,
|
||||
> {
|
||||
let context = match model_id {
|
||||
Some(model_id) => state
|
||||
.data
|
||||
@@ -278,21 +386,7 @@ async fn calculate_execution_plan_cost_upper_bound(
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?,
|
||||
};
|
||||
let Some(context) = context else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut estimate =
|
||||
aether_billing::BillingAuthorizationEstimateInput::new(task_type, input_tokens);
|
||||
estimate.api_format = Some(api_format.to_string());
|
||||
estimate.requested_processing_tier = requested_processing_tier.map(ToOwned::to_owned);
|
||||
estimate.cache_ttl_minutes = cache_ttl_minutes;
|
||||
estimate.max_output_tokens = max_output_tokens;
|
||||
aether_billing::BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(
|
||||
&aether_billing::BillingModelPricingSnapshot::from(context),
|
||||
&estimate,
|
||||
)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
fn execution_plan_cost_upper_bound_cache_key(
|
||||
@@ -1099,6 +1193,210 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn positive_balance_does_not_allow_historical_invalid_processing_pricing() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 1.0,
|
||||
"output_price_per_1m": 2.0
|
||||
}],
|
||||
"processing_tiers": {
|
||||
"priority": {
|
||||
"tiers": [{}],
|
||||
"price_multiplier": -1.0
|
||||
}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let state = state_with_quota_and_wallet(quota_availability(50.0, true), context);
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let plan = execution_plan(
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"service_tier": "priority",
|
||||
"max_completion_tokens": 1
|
||||
}),
|
||||
"openai:chat",
|
||||
);
|
||||
|
||||
let error = execution_plan_balance_capacity_rejection(
|
||||
&state,
|
||||
&decision,
|
||||
&plan,
|
||||
Some(&billing_report_context()),
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid configured processing pricing must stop authorization");
|
||||
|
||||
assert!(error
|
||||
.into_message()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_key_does_not_bypass_invalid_processing_pricing_validation() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{}], "price_multiplier": 2.0}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let state = state_with_quota_and_wallet(quota_availability(50.0, true), context);
|
||||
let mut decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
decision
|
||||
.auth_context
|
||||
.as_mut()
|
||||
.expect("auth context should exist")
|
||||
.api_key_is_standalone = true;
|
||||
let plan = execution_plan(
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"service_tier": "priority",
|
||||
"max_completion_tokens": 1
|
||||
}),
|
||||
"openai:chat",
|
||||
);
|
||||
|
||||
let error = execution_plan_balance_capacity_rejection(
|
||||
&state,
|
||||
&decision,
|
||||
&plan,
|
||||
Some(&billing_report_context()),
|
||||
)
|
||||
.await
|
||||
.expect_err("standalone keys must still validate configured pricing");
|
||||
assert!(error
|
||||
.into_message()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlimited_wallet_does_not_bypass_invalid_processing_pricing_validation() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {"tiers": [{}], "price_multiplier": 2.0}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let state = state_with_quota_and_wallet(quota_availability(0.0, true), context);
|
||||
{
|
||||
let store = state
|
||||
.auth_wallet_store
|
||||
.as_ref()
|
||||
.expect("test wallet store should exist");
|
||||
let mut wallets = store.lock().expect("wallet store should lock");
|
||||
wallets
|
||||
.get_mut("wallet-user-1")
|
||||
.expect("test wallet should exist")
|
||||
.limit_mode = "unlimited".to_string();
|
||||
}
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let plan = execution_plan(
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"service_tier": "priority",
|
||||
"max_completion_tokens": 1
|
||||
}),
|
||||
"openai:chat",
|
||||
);
|
||||
|
||||
let error = execution_plan_balance_capacity_rejection(
|
||||
&state,
|
||||
&decision,
|
||||
&plan,
|
||||
Some(&billing_report_context()),
|
||||
)
|
||||
.await
|
||||
.expect_err("unlimited wallets must still validate configured pricing");
|
||||
assert!(error
|
||||
.into_message()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_and_unlimited_paths_keep_allowing_valid_pricing() {
|
||||
let valid_context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 1.0}]
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let plan = execution_plan(
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_completion_tokens": 1
|
||||
}),
|
||||
"openai:chat",
|
||||
);
|
||||
let report_context = billing_report_context();
|
||||
|
||||
let standalone_state =
|
||||
state_with_quota_and_wallet(quota_availability(50.0, true), valid_context.clone());
|
||||
let mut standalone_decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
standalone_decision
|
||||
.auth_context
|
||||
.as_mut()
|
||||
.expect("auth context should exist")
|
||||
.api_key_is_standalone = true;
|
||||
assert_eq!(
|
||||
execution_plan_balance_capacity_rejection(
|
||||
&standalone_state,
|
||||
&standalone_decision,
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
)
|
||||
.await
|
||||
.expect("valid standalone pricing should resolve"),
|
||||
None
|
||||
);
|
||||
|
||||
let unlimited_state =
|
||||
state_with_quota_and_wallet(quota_availability(0.0, true), valid_context);
|
||||
{
|
||||
let store = unlimited_state
|
||||
.auth_wallet_store
|
||||
.as_ref()
|
||||
.expect("test wallet store should exist");
|
||||
let mut wallets = store.lock().expect("wallet store should lock");
|
||||
wallets
|
||||
.get_mut("wallet-user-1")
|
||||
.expect("test wallet should exist")
|
||||
.limit_mode = "unlimited".to_string();
|
||||
}
|
||||
assert_eq!(
|
||||
execution_plan_balance_capacity_rejection(
|
||||
&unlimited_state,
|
||||
&decision_with_allowed_models(vec!["gpt-5".to_string()]),
|
||||
&plan,
|
||||
Some(&report_context),
|
||||
)
|
||||
.await
|
||||
.expect("valid unlimited-wallet pricing should resolve"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_capacity_reuses_quota_wallet_and_cost_estimate_within_ttl() {
|
||||
let context = billing_context_with_pricing(
|
||||
@@ -1370,6 +1668,53 @@ mod tests {
|
||||
assert_eq!(rejection, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stateful_unavailable_estimate_still_rejects_invalid_processing_pricing() {
|
||||
let context = billing_context_with_pricing(
|
||||
Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 1.0,
|
||||
"output_price_per_1m": 2.0
|
||||
}],
|
||||
"processing_tiers": {
|
||||
"priority": {
|
||||
"tiers": [{}],
|
||||
"price_multiplier": 2.0
|
||||
}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let state = state_with_quota_and_wallet(quota_availability(1.0, false), context);
|
||||
let decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
|
||||
let plan = execution_plan(
|
||||
json!({
|
||||
"model": "gpt-5",
|
||||
"input": "continue",
|
||||
"previous_response_id": "resp_123",
|
||||
"service_tier": "priority",
|
||||
"max_output_tokens": 1
|
||||
}),
|
||||
"openai:responses",
|
||||
);
|
||||
|
||||
let error = execution_plan_balance_capacity_rejection(
|
||||
&state,
|
||||
&decision,
|
||||
&plan,
|
||||
Some(&billing_report_context()),
|
||||
)
|
||||
.await
|
||||
.expect_err("unavailable estimates must still validate configured processing pricing");
|
||||
|
||||
assert!(error
|
||||
.into_message()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wallet_overage_policy_extends_known_cost_capacity_when_enabled() {
|
||||
let context = billing_context_with_pricing(
|
||||
|
||||
@@ -3508,7 +3508,17 @@ mod tests {
|
||||
"settlement_snapshot": {
|
||||
"schema_version": "3.0",
|
||||
"pricing_snapshot": {
|
||||
"pricing_source": "provider_override"
|
||||
"pricing_source": "provider_override",
|
||||
"tiered_pricing_source": "provider_override",
|
||||
"billing_processing_tier": "fast",
|
||||
"processing_tier_price_multiplier": 2.5,
|
||||
"tiered_pricing": {
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 7.5,
|
||||
"output_price_per_1m": 37.5
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"billing_snapshot": {
|
||||
@@ -3549,6 +3559,21 @@ mod tests {
|
||||
payload["settlement"]["settlement_snapshot"]["pricing_snapshot"]["pricing_source"],
|
||||
"provider_override"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["settlement"]["settlement_snapshot"]["pricing_snapshot"]
|
||||
["billing_processing_tier"],
|
||||
"fast"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["settlement"]["settlement_snapshot"]["pricing_snapshot"]
|
||||
["processing_tier_price_multiplier"],
|
||||
2.5
|
||||
);
|
||||
assert_eq!(
|
||||
payload["settlement"]["settlement_snapshot"]["pricing_snapshot"]["tiered_pricing"]
|
||||
["tiers"][0]["input_price_per_1m"],
|
||||
7.5
|
||||
);
|
||||
assert_eq!(
|
||||
payload["settlement"]["billing_dimensions"]["input_tokens"],
|
||||
35
|
||||
|
||||
@@ -379,6 +379,7 @@ fn build_settlement_snapshot(
|
||||
"billing_processing_tier": resolution.billing_processing_tier,
|
||||
"pricing_source": resolution.pricing_source(),
|
||||
"tiered_pricing_source": resolution.tiered_pricing_source.map(|source| source.as_str()),
|
||||
"processing_tier_price_multiplier": resolution.processing_tier_price_multiplier,
|
||||
"price_per_request_source": resolution.price_per_request_source.map(|source| source.as_str()),
|
||||
"tiered_pricing": resolution.tiered_pricing,
|
||||
"price_per_request": resolution.price_per_request,
|
||||
@@ -460,6 +461,22 @@ mod tests {
|
||||
assert_eq!(tiers.actual.as_deref(), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_tier_facts_recognize_anthropic_fast_speed() {
|
||||
let data = UsageEventData {
|
||||
provider_request_body: Some(json!({"speed": "fast"})),
|
||||
response_body: Some(json!({
|
||||
"usage": {"speed": "fast", "service_tier": "standard"}
|
||||
})),
|
||||
..UsageEventData::default()
|
||||
};
|
||||
|
||||
let tiers = usage_event_processing_tiers(&data);
|
||||
|
||||
assert_eq!(tiers.requested.as_deref(), Some("fast"));
|
||||
assert_eq!(tiers.actual.as_deref(), Some("fast"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settlement_uses_effective_gpt_5_6_cache_ttl_after_body_capture() {
|
||||
let lookup = TestLookup {
|
||||
@@ -637,7 +654,7 @@ mod tests {
|
||||
Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 5.0, "output_price_per_1m": 30.0}],
|
||||
"processing_tiers": {
|
||||
"flex": {"tiers": [{"up_to": null, "input_price_per_1m": 2.5, "output_price_per_1m": 15.0}]}
|
||||
"flex": {"price_multiplier": 0.5}
|
||||
}
|
||||
})),
|
||||
Some("model-1".to_string()),
|
||||
@@ -692,6 +709,7 @@ mod tests {
|
||||
assert_eq!(pricing_snapshot["actual_processing_tier"], "flex");
|
||||
assert_eq!(pricing_snapshot["billing_processing_tier"], "flex");
|
||||
assert_eq!(pricing_snapshot["tiered_pricing_source"], "global_default");
|
||||
assert_eq!(pricing_snapshot["processing_tier_price_multiplier"], 0.5);
|
||||
assert_eq!(
|
||||
pricing_snapshot["tiered_pricing"]["tiers"][0]["input_price_per_1m"],
|
||||
2.5
|
||||
|
||||
@@ -24,7 +24,8 @@ pub use precision::{
|
||||
};
|
||||
pub use pricing::{
|
||||
BillingAuthorizationEstimateInput, BillingComputation, BillingModelPricingSnapshot,
|
||||
BillingPricingResolution, BillingPricingSource, BillingUsageInput,
|
||||
BillingPricingConfigurationError, BillingPricingResolution, BillingPricingSource,
|
||||
BillingUsageInput,
|
||||
};
|
||||
pub use schema::{
|
||||
BillingSnapshot, BillingSnapshotStatus, CostResult, BILLING_SNAPSHOT_SCHEMA_VERSION,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,10 +36,12 @@ impl BillingService {
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
input: &BillingUsageInput,
|
||||
) -> Result<BillingComputation, ExpressionEvaluationError> {
|
||||
let pricing_resolution = pricing.resolve_pricing(
|
||||
input.requested_processing_tier.as_deref(),
|
||||
input.actual_processing_tier.as_deref(),
|
||||
);
|
||||
let pricing_resolution = pricing
|
||||
.resolve_pricing_checked(
|
||||
input.requested_processing_tier.as_deref(),
|
||||
input.actual_processing_tier.as_deref(),
|
||||
)
|
||||
.map_err(|err| ExpressionEvaluationError::Failed(err.to_string()))?;
|
||||
self.calculate_with_resolution(pricing, input, pricing_resolution)
|
||||
}
|
||||
|
||||
@@ -48,14 +50,15 @@ impl BillingService {
|
||||
pricing: &BillingModelPricingSnapshot,
|
||||
estimate: &BillingAuthorizationEstimateInput,
|
||||
) -> Result<Option<f64>, ExpressionEvaluationError> {
|
||||
let Some(pricing_resolutions) = pricing
|
||||
.resolve_authorization_pricing_candidates(estimate.requested_processing_tier.as_deref())
|
||||
.map_err(|err| ExpressionEvaluationError::Failed(err.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if normalize_task_type(&estimate.task_type) == "image" {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(pricing_resolutions) = pricing.resolve_authorization_pricing_candidates(
|
||||
estimate.requested_processing_tier.as_deref(),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if pricing.is_free_tier() {
|
||||
return Ok(Some(0.0));
|
||||
}
|
||||
@@ -1164,6 +1167,211 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_multiplier_is_settled_and_authorized_from_standard_prices() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
provider_api_key_rate_multipliers: None,
|
||||
default_price_per_request: Some(0.02),
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 2.0,
|
||||
"output_price_per_1m": 4.0,
|
||||
"cache_creation_price_per_1m": 0.0,
|
||||
"cache_read_price_per_1m": 0.0
|
||||
}],
|
||||
"processing_tiers": {
|
||||
"priority": {"price_multiplier": 2.5}
|
||||
}
|
||||
})),
|
||||
model_price_per_request: None,
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let usage = BillingUsageInput {
|
||||
api_format: Some("openai:responses".to_string()),
|
||||
requested_processing_tier: Some("priority".to_string()),
|
||||
actual_processing_tier: Some("priority".to_string()),
|
||||
input_tokens: 1_000_000,
|
||||
..BillingUsageInput::new("chat")
|
||||
};
|
||||
|
||||
let settled = BillingService::new()
|
||||
.calculate(&pricing, &usage)
|
||||
.expect("multiplier pricing should settle");
|
||||
assert_eq!(settled.cost_result.status, BillingSnapshotStatus::Complete);
|
||||
assert_eq!(settled.cost_result.cost, 5.02);
|
||||
assert_eq!(settled.actual_total_cost, 5.02);
|
||||
assert_eq!(
|
||||
settled.cost_result.snapshot.resolved_variables["input_price_per_1m"],
|
||||
json!(5.0)
|
||||
);
|
||||
assert_eq!(
|
||||
settled.cost_result.snapshot.resolved_variables["price_per_request"],
|
||||
json!(0.02),
|
||||
"processing multiplier must not affect price_per_request"
|
||||
);
|
||||
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000_000);
|
||||
estimate.api_format = Some("openai:responses".to_string());
|
||||
estimate.requested_processing_tier = Some("priority".to_string());
|
||||
estimate.max_output_tokens = Some(0);
|
||||
assert_eq!(
|
||||
BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect("multiplier pricing should authorize"),
|
||||
Some(5.02)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_processing_multiplier_fails_closed_at_settlement_and_authorization() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
provider_api_key_rate_multipliers: None,
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 2.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {"price_multiplier": -1.0}
|
||||
}
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let usage = processing_usage(Some("priority"), Some("priority"), 1_000);
|
||||
|
||||
let settlement_error = BillingService::new()
|
||||
.calculate(&pricing, &usage)
|
||||
.expect_err("invalid multiplier settlement must return a configuration error");
|
||||
assert!(settlement_error
|
||||
.to_string()
|
||||
.contains("price_multiplier must be a non-negative finite number"));
|
||||
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000);
|
||||
estimate.api_format = Some("openai:responses".to_string());
|
||||
estimate.requested_processing_tier = Some("priority".to_string());
|
||||
estimate.max_output_tokens = Some(0);
|
||||
let authorization_error = BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect_err("invalid multiplier authorization must return a configuration error");
|
||||
assert!(authorization_error
|
||||
.to_string()
|
||||
.contains("price_multiplier must be a non-negative finite number"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_processing_catalog_cannot_turn_an_invalid_multiplier_into_zero_cost() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 2.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {
|
||||
"tiers": [{}],
|
||||
"price_multiplier": 2.0
|
||||
}
|
||||
}
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let usage = processing_usage(Some("priority"), Some("priority"), 1_000);
|
||||
|
||||
let settlement_error = BillingService::new()
|
||||
.calculate(&pricing, &usage)
|
||||
.expect_err("malformed processing pricing must not settle as zero");
|
||||
assert!(settlement_error
|
||||
.to_string()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000);
|
||||
estimate.requested_processing_tier = Some("priority".to_string());
|
||||
estimate.max_output_tokens = Some(0);
|
||||
let authorization_error = BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect_err("malformed processing pricing must stop authorization");
|
||||
assert!(authorization_error
|
||||
.to_string()
|
||||
.contains("explicit catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorization_accepts_input_only_processing_catalog() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 0.1}],
|
||||
"processing_tiers": {
|
||||
"embedding": {
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 0.2,
|
||||
"output_price_per_1m": null
|
||||
}]
|
||||
}
|
||||
}
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("embedding", 1_000_000);
|
||||
estimate.requested_processing_tier = Some("embedding".to_string());
|
||||
|
||||
assert_eq!(
|
||||
BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect("input-only processing catalog should be valid"),
|
||||
Some(0.45),
|
||||
"the bound includes the runtime's conservative cache-write fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_image_catalog_is_reported_before_the_unavailable_image_estimate() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_price_per_request: None,
|
||||
default_tiered_pricing: Some(json!({
|
||||
"image_output_price_ranges": {
|
||||
"1048576": {"low": 0.04}
|
||||
}
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let estimate = BillingAuthorizationEstimateInput::new("image", 0);
|
||||
|
||||
let err = BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect_err("an unparseable image range must be a configuration error");
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Standard catalog contains malformed or unrecognized prices"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_noncanonical_processing_tier_key_is_a_configuration_error() {
|
||||
let pricing = BillingModelPricingSnapshot {
|
||||
default_tiered_pricing: Some(json!({
|
||||
"tiers": [{"up_to": null, "input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
"Priority": {"price_multiplier": 2.0}
|
||||
}
|
||||
})),
|
||||
model_tiered_pricing: None,
|
||||
..pricing()
|
||||
};
|
||||
let mut estimate = BillingAuthorizationEstimateInput::new("chat", 1_000);
|
||||
estimate.requested_processing_tier = Some("priority".to_string());
|
||||
estimate.max_output_tokens = Some(0);
|
||||
|
||||
let err = BillingService::new()
|
||||
.estimate_authorization_cost_upper_bound(&pricing, &estimate)
|
||||
.expect_err("a noncanonical historical key must not disappear during lookup");
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("must be canonical lowercase without surrounding whitespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_processing_catalog_and_unknown_actual_tier_fail_closed() {
|
||||
let priority = BillingService::new()
|
||||
|
||||
@@ -3,11 +3,11 @@ mod types;
|
||||
|
||||
pub use snapshot::GlobalModelSnapshot;
|
||||
pub use types::{
|
||||
metadata_supports_embedding, AdminGlobalModelListQuery, AdminProviderModelListQuery,
|
||||
CreateAdminGlobalModelRecord, GlobalModelReadRepository, GlobalModelWriteRepository,
|
||||
PublicCatalogModelListQuery, PublicCatalogModelSearchQuery, PublicGlobalModelQuery,
|
||||
StoredAdminGlobalModel, StoredAdminGlobalModelPage, StoredAdminProviderModel,
|
||||
StoredProviderActiveGlobalModel, StoredProviderModelStats, StoredPublicCatalogModel,
|
||||
StoredPublicGlobalModel, StoredPublicGlobalModelPage, UpdateAdminGlobalModelRecord,
|
||||
UpsertAdminProviderModelRecord,
|
||||
explicit_pricing_catalog_state, metadata_supports_embedding, AdminGlobalModelListQuery,
|
||||
AdminProviderModelListQuery, CreateAdminGlobalModelRecord, ExplicitPricingCatalogState,
|
||||
GlobalModelReadRepository, GlobalModelWriteRepository, PublicCatalogModelListQuery,
|
||||
PublicCatalogModelSearchQuery, PublicGlobalModelQuery, StoredAdminGlobalModel,
|
||||
StoredAdminGlobalModelPage, StoredAdminProviderModel, StoredProviderActiveGlobalModel,
|
||||
StoredProviderModelStats, StoredPublicCatalogModel, StoredPublicGlobalModel,
|
||||
StoredPublicGlobalModelPage, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,394 @@ fn validate_optional_price(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExplicitPricingCatalogState {
|
||||
Absent,
|
||||
Valid,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
const TOKEN_PRICE_FIELDS: &[&str] = &[
|
||||
"input_price_per_1m",
|
||||
"output_price_per_1m",
|
||||
"cache_creation_price_per_1m",
|
||||
"cache_read_price_per_1m",
|
||||
];
|
||||
|
||||
const IMAGE_MATRIX_PRICE_FIELDS: &[&str] = &[
|
||||
"image_output_prices",
|
||||
"image_output_price_per_image",
|
||||
"image_output_price_matrix",
|
||||
"image_prices",
|
||||
];
|
||||
|
||||
/// Classifies whether a JSON object contains a complete pricing catalog understood by the
|
||||
/// default billing runtime.
|
||||
///
|
||||
/// `Absent` deliberately differs from `Invalid`: a provider override containing only
|
||||
/// `processing_tiers` may inherit its Standard catalog, while a present-but-malformed catalog
|
||||
/// must never shadow a valid lower-precedence catalog or silently bill as zero.
|
||||
pub fn explicit_pricing_catalog_state(value: &Value) -> ExplicitPricingCatalogState {
|
||||
let Some(object) = value.as_object() else {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
};
|
||||
|
||||
let mut has_catalog_field = false;
|
||||
let mut has_valid_price_data = false;
|
||||
|
||||
if let Some(tiers) = object.get("tiers") {
|
||||
if tiers.is_null() {
|
||||
// Null and [] both represent an inherited/absent Standard catalog in legacy rows.
|
||||
} else {
|
||||
let Some(tiers) = tiers.as_array() else {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
};
|
||||
// An empty provider Standard list is the legacy representation for "inherit the global
|
||||
// catalog". It is not an explicit catalog and therefore cannot shadow a multiplier or a
|
||||
// lower-precedence Standard catalog.
|
||||
if !tiers.is_empty() {
|
||||
has_catalog_field = true;
|
||||
if !token_pricing_tiers_are_valid(tiers) {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
}
|
||||
has_valid_price_data = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key in ["image_output_price_default", "image_price_default"] {
|
||||
let Some(price) = object.get(key) else {
|
||||
continue;
|
||||
};
|
||||
if price.is_null() {
|
||||
continue;
|
||||
}
|
||||
has_catalog_field = true;
|
||||
if !value_is_valid_price(price) {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
}
|
||||
has_valid_price_data = true;
|
||||
}
|
||||
|
||||
for key in IMAGE_MATRIX_PRICE_FIELDS {
|
||||
let Some(prices) = object.get(*key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(has_prices) = validate_image_matrix_prices(key, prices) else {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
};
|
||||
if has_prices {
|
||||
has_catalog_field = true;
|
||||
has_valid_price_data = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ranges) = object.get("image_output_price_ranges") {
|
||||
let Some(has_prices) = validate_image_price_ranges(ranges) else {
|
||||
return ExplicitPricingCatalogState::Invalid;
|
||||
};
|
||||
if has_prices {
|
||||
has_catalog_field = true;
|
||||
has_valid_price_data = true;
|
||||
}
|
||||
}
|
||||
|
||||
match (has_catalog_field, has_valid_price_data) {
|
||||
(false, _) => ExplicitPricingCatalogState::Absent,
|
||||
(true, true) => ExplicitPricingCatalogState::Valid,
|
||||
(true, false) => ExplicitPricingCatalogState::Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
fn token_pricing_tiers_are_valid(tiers: &[Value]) -> bool {
|
||||
let mut previous_up_to = None;
|
||||
for (index, tier) in tiers.iter().enumerate() {
|
||||
let Some(tier) = tier.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let up_to = match tier.get("up_to") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => match nonnegative_i64(value) {
|
||||
Some(value) => Some(value),
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
if index + 1 < tiers.len() && up_to.is_none() {
|
||||
return false;
|
||||
}
|
||||
if let (Some(previous), Some(current)) = (previous_up_to, up_to) {
|
||||
if current <= previous {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
previous_up_to = up_to;
|
||||
|
||||
let mut has_tier_price = false;
|
||||
for field in TOKEN_PRICE_FIELDS {
|
||||
let Some(price) = tier.get(*field) else {
|
||||
continue;
|
||||
};
|
||||
let Some(is_declared) = validate_optional_price_value(price) else {
|
||||
return false;
|
||||
};
|
||||
has_tier_price |= is_declared;
|
||||
}
|
||||
if !has_tier_price {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(ttl_pricing) = tier.get("cache_ttl_pricing") {
|
||||
let Some(entries) = ttl_pricing.as_array() else {
|
||||
return false;
|
||||
};
|
||||
for entry in entries {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if entry
|
||||
.get("ttl_minutes")
|
||||
.is_some_and(|value| nonnegative_i64(value).is_none())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for field in ["cache_creation_price_per_1m", "cache_read_price_per_1m"] {
|
||||
if let Some(price) = entry.get(field) {
|
||||
if validate_optional_price_value(price).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn validate_image_matrix_prices(field: &str, value: &Value) -> Option<bool> {
|
||||
match value {
|
||||
Value::Object(entries) => {
|
||||
let mut has_price = false;
|
||||
for (key, entry) in entries {
|
||||
if key.eq_ignore_ascii_case("default") {
|
||||
if field == "image_output_prices" {
|
||||
has_price |= validate_optional_price_value(entry)?;
|
||||
} else if entry.is_number() {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match entry {
|
||||
Value::Number(_) => {
|
||||
if !value_is_valid_price(entry) {
|
||||
return None;
|
||||
}
|
||||
let has_reachable_flat_key = key
|
||||
.split_once(':')
|
||||
.or_else(|| key.split_once('|'))
|
||||
.is_some_and(|(size, quality)| {
|
||||
!size.trim().is_empty() && !quality.trim().is_empty()
|
||||
});
|
||||
if !has_reachable_flat_key {
|
||||
return None;
|
||||
}
|
||||
has_price = true;
|
||||
}
|
||||
Value::Object(nested) => {
|
||||
for (nested_key, price) in nested {
|
||||
if key.trim().is_empty() || nested_key.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
match price {
|
||||
Value::Number(_) if value_is_valid_price(price) => {
|
||||
has_price = true;
|
||||
}
|
||||
Value::Number(_) => return None,
|
||||
Value::Null => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Null => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(has_price)
|
||||
}
|
||||
Value::Array(entries) => {
|
||||
let mut has_price = false;
|
||||
for entry in entries {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
if entry
|
||||
.get("size")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(price) = entry
|
||||
.get("price_per_image")
|
||||
.or_else(|| entry.get("price"))
|
||||
.or_else(|| entry.get("cost"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let is_declared = validate_optional_price_value(price)?;
|
||||
has_price |= is_declared;
|
||||
}
|
||||
Some(has_price)
|
||||
}
|
||||
// The runtime only treats object/array matrix shapes as price catalogs. Preserve scalar
|
||||
// extension values without letting them make an otherwise empty catalog authoritative.
|
||||
_ => Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_image_price_ranges(value: &Value) -> Option<bool> {
|
||||
let (ranges, allow_direct_prices) = match value {
|
||||
Value::Array(ranges) => (ranges.iter().collect::<Vec<_>>(), true),
|
||||
Value::Object(ranges) => (ranges.values().collect::<Vec<_>>(), false),
|
||||
Value::Null => return Some(false),
|
||||
_ => return None,
|
||||
};
|
||||
let mut has_price = false;
|
||||
for range in ranges {
|
||||
let range = range.as_object()?;
|
||||
for key in ["up_to_pixels", "up_to", "max_pixels"] {
|
||||
if let Some(value) = range.get(key) {
|
||||
if !value.is_null() && nonnegative_i64(value).is_none() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut range_has_price = false;
|
||||
let mut has_price_field = false;
|
||||
if let Some(prices) = range.get("prices") {
|
||||
let prices = prices.as_object().filter(|prices| !prices.is_empty())?;
|
||||
for price in prices.values() {
|
||||
has_price_field = true;
|
||||
let is_declared = match price {
|
||||
Value::Number(_) | Value::Null => validate_optional_price_value(price)?,
|
||||
_ => false,
|
||||
};
|
||||
range_has_price |= is_declared;
|
||||
}
|
||||
} else if allow_direct_prices {
|
||||
for key in ["low", "medium", "high", "price_per_image", "price", "value"] {
|
||||
let Some(price) = range.get(key) else {
|
||||
continue;
|
||||
};
|
||||
has_price_field = true;
|
||||
let is_declared = validate_optional_price_value(price)?;
|
||||
range_has_price |= is_declared;
|
||||
}
|
||||
}
|
||||
if !range_has_price {
|
||||
if !has_price_field {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
has_price = true;
|
||||
}
|
||||
Some(has_price)
|
||||
}
|
||||
|
||||
fn value_is_valid_price(value: &Value) -> bool {
|
||||
value
|
||||
.as_f64()
|
||||
.is_some_and(|price| price.is_finite() && price >= 0.0)
|
||||
}
|
||||
|
||||
fn validate_optional_price_value(value: &Value) -> Option<bool> {
|
||||
if value.is_null() {
|
||||
return Some(false);
|
||||
}
|
||||
value_is_valid_price(value).then_some(true)
|
||||
}
|
||||
|
||||
fn nonnegative_i64(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
|
||||
.filter(|value| *value >= 0)
|
||||
}
|
||||
|
||||
fn validate_processing_tier_price_multipliers(
|
||||
field_name: &str,
|
||||
tiered_pricing: Option<&Value>,
|
||||
) -> Result<(), crate::DataLayerError> {
|
||||
let Some(tiered_pricing) = tiered_pricing else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(processing_tiers_value) = tiered_pricing.get("processing_tiers") else {
|
||||
return Ok(());
|
||||
};
|
||||
if processing_tiers_value.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(processing_tiers) = processing_tiers_value.as_object() else {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers must be an object"
|
||||
)));
|
||||
};
|
||||
|
||||
let standard_catalog_state = explicit_pricing_catalog_state(tiered_pricing);
|
||||
|
||||
for (tier_name, overlay) in processing_tiers {
|
||||
let canonical_tier_name =
|
||||
crate::repository::usage::normalize_provider_service_tier(tier_name);
|
||||
if canonical_tier_name.as_deref() != Some(tier_name.as_str()) {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers tier name `{tier_name}` must be canonical lowercase without surrounding whitespace"
|
||||
)));
|
||||
}
|
||||
let Some(overlay) = overlay.as_object().map(|_| overlay) else {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers.{tier_name} must be an object"
|
||||
)));
|
||||
};
|
||||
|
||||
// Settlement gives an explicit catalog precedence over a multiplier. Keep accepting a
|
||||
// stale or future multiplier beside an authoritative explicit catalog for compatibility.
|
||||
match explicit_pricing_catalog_state(overlay) {
|
||||
ExplicitPricingCatalogState::Valid => continue,
|
||||
ExplicitPricingCatalogState::Invalid => {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers.{tier_name} explicit catalog must contain valid non-negative finite token or image prices"
|
||||
)));
|
||||
}
|
||||
ExplicitPricingCatalogState::Absent => {}
|
||||
}
|
||||
|
||||
let Some(multiplier) = overlay.get("price_multiplier") else {
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers.{tier_name} must contain an explicit catalog or price_multiplier"
|
||||
)));
|
||||
};
|
||||
if multiplier
|
||||
.as_f64()
|
||||
.is_none_or(|multiplier| !multiplier.is_finite() || multiplier < 0.0)
|
||||
{
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers.{tier_name}.price_multiplier must be a non-negative finite number"
|
||||
)));
|
||||
}
|
||||
if field_name == "global_models.default_tiered_pricing"
|
||||
&& standard_catalog_state != ExplicitPricingCatalogState::Valid
|
||||
{
|
||||
return Err(crate::DataLayerError::UnexpectedValue(format!(
|
||||
"{field_name}.processing_tiers.{tier_name}.price_multiplier requires a valid Standard pricing catalog"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_embedding_global_billing(
|
||||
default_price_per_request: Option<f64>,
|
||||
default_tiered_pricing: Option<&Value>,
|
||||
@@ -585,6 +973,10 @@ impl UpsertAdminProviderModelRecord {
|
||||
));
|
||||
}
|
||||
validate_provider_model_pricing(price_per_request)?;
|
||||
validate_processing_tier_price_multipliers(
|
||||
"models.tiered_pricing",
|
||||
tiered_pricing.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
@@ -653,6 +1045,10 @@ impl CreateAdminGlobalModelRecord {
|
||||
supported_capabilities.as_ref(),
|
||||
config.as_ref(),
|
||||
)?;
|
||||
validate_processing_tier_price_multipliers(
|
||||
"global_models.default_tiered_pricing",
|
||||
default_tiered_pricing.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
@@ -708,6 +1104,10 @@ impl UpdateAdminGlobalModelRecord {
|
||||
supported_capabilities.as_ref(),
|
||||
config.as_ref(),
|
||||
)?;
|
||||
validate_processing_tier_price_multipliers(
|
||||
"global_models.default_tiered_pricing",
|
||||
default_tiered_pricing.as_ref(),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
id,
|
||||
@@ -895,7 +1295,10 @@ pub trait GlobalModelWriteRepository: Send + Sync {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{CreateAdminGlobalModelRecord, UpsertAdminProviderModelRecord};
|
||||
use super::{
|
||||
explicit_pricing_catalog_state, CreateAdminGlobalModelRecord, ExplicitPricingCatalogState,
|
||||
UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn embedding_missing_billing_config_rejected() {
|
||||
@@ -985,4 +1388,283 @@ mod tests {
|
||||
.to_string()
|
||||
.contains("models.price_per_request must be a non-negative finite number"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_model_invalid_processing_tier_multiplier_rejected_on_create_and_update() {
|
||||
let invalid_pricing = json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0, "output_price_per_1m": 2.0}],
|
||||
"processing_tiers": {
|
||||
"fast": {"price_multiplier": -1.0}
|
||||
}
|
||||
});
|
||||
|
||||
let create_err = CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(invalid_pricing.clone()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("create should reject a negative processing-tier multiplier");
|
||||
assert!(create_err.to_string().contains(
|
||||
"global_models.default_tiered_pricing.processing_tiers.fast.price_multiplier must be a non-negative finite number"
|
||||
));
|
||||
|
||||
let update_err = UpdateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(invalid_pricing),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("update should reject a negative processing-tier multiplier");
|
||||
assert!(update_err.to_string().contains(
|
||||
"global_models.default_tiered_pricing.processing_tiers.fast.price_multiplier must be a non-negative finite number"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_model_invalid_processing_tier_multiplier_rejected() {
|
||||
let err = UpsertAdminProviderModelRecord::new(
|
||||
"model-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"global-model-1".to_string(),
|
||||
"provider-model-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
"flex": {"price_multiplier": "0.5"}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.expect_err("provider model should reject a non-numeric processing-tier multiplier");
|
||||
|
||||
assert!(err.to_string().contains(
|
||||
"models.tiered_pricing.processing_tiers.flex.price_multiplier must be a non-negative finite number"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_processing_tier_catalog_takes_precedence_over_stale_multiplier() {
|
||||
let pricing = json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0, "output_price_per_1m": 2.0}],
|
||||
"processing_tiers": {
|
||||
"fast": {
|
||||
"tiers": [{"input_price_per_1m": 2.0, "output_price_per_1m": 4.0}],
|
||||
"price_multiplier": "stale"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(pricing.clone()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("an explicit global processing-tier catalog should shadow a stale multiplier");
|
||||
|
||||
UpsertAdminProviderModelRecord::new(
|
||||
"model-1".to_string(),
|
||||
"provider-1".to_string(),
|
||||
"global-model-1".to_string(),
|
||||
"provider-model-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(pricing),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.expect("an explicit provider processing-tier catalog should shadow a stale multiplier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_negative_processing_tier_multipliers_are_accepted() {
|
||||
let pricing = json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0, "output_price_per_1m": 2.0}],
|
||||
"processing_tiers": {
|
||||
"flex": {"price_multiplier": 0.0},
|
||||
"fast": {"price_multiplier": 2.5}
|
||||
}
|
||||
});
|
||||
|
||||
CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(pricing),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("finite non-negative processing-tier multipliers should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_explicit_processing_catalog_cannot_hide_an_invalid_multiplier() {
|
||||
let err = CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
"priority": {
|
||||
"tiers": [{}],
|
||||
"price_multiplier": -1.0
|
||||
}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("an empty explicit catalog must not bypass processing-tier validation");
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("processing_tiers.priority explicit catalog must contain valid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processing_catalog_accepts_input_only_and_image_only_prices() {
|
||||
CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
"embedding": {
|
||||
"tiers": [{
|
||||
"up_to": null,
|
||||
"input_price_per_1m": 0.1,
|
||||
"output_price_per_1m": null
|
||||
}]
|
||||
},
|
||||
"image": {"image_output_price_default": 0.04}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("input-only and image-only processing catalogs are billable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_multiplier_requires_a_valid_standard_catalog() {
|
||||
let err = CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(json!({
|
||||
"processing_tiers": {
|
||||
"priority": {"price_multiplier": 2.0}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("a global multiplier without Standard prices cannot be materialized");
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("price_multiplier requires a valid Standard pricing catalog"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_processing_tiers_remains_an_empty_compatible_value() {
|
||||
CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0}],
|
||||
"processing_tiers": null
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("null processing_tiers should keep its legacy empty meaning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noncanonical_processing_tier_name_is_rejected() {
|
||||
let err = CreateAdminGlobalModelRecord::new(
|
||||
"global-model-1".to_string(),
|
||||
"model-1".to_string(),
|
||||
"Model 1".to_string(),
|
||||
true,
|
||||
None,
|
||||
Some(json!({
|
||||
"tiers": [{"input_price_per_1m": 1.0}],
|
||||
"processing_tiers": {
|
||||
" Priority ": {"price_multiplier": 2.0}
|
||||
}
|
||||
})),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("noncanonical processing-tier keys are not resolvable at runtime");
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("must be canonical lowercase without surrounding whitespace"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_catalog_requires_a_runtime_reachable_price_key() {
|
||||
for unreachable in [
|
||||
json!({"image_prices": {"default": 0.1}}),
|
||||
json!({"image_output_prices": {"1024x1024": 0.1}}),
|
||||
] {
|
||||
assert_ne!(
|
||||
explicit_pricing_catalog_state(&unreachable),
|
||||
ExplicitPricingCatalogState::Valid
|
||||
);
|
||||
}
|
||||
|
||||
for reachable in [
|
||||
json!({"image_output_prices": {"default": 0.1}}),
|
||||
json!({"image_output_prices": {"1024x1024:high": 0.1}}),
|
||||
json!({"image_prices": {"1024x1024": {"high": 0.1}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
explicit_pricing_catalog_state(&reachable),
|
||||
ExplicitPricingCatalogState::Valid
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,24 @@ fn normalize_provider_reasoning_effort(value: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn extract_provider_service_tier_from_body(value: Option<&Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|object| object.get("service_tier"))
|
||||
let object = value.and_then(Value::as_object)?;
|
||||
|
||||
// Anthropic Fast is a separate processing mode (`speed=fast`), not an OpenAI
|
||||
// `service_tier`. Prefer that explicit paid mode when both facts happen to be present so it
|
||||
// resolves against `processing_tiers.fast` instead of being mistaken for Standard/Priority.
|
||||
let speed = object
|
||||
.get("speed")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier);
|
||||
if speed.as_deref() == Some("fast") {
|
||||
return speed;
|
||||
}
|
||||
|
||||
object
|
||||
.get("service_tier")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_provider_service_tier)
|
||||
.or_else(|| speed.filter(|speed| speed == "standard"))
|
||||
}
|
||||
|
||||
pub fn extract_provider_actual_service_tier_from_response(value: Option<&Value>) -> Option<String> {
|
||||
@@ -56,10 +69,20 @@ pub fn extract_provider_actual_service_tier_from_response(value: Option<&Value>)
|
||||
.rev()
|
||||
.find_map(|chunk| extract_provider_actual_service_tier_from_response(Some(chunk)))
|
||||
})
|
||||
.or_else(|| {
|
||||
value.get("response").and_then(|response| {
|
||||
extract_provider_actual_service_tier_from_response(Some(response))
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
value.get("message").and_then(|message| {
|
||||
extract_provider_actual_service_tier_from_response(Some(message))
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("response")
|
||||
.and_then(|response| extract_provider_service_tier_from_body(Some(response)))
|
||||
.get("usage")
|
||||
.and_then(|usage| extract_provider_service_tier_from_body(Some(usage)))
|
||||
})
|
||||
.or_else(|| extract_provider_service_tier_from_body(Some(value)))
|
||||
}
|
||||
@@ -2215,7 +2238,8 @@ fn parse_timestamp(value: i64, field_name: &str) -> Result<u64, crate::DataLayer
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
extract_provider_actual_service_tier_from_response, resolve_provider_cache_ttl_minutes,
|
||||
extract_provider_actual_service_tier_from_response,
|
||||
extract_provider_service_tier_from_body, resolve_provider_cache_ttl_minutes,
|
||||
StoredRequestUsageAudit, UpsertUsageRecord, UsageBodyCaptureState, UsageBodyCaptureStorage,
|
||||
UsageBodyField, UsageProviderPerformanceQuery,
|
||||
};
|
||||
@@ -2758,6 +2782,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_anthropic_fast_speed_as_its_own_processing_tier() {
|
||||
let request = json!({"speed": " FAST ", "service_tier": "default"});
|
||||
let sync_response = json!({
|
||||
"usage": {"speed": "fast", "service_tier": "standard"}
|
||||
});
|
||||
let stream_response = json!({
|
||||
"chunks": [{
|
||||
"type": "message_start",
|
||||
"message": {"usage": {"speed": "fast", "service_tier": "standard"}}
|
||||
}]
|
||||
});
|
||||
let standard_response = json!({
|
||||
"usage": {"speed": "standard"}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
extract_provider_service_tier_from_body(Some(&request)).as_deref(),
|
||||
Some("fast")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_provider_actual_service_tier_from_response(Some(&sync_response)).as_deref(),
|
||||
Some("fast")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_provider_actual_service_tier_from_response(Some(&stream_response)).as_deref(),
|
||||
Some("fast")
|
||||
);
|
||||
assert_eq!(
|
||||
extract_provider_actual_service_tier_from_response(Some(&standard_response)).as_deref(),
|
||||
Some("standard")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_capture_json_entry_includes_capture_source_and_body_ref() {
|
||||
let mut usage = sample_usage();
|
||||
|
||||
@@ -183,4 +183,98 @@ describe('resolveModelsDevTieredPricing', () => {
|
||||
it('does not synthesize pricing when the fetched cost is absent', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a models.dev fast cost as an explicit Priority catalog when bands differ', () => {
|
||||
expect(resolveModelsDevTieredPricing('openai', 'gpt-5.6-sol', {
|
||||
input: 5,
|
||||
output: 30,
|
||||
tiers: [{
|
||||
input: 10,
|
||||
output: 45,
|
||||
tier: { type: 'context', size: 272_000 },
|
||||
}],
|
||||
}, {
|
||||
fast: {
|
||||
cost: { input: 10, output: 60 },
|
||||
provider: { body: { service_tier: 'priority' } },
|
||||
},
|
||||
})).toEqual({
|
||||
tiers: [
|
||||
{ up_to: 271_999, input_price_per_1m: 5, output_price_per_1m: 30 },
|
||||
{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 45 },
|
||||
],
|
||||
processing_tiers: {
|
||||
priority: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a multiplier only when every fast price has the same ratio', () => {
|
||||
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.8', {
|
||||
input: 5,
|
||||
output: 25,
|
||||
cache_read: 0.5,
|
||||
cache_write: 6.25,
|
||||
}, {
|
||||
fast: {
|
||||
cost: { input: 10, output: 50, cache_read: 1, cache_write: 12.5 },
|
||||
provider: { body: { speed: 'fast' } },
|
||||
},
|
||||
})?.processing_tiers).toEqual({
|
||||
fast: { price_multiplier: 2 },
|
||||
})
|
||||
|
||||
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-4.7', {
|
||||
input: 5,
|
||||
output: 25,
|
||||
}, {
|
||||
fast: {
|
||||
cost: { input: 30, output: 150 },
|
||||
provider: { body: { speed: 'fast' } },
|
||||
},
|
||||
})?.processing_tiers).toEqual({
|
||||
fast: { price_multiplier: 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers Anthropic speed=fast when the mode body also carries a standard service tier', () => {
|
||||
expect(resolveModelsDevTieredPricing('anthropic', 'claude-opus-fast', {
|
||||
input: 5,
|
||||
output: 25,
|
||||
}, {
|
||||
fast: {
|
||||
cost: { input: 10, output: 50 },
|
||||
provider: { body: { speed: ' FAST ', service_tier: 'default' } },
|
||||
},
|
||||
})?.processing_tiers).toEqual({
|
||||
fast: { price_multiplier: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the mode key and keeps non-uniform prices explicit', () => {
|
||||
expect(resolveModelsDevTieredPricing('vendor', 'model', {
|
||||
input: 2,
|
||||
output: 4,
|
||||
}, {
|
||||
flex: { cost: { input: 1, output: 3 } },
|
||||
})?.processing_tiers).toEqual({
|
||||
flex: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1, output_price_per_1m: 3 }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not reinterpret unrelated or special experimental modes as processing tiers', () => {
|
||||
const modes = JSON.parse(
|
||||
'{"pro":{"cost":{"input":2,"output":4}},"__proto__":{"cost":{"input":2,"output":4}}}',
|
||||
)
|
||||
const pricing = resolveModelsDevTieredPricing('vendor', 'model', {
|
||||
input: 1,
|
||||
output: 2,
|
||||
}, modes)
|
||||
|
||||
expect(pricing?.processing_tiers).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: { get: apiMocks.get },
|
||||
}))
|
||||
|
||||
import { clearModelsDevCache, getModelsDevList } from '@/api/models-dev'
|
||||
|
||||
beforeEach(() => {
|
||||
clearModelsDevCache()
|
||||
localStorage.clear()
|
||||
apiMocks.get.mockReset()
|
||||
})
|
||||
|
||||
describe('getModelsDevList', () => {
|
||||
it('uses current modalities and experimental mode pricing while keeping legacy fallbacks', async () => {
|
||||
apiMocks.get.mockResolvedValue({
|
||||
data: {
|
||||
openai: {
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
official: true,
|
||||
models: {
|
||||
'gpt-test': {
|
||||
id: 'gpt-test',
|
||||
name: 'GPT Test',
|
||||
input: ['text'],
|
||||
output: ['text'],
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['text', 'image'],
|
||||
},
|
||||
cost: { input: 2, output: 4 },
|
||||
experimental: {
|
||||
modes: {
|
||||
fast: {
|
||||
cost: { input: 4, output: 8 },
|
||||
provider: { body: { service_tier: 'priority' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
legacy: {
|
||||
id: 'legacy',
|
||||
name: 'Legacy',
|
||||
input: ['text', 'image'],
|
||||
output: ['text'],
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const models = await getModelsDevList()
|
||||
const current = models.find(model => model.modelId === 'gpt-test')
|
||||
const legacy = models.find(model => model.modelId === 'legacy')
|
||||
|
||||
expect(current).toMatchObject({
|
||||
supportsVision: true,
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text', 'image'],
|
||||
tieredPricing: {
|
||||
processing_tiers: { priority: { price_multiplier: 2 } },
|
||||
},
|
||||
})
|
||||
expect(legacy).toMatchObject({
|
||||
supportsVision: true,
|
||||
inputModalities: ['text', 'image'],
|
||||
outputModalities: ['text'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -154,10 +154,34 @@ export interface RequestSchedulingFailure {
|
||||
no_upstream_attempt?: boolean | null
|
||||
}
|
||||
|
||||
export interface RequestPricingTier {
|
||||
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?: Array<{
|
||||
ttl_minutes?: number | null
|
||||
cache_creation_price_per_1m?: number | null
|
||||
cache_read_price_per_1m?: number | null
|
||||
}> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestSettlementTieredPricing {
|
||||
tiers?: RequestPricingTier[] | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RequestSettlementPricingSnapshot {
|
||||
requested_processing_tier?: string | null
|
||||
actual_processing_tier?: string | null
|
||||
billing_processing_tier?: string | null
|
||||
processing_tier_price_multiplier?: number | null
|
||||
pricing_source?: string | null
|
||||
tiered_pricing_source?: string | null
|
||||
price_per_request_source?: string | null
|
||||
tiered_pricing?: RequestSettlementTieredPricing | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -283,30 +307,8 @@ export interface RequestDetail {
|
||||
tier_index: number // 命中的阶梯索引 (0-based)
|
||||
tier_count: number // 阶梯总数
|
||||
source?: 'provider' | 'global' // 定价来源: 提供商或全局
|
||||
current_tier: { // 当前命中的阶梯配置
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}
|
||||
tiers: Array<{ // 完整阶梯配置列表
|
||||
up_to?: number | null
|
||||
input_price_per_1m: number
|
||||
output_price_per_1m: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
cache_ttl_pricing?: Array<{
|
||||
ttl_minutes: number
|
||||
cache_creation_price_per_1m?: number
|
||||
cache_read_price_per_1m?: number
|
||||
}>
|
||||
}>
|
||||
current_tier: RequestPricingTier // 当前命中的阶梯配置
|
||||
tiers: RequestPricingTier[] // 完整阶梯配置列表
|
||||
} | null
|
||||
// 视频/图像/音频计费信息
|
||||
video_billing?: VideoBilling | null
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface ImageOutputPriceRange {
|
||||
|
||||
/** 按处理层级覆盖的费率配置。允许图像或未来计费字段独立扩展。 */
|
||||
export interface ProcessingTierPricingConfig {
|
||||
/** 相对 Standard 目录的统一价格倍率。新写入应与显式目录二选一;读取混合配置时显式目录优先。 */
|
||||
price_multiplier?: number
|
||||
tiers?: PricingTier[]
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
@@ -52,6 +54,19 @@ export interface TieredPricingConfig {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 价格覆盖可以只声明 processing_tiers,并继续从 GlobalModel
|
||||
* 继承 Standard 目录,因此 tiers 在原始 Provider 配置中是可选的。
|
||||
*/
|
||||
export interface ProviderTieredPricingConfig {
|
||||
tiers?: PricingTier[]
|
||||
image_output_prices?: Record<string, ImageOutputQualityPricing> | null
|
||||
image_output_price_default?: number | null
|
||||
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||
processing_tiers?: Record<string, ProcessingTierPricingConfig> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -61,7 +76,7 @@ export interface Model {
|
||||
config?: Record<string, unknown> | null // 额外配置(如 billing/video 等)
|
||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 原始覆盖,可仅包含 processing_tiers
|
||||
supports_vision?: boolean | null
|
||||
supports_function_calling?: boolean | null
|
||||
supports_streaming?: boolean | null
|
||||
@@ -69,7 +84,7 @@ export interface Model {
|
||||
supports_image_generation?: boolean | null
|
||||
supports_embedding?: boolean | null
|
||||
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
|
||||
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
|
||||
effective_tiered_pricing?: ProviderTieredPricingConfig | null // 当前响应可能是 Provider partial 覆盖
|
||||
effective_input_price?: number | null
|
||||
effective_output_price?: number | null
|
||||
effective_price_per_request?: number | null // 有效按次计费价格
|
||||
@@ -97,7 +112,7 @@ export interface ModelCreate {
|
||||
global_model_id: string // 关联的 GlobalModel ID(必填)
|
||||
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
|
||||
tiered_pricing?: ProviderTieredPricingConfig // Provider 阶梯计费覆盖
|
||||
// 能力配置(可选,为空时使用 GlobalModel 默认值)
|
||||
supports_vision?: boolean
|
||||
supports_function_calling?: boolean
|
||||
@@ -113,7 +128,7 @@ export interface ModelUpdate {
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
global_model_id?: string
|
||||
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
tiered_pricing?: ProviderTieredPricingConfig | null // Provider 阶梯计费覆盖
|
||||
supports_vision?: boolean
|
||||
supports_function_calling?: boolean
|
||||
supports_streaming?: boolean
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface ModelsDevCost extends ModelsDevTokenCost {
|
||||
tiers?: ModelsDevCostTier[]
|
||||
}
|
||||
|
||||
const TOKEN_PRICE_FIELDS = [
|
||||
'input_price_per_1m',
|
||||
'output_price_per_1m',
|
||||
'cache_creation_price_per_1m',
|
||||
'cache_read_price_per_1m',
|
||||
] as const
|
||||
const PROCESSING_MODE_FALLBACK_KEYS = new Set(['fast', 'priority', 'flex', 'batch'])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -91,11 +99,88 @@ export function buildModelsDevTieredPricing(cost: unknown): TieredPricingConfig
|
||||
return { tiers }
|
||||
}
|
||||
|
||||
function uniformPriceMultiplier(
|
||||
standard: TieredPricingConfig,
|
||||
processing: TieredPricingConfig,
|
||||
): number | null {
|
||||
if (standard.tiers.length !== processing.tiers.length) return null
|
||||
|
||||
let candidate: number | null = null
|
||||
for (const [index, standardTier] of standard.tiers.entries()) {
|
||||
const processingTier = processing.tiers[index]
|
||||
if (standardTier.up_to !== processingTier?.up_to) return null
|
||||
|
||||
for (const field of TOKEN_PRICE_FIELDS) {
|
||||
const standardPrice = standardTier[field]
|
||||
const processingPrice = processingTier[field]
|
||||
if (standardPrice === undefined || processingPrice === undefined) {
|
||||
if (standardPrice !== processingPrice) return null
|
||||
continue
|
||||
}
|
||||
if (standardPrice === 0) {
|
||||
if (processingPrice !== 0) return null
|
||||
continue
|
||||
}
|
||||
|
||||
const ratio = processingPrice / standardPrice
|
||||
if (!Number.isFinite(ratio) || ratio < 0) return null
|
||||
if (candidate === null) candidate = ratio
|
||||
if (Math.abs(processingPrice - standardPrice * candidate) > 1e-9) return null
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
export function resolveModelsDevTieredPricing(
|
||||
_providerId: string,
|
||||
_modelId: string,
|
||||
cost: unknown,
|
||||
experimentalModes?: unknown,
|
||||
): TieredPricingConfig | null {
|
||||
// Provider/model identities must never inject local prices over the fetched catalog.
|
||||
return buildModelsDevTieredPricing(cost)
|
||||
const standard = buildModelsDevTieredPricing(cost)
|
||||
if (!standard || !isRecord(experimentalModes)) return standard
|
||||
|
||||
const processingTierEntries: Array<[string, NonNullable<TieredPricingConfig['processing_tiers']>[string]]> = []
|
||||
const seenProcessingTiers = new Set<string>()
|
||||
for (const [modeKey, rawMode] of Object.entries(experimentalModes)) {
|
||||
if (!isRecord(rawMode)) continue
|
||||
const modePricing = buildModelsDevTieredPricing(rawMode.cost)
|
||||
if (!modePricing) continue
|
||||
|
||||
const provider = isRecord(rawMode.provider) ? rawMode.provider : null
|
||||
const body = provider && isRecord(provider.body) ? provider.body : null
|
||||
// Anthropic Fast is expressed with `speed=fast`. A provider body may also carry a
|
||||
// `service_tier` fact (commonly `default`/`standard`), but runtime settlement deliberately
|
||||
// gives Fast speed precedence, so catalog import must resolve the same processing-tier key.
|
||||
const mappedProcessingTier = typeof body?.speed === 'string'
|
||||
&& body.speed.trim().toLowerCase() === 'fast'
|
||||
? body.speed
|
||||
: typeof body?.service_tier === 'string'
|
||||
? body.service_tier
|
||||
: null
|
||||
const normalizedModeKey = modeKey.trim().toLowerCase()
|
||||
const rawProcessingTier = mappedProcessingTier
|
||||
?? (PROCESSING_MODE_FALLBACK_KEYS.has(normalizedModeKey) ? normalizedModeKey : '')
|
||||
const processingTier = rawProcessingTier.trim().toLowerCase()
|
||||
if (
|
||||
!processingTier
|
||||
|| processingTier.length > 64
|
||||
|| ['auto', 'default', 'standard'].includes(processingTier)
|
||||
|| seenProcessingTiers.has(processingTier)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const multiplier = uniformPriceMultiplier(standard, modePricing)
|
||||
seenProcessingTiers.add(processingTier)
|
||||
processingTierEntries.push([processingTier, multiplier === null
|
||||
? { tiers: modePricing.tiers }
|
||||
: { price_multiplier: multiplier }])
|
||||
}
|
||||
if (processingTierEntries.length === 0) return standard
|
||||
return {
|
||||
...standard,
|
||||
processing_tiers: Object.fromEntries(processingTierEntries),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,21 @@ export interface ModelsDevModel {
|
||||
last_updated?: string
|
||||
input?: string[] // 输入模态: text, image, audio, video, pdf
|
||||
output?: string[] // 输出模态: text, image, audio
|
||||
modalities?: {
|
||||
input?: string[]
|
||||
output?: string[]
|
||||
}
|
||||
open_weights?: boolean
|
||||
cost?: ModelsDevCost
|
||||
experimental?: {
|
||||
modes?: Record<string, {
|
||||
cost?: ModelsDevCost
|
||||
provider?: {
|
||||
body?: Record<string, unknown>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
}>
|
||||
}
|
||||
limit?: ModelsDevLimit
|
||||
deprecated?: boolean
|
||||
}
|
||||
@@ -166,7 +179,14 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
if (!provider.models) continue
|
||||
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
const tieredPricing = resolveModelsDevTieredPricing(providerId, modelId, model.cost)
|
||||
const inputModalities = model.modalities?.input ?? model.input
|
||||
const outputModalities = model.modalities?.output ?? model.output
|
||||
const tieredPricing = resolveModelsDevTieredPricing(
|
||||
providerId,
|
||||
modelId,
|
||||
model.cost,
|
||||
model.experimental?.modes,
|
||||
)
|
||||
const basePricingTier = tieredPricing?.tiers[0]
|
||||
items.push({
|
||||
providerId,
|
||||
@@ -179,7 +199,7 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
tieredPricing: tieredPricing ?? undefined,
|
||||
contextLimit: model.limit?.context,
|
||||
outputLimit: model.limit?.output,
|
||||
supportsVision: model.input?.includes('image'),
|
||||
supportsVision: inputModalities?.includes('image'),
|
||||
supportsToolCall: model.tool_call,
|
||||
supportsReasoning: model.reasoning,
|
||||
supportsStructuredOutput: model.structured_output,
|
||||
@@ -194,8 +214,8 @@ export async function getModelsDevList(officialOnly: boolean = true): Promise<Mo
|
||||
// display_metadata 相关字段
|
||||
knowledgeCutoff: model.knowledge,
|
||||
releaseDate: model.release_date,
|
||||
inputModalities: model.input,
|
||||
outputModalities: model.output,
|
||||
inputModalities,
|
||||
outputModalities,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,7 @@
|
||||
:show-token-pricing="billingMode === 'token'"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
:show-image-editor="billingMode === 'image'"
|
||||
:show-processing-tier-multiplier-controls="true"
|
||||
/>
|
||||
|
||||
<TabsContent
|
||||
|
||||
@@ -137,7 +137,6 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 默认定价 -->
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activePriceMultiplier !== null"
|
||||
class="flex items-center justify-between rounded-md border bg-muted/20 px-3 py-2 text-xs"
|
||||
data-testid="processing-tier-price-multiplier"
|
||||
>
|
||||
<span class="text-muted-foreground">相对 Standard</span>
|
||||
<span class="font-mono font-medium text-foreground">{{ formatMultiplier(activePriceMultiplier) }}×</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeTokenTiers.length > 0"
|
||||
class="overflow-x-auto rounded-md border"
|
||||
@@ -226,7 +235,8 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const KNOWN_PROCESSING_TIERS = [
|
||||
{ key: 'priority', label: 'Priority' },
|
||||
{ key: 'priority', label: 'Fast(OpenAI)' },
|
||||
{ key: 'fast', label: 'Fast(Claude)' },
|
||||
{ key: 'flex', label: 'Flex' },
|
||||
{ key: 'batch', label: 'Batch' },
|
||||
] as const
|
||||
@@ -264,6 +274,14 @@ const activeTokenTiers = computed<PricingTier[]>(() =>
|
||||
? activeEntry.value.config.tiers.filter(isRecord) as PricingTier[]
|
||||
: [],
|
||||
)
|
||||
const activePriceMultiplier = computed(() => {
|
||||
const config = activeEntry.value?.config
|
||||
if (!config || processingPricingHasExplicitFacts(config)) return null
|
||||
const multiplier = config.price_multiplier
|
||||
return typeof multiplier === 'number' && Number.isFinite(multiplier) && multiplier >= 0
|
||||
? multiplier
|
||||
: null
|
||||
})
|
||||
const activeImageDefaultPrice = computed(() =>
|
||||
toFiniteNumber(activeEntry.value?.config.image_output_price_default),
|
||||
)
|
||||
@@ -311,6 +329,15 @@ const imageTableMinWidthClass = computed(() =>
|
||||
)
|
||||
|
||||
function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean {
|
||||
if (
|
||||
typeof config.price_multiplier === 'number'
|
||||
&& Number.isFinite(config.price_multiplier)
|
||||
&& config.price_multiplier >= 0
|
||||
) return true
|
||||
return processingPricingHasExplicitFacts(config)
|
||||
}
|
||||
|
||||
function processingPricingHasExplicitFacts(config: ProcessingTierPricingConfig): boolean {
|
||||
if (Array.isArray(config.tiers) && config.tiers.length > 0) return true
|
||||
if (toFiniteNumber(config.image_output_price_default) !== null) return true
|
||||
if (isRecord(config.image_output_prices)) {
|
||||
@@ -318,12 +345,26 @@ function processingPricingHasFacts(config: ProcessingTierPricingConfig): boolean
|
||||
if (isRecord(prices) && Object.keys(finitePriceRecord(prices)).length > 0) return true
|
||||
}
|
||||
}
|
||||
return Array.isArray(config.image_output_price_ranges)
|
||||
if (Array.isArray(config.image_output_price_ranges)
|
||||
&& config.image_output_price_ranges.some(range => (
|
||||
isRecord(range)
|
||||
&& isRecord(range.prices)
|
||||
&& Object.keys(finitePriceRecord(range.prices)).length > 0
|
||||
))
|
||||
))) return true
|
||||
return [
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
].some(key => valueHasEntries(config[key]))
|
||||
}
|
||||
|
||||
function valueHasEntries(value: unknown): boolean {
|
||||
return (Array.isArray(value) && value.length > 0)
|
||||
|| (isRecord(value) && Object.keys(value).length > 0)
|
||||
}
|
||||
|
||||
function formatMultiplier(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)))
|
||||
}
|
||||
|
||||
function formatTokenRange(tiers: PricingTier[], index: number): string {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2 border-b border-border/60 pb-3">
|
||||
<div
|
||||
v-if="showProcessingTierControls"
|
||||
class="space-y-2 border-b border-border/60 pb-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
@@ -55,210 +58,355 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isActivePricingScopeConfigured"
|
||||
v-if="showProcessingTierControls && !isActivePricingScopeConfigured"
|
||||
class="flex flex-wrap items-center justify-between gap-3 py-4"
|
||||
data-testid="processing-tier-empty"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
未配置 {{ activeProcessingTierLabel }} 费率
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="processing-tier-add"
|
||||
@click="addActiveProcessingTier"
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
添加费率
|
||||
</Button>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="processing-tier-add-multiplier"
|
||||
@click="startActiveProcessingTierMultiplier"
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
使用倍率
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="processing-tier-add"
|
||||
@click="addActiveProcessingTier"
|
||||
>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
添加自定义费率
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="showTokenPricing !== false">
|
||||
<!-- 阶梯列表 -->
|
||||
<div
|
||||
v-for="(tier, index) in localTiers"
|
||||
:key="index"
|
||||
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
|
||||
>
|
||||
<!-- 阶梯头部 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<template v-if="isTierUpperBoundEditable(index)">
|
||||
<template v-if="customInputMode[index]">
|
||||
<Input
|
||||
v-model="customInputValue[index]"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-7 w-20 text-sm"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限(千 Token)`"
|
||||
placeholder="K"
|
||||
@keyup.enter="confirmCustomInput(index)"
|
||||
@blur="confirmCustomInput(index)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">K</span>
|
||||
</template>
|
||||
<select
|
||||
v-else
|
||||
:value="getSelectValue(index)"
|
||||
class="h-7 px-2 text-sm border rounded bg-background"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
|
||||
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="opt in getAvailableThresholds(index)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="font-medium"
|
||||
>无上限</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs text-muted-foreground"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
|
||||
@click="toggleCachePriceMode(index)"
|
||||
>
|
||||
<Repeat2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="localTiers.length > 1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
|
||||
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showProcessingTierControls && activeProcessingTierUsesMultiplier"
|
||||
class="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3"
|
||||
data-testid="processing-tier-multiplier-editor"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs font-medium">层级倍率(相对 Standard)</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
该层级按 Standard 的完整价格目录统一缩放。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 价格输入 -->
|
||||
<div
|
||||
class="grid gap-3"
|
||||
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="processing-tier-use-custom"
|
||||
@click="useCustomPricingForActiveProcessingTier"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输入 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.input_price_per_1m"
|
||||
data-testid="tier-input-price"
|
||||
:data-tier-index="index"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格(美元/百万 Token)`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输出 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.output_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token)`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheCreationEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheCreation(index, v)"
|
||||
/>
|
||||
改用自定义价格
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative max-w-40">
|
||||
<Input
|
||||
:model-value="activeProcessingTierMultiplierDraft?.value ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="h-8 pr-7"
|
||||
data-testid="processing-tier-multiplier-input"
|
||||
:aria-label="`${activeProcessingTierLabel} 层级倍率`"
|
||||
placeholder="请输入倍率"
|
||||
@update:model-value="updateActiveProcessingTierMultiplier"
|
||||
/>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="isActivePricingScopeConfigured">
|
||||
<template v-if="showTokenPricing !== false">
|
||||
<!-- 阶梯列表 -->
|
||||
<div
|
||||
v-for="(tier, index) in localTiers"
|
||||
:key="index"
|
||||
class="space-y-3 border-b border-border/60 pb-3 last:border-b-0"
|
||||
>
|
||||
<!-- 阶梯头部 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">{{ getTierStartLabel(index) }}</span>
|
||||
<span class="text-muted-foreground">-</span>
|
||||
<template v-if="isTierUpperBoundEditable(index)">
|
||||
<template v-if="customInputMode[index]">
|
||||
<Input
|
||||
v-model="customInputValue[index]"
|
||||
type="number"
|
||||
min="1"
|
||||
class="h-7 w-20 text-sm"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 自定义上限(千 Token)`"
|
||||
placeholder="K"
|
||||
@keyup.enter="confirmCustomInput(index)"
|
||||
@blur="confirmCustomInput(index)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">K</span>
|
||||
</template>
|
||||
<select
|
||||
v-else
|
||||
:value="getSelectValue(index)"
|
||||
class="h-7 px-2 text-sm border rounded bg-background"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 上限`"
|
||||
@change="(e) => handleThresholdChange(index, parseInt((e.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option
|
||||
v-for="opt in getAvailableThresholds(index)"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
v-else
|
||||
class="font-medium"
|
||||
>无上限</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheReadEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheRead(index, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-xs text-muted-foreground"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 切换缓存价格输入方式`"
|
||||
@click="toggleCachePriceMode(index)"
|
||||
>
|
||||
<Repeat2 class="mr-1 h-3.5 w-3.5" />
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '价格' : '倍率' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="localTiers.length > 1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 w-7 p-0"
|
||||
:aria-label="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
|
||||
:title="`删除 ${activeProcessingTierLabel} 阶梯 ${index + 1}`"
|
||||
@click="removeTier(index)"
|
||||
>
|
||||
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 价格输入 -->
|
||||
<div
|
||||
v-if="showCache1h"
|
||||
class="space-y-1"
|
||||
class="grid gap-3"
|
||||
:class="[showCache1h ? 'grid-cols-2 lg:grid-cols-5' : 'grid-cols-2 lg:grid-cols-4']"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
|
||||
<Input
|
||||
:model-value="getCache1hDisplay(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
|
||||
:placeholder="getCache1hPlaceholder(index)"
|
||||
@update:model-value="(v) => updateCache1h(index, v)"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输入 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.input_price_per_1m"
|
||||
data-testid="tier-input-price"
|
||||
:data-tier-index="index"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输入价格(美元/百万 Token)`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateInputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">输出 ($/M)</Label>
|
||||
<Input
|
||||
:model-value="tier.output_price_per_1m"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 输出价格(美元/百万 Token)`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateOutputPrice(index, parseFloatInput(v))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '创建(倍率)' : '创建 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheCreationEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存创建${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheCreation(index, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">
|
||||
{{ getCachePriceMode(index) === 'multiplier' ? '读取(倍率)' : '读取 ($/M)' }}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="getCacheReadEditorValue(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:class="getCachePriceMode(index) === 'multiplier' ? 'pr-7' : ''"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 缓存读取${getCachePriceMode(index) === 'multiplier' ? '倍率' : '价格'}`"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => updateCacheRead(index, v)"
|
||||
/>
|
||||
<span
|
||||
v-if="getCachePriceMode(index) === 'multiplier'"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
|
||||
>×</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCache1h"
|
||||
class="space-y-1"
|
||||
>
|
||||
<Label class="text-xs text-muted-foreground">1h 缓存</Label>
|
||||
<Input
|
||||
:model-value="getCache1hDisplay(index)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="h-8"
|
||||
:aria-label="`${activeProcessingTierLabel} 阶梯 ${index + 1} 一小时缓存价格`"
|
||||
:placeholder="getCache1hPlaceholder(index)"
|
||||
@update:model-value="(v) => updateCache1h(index, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加阶梯按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addTier"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
<!-- 添加阶梯按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
@click="addTier"
|
||||
>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
添加价格阶梯
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured"
|
||||
v-if="showProcessingTierMultiplierControls && showTokenPricing !== false"
|
||||
class="space-y-3 border-t border-border/60 pt-3"
|
||||
data-testid="processing-tier-multiplier-list"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
层级倍率(相对标准价格)
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
配置模型级默认倍率;层级是否可用由 Provider 端点/API 格式决定。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="group in compactProcessingTierGroups"
|
||||
:key="group.key"
|
||||
class="space-y-2"
|
||||
:data-processing-tier-group="group.key"
|
||||
>
|
||||
<p
|
||||
v-if="group.label"
|
||||
class="px-1 text-sm font-medium text-foreground"
|
||||
:data-testid="`processing-tier-group-${group.key}`"
|
||||
>
|
||||
{{ group.label }}
|
||||
</p>
|
||||
<div
|
||||
class="space-y-2"
|
||||
:class="group.label ? 'border-l-2 border-border/60 pl-3' : ''"
|
||||
>
|
||||
<div
|
||||
v-for="option in group.options"
|
||||
:key="option.key"
|
||||
class="flex min-h-10 flex-wrap items-center gap-3 rounded-md border border-border/60 px-3 py-2"
|
||||
:data-processing-tier-multiplier="option.key"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="option.enabled"
|
||||
:aria-label="`启用 ${option.accessibleLabel} 层级倍率`"
|
||||
@update:checked="enabled => setCompactProcessingTierEnabled(option.key, enabled)"
|
||||
/>
|
||||
<div class="min-w-32 flex-1">
|
||||
<p class="text-sm font-medium">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="option.detail"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ option.detail }}
|
||||
</p>
|
||||
<p
|
||||
v-if="option.mode === 'custom'"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
已配置自定义价格目录
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="option.mode === 'custom'">
|
||||
<span class="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">自定义价格</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:data-testid="`processing-tier-convert-${option.key}`"
|
||||
@click="startProcessingTierMultiplier(option.key)"
|
||||
>
|
||||
改用倍率
|
||||
</Button>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="relative w-36"
|
||||
>
|
||||
<Input
|
||||
:model-value="option.value"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
class="h-8 pr-7"
|
||||
:disabled="!option.enabled"
|
||||
:data-testid="`processing-tier-multiplier-${option.key}`"
|
||||
:aria-label="`${option.accessibleLabel} 层级倍率`"
|
||||
placeholder="未设置"
|
||||
@update:model-value="value => updateProcessingTierMultiplier(option.key, value)"
|
||||
/>
|
||||
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">×</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showImagePricing && showImageEditor !== false && isActivePricingScopeConfigured && !activeProcessingTierUsesMultiplier"
|
||||
class="space-y-3 border-t border-border/60 pt-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -424,7 +572,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, reactive } from 'vue'
|
||||
import { Plus, Repeat2, Trash2, X } from 'lucide-vue-next'
|
||||
import { Button, Input, Label } from '@/components/ui'
|
||||
import { Button, Checkbox, Input, Label } from '@/components/ui'
|
||||
import { formatTokens } from '@/utils/format'
|
||||
import type {
|
||||
ImageOutputQualityPricing,
|
||||
@@ -470,6 +618,23 @@ type ProcessingTierOption = {
|
||||
label: string
|
||||
configured: boolean
|
||||
}
|
||||
type ProcessingTierMultiplierDraft = {
|
||||
enabled: boolean
|
||||
mode: 'multiplier' | 'custom'
|
||||
value: string
|
||||
}
|
||||
type CompactProcessingTierOption = ProcessingTierMultiplierDraft & {
|
||||
key: string
|
||||
label: string
|
||||
detail?: string
|
||||
group?: string
|
||||
accessibleLabel: string
|
||||
}
|
||||
type CompactProcessingTierGroup = {
|
||||
key: string
|
||||
label: string | null
|
||||
options: CompactProcessingTierOption[]
|
||||
}
|
||||
type PricingScopePolicy = {
|
||||
allowEmptyTiers: boolean
|
||||
terminalUpperBound: 'require-unbounded' | 'finite-or-unbounded'
|
||||
@@ -481,10 +646,15 @@ const props = withDefaults(defineProps<{
|
||||
showCache1h?: boolean
|
||||
showImagePricing?: boolean
|
||||
showImageEditor?: boolean
|
||||
showProcessingTierControls?: boolean
|
||||
showProcessingTierMultiplierControls?: boolean
|
||||
autoFillMissingCachePrices?: boolean
|
||||
}>(), {
|
||||
modelValue: null,
|
||||
showTokenPricing: true,
|
||||
showImageEditor: true,
|
||||
showProcessingTierControls: true,
|
||||
showProcessingTierMultiplierControls: false,
|
||||
autoFillMissingCachePrices: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
@@ -497,7 +667,14 @@ const STANDARD_PRICING_SCOPE = 'standard'
|
||||
const PROCESSING_PRICING_SCOPE_PREFIX = 'processing:'
|
||||
const UNBOUNDED_THRESHOLD_VALUE = -2
|
||||
const KNOWN_PROCESSING_TIERS = [
|
||||
{ key: 'priority', label: 'Priority' },
|
||||
{ key: 'priority', label: 'Fast(OpenAI)' },
|
||||
{ key: 'fast', label: 'Fast(Claude)' },
|
||||
{ key: 'flex', label: 'Flex' },
|
||||
{ key: 'batch', label: 'Batch' },
|
||||
] as const
|
||||
const COMPACT_PROCESSING_TIERS = [
|
||||
{ key: 'priority', label: 'OpenAI', detail: 'Chat / Responses', group: 'Fast' },
|
||||
{ key: 'fast', label: 'Claude', detail: 'Messages', group: 'Fast' },
|
||||
{ key: 'flex', label: 'Flex' },
|
||||
{ key: 'batch', label: 'Batch' },
|
||||
] as const
|
||||
@@ -519,11 +696,17 @@ const cacheManualStateByScope = reactive<Record<string, Record<number, CacheManu
|
||||
const cachePriceModesByScope = reactive<Record<string, Record<number, CachePriceMode>>>({})
|
||||
const cacheMultiplierDraftsByScope = reactive<Record<string, Record<number, CacheMultiplierDraft>>>({})
|
||||
const imagePricingStateByScope = reactive<Record<string, ImagePricingState>>({})
|
||||
const processingTierMultiplierDrafts = reactive<Record<string, ProcessingTierMultiplierDraft>>(
|
||||
Object.create(null) as Record<string, ProcessingTierMultiplierDraft>,
|
||||
)
|
||||
|
||||
const activeProcessingTierKey = computed(() => processingTierKeyFromScope(activePricingScope.value))
|
||||
const isActiveProcessingTierConfigured = computed(() => {
|
||||
const key = activeProcessingTierKey.value
|
||||
return key !== null && hasOwn(processingTierConfigs.value, key)
|
||||
return key !== null && (
|
||||
hasOwn(processingTierConfigs.value, key)
|
||||
|| processingTierMultiplierDrafts[key]?.enabled === true
|
||||
)
|
||||
})
|
||||
const isActivePricingScopeConfigured = computed(() => (
|
||||
activePricingScope.value === STANDARD_PRICING_SCOPE || isActiveProcessingTierConfigured.value
|
||||
@@ -533,6 +716,41 @@ const activeProcessingTierLabel = computed(() => {
|
||||
if (key === null) return 'Standard'
|
||||
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
|
||||
})
|
||||
const activeProcessingTierMultiplierDraft = computed(() => {
|
||||
const key = activeProcessingTierKey.value
|
||||
return key === null ? null : processingTierMultiplierDrafts[key] ?? null
|
||||
})
|
||||
const activeProcessingTierUsesMultiplier = computed(() => (
|
||||
activeProcessingTierKey.value !== null
|
||||
&& activeProcessingTierMultiplierDraft.value?.enabled === true
|
||||
&& activeProcessingTierMultiplierDraft.value.mode === 'multiplier'
|
||||
))
|
||||
const compactProcessingTierOptions = computed<CompactProcessingTierOption[]>(() => (
|
||||
COMPACT_PROCESSING_TIERS.map(option => ({
|
||||
...option,
|
||||
accessibleLabel: [option.group, option.label, 'detail' in option ? option.detail : null]
|
||||
.filter((part): part is string => Boolean(part))
|
||||
.join(' · '),
|
||||
...(processingTierMultiplierDrafts[option.key] ?? {
|
||||
enabled: false,
|
||||
mode: 'multiplier' as const,
|
||||
value: '',
|
||||
}),
|
||||
}))
|
||||
))
|
||||
const compactProcessingTierGroups = computed<CompactProcessingTierGroup[]>(() => {
|
||||
const groups: CompactProcessingTierGroup[] = []
|
||||
for (const option of compactProcessingTierOptions.value) {
|
||||
const key = option.group ? option.group.toLowerCase() : option.key
|
||||
const existing = groups.find(group => group.key === key)
|
||||
if (existing) {
|
||||
existing.options.push(option)
|
||||
} else {
|
||||
groups.push({ key, label: option.group ?? null, options: [option] })
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
const processingTierOptions = computed<ProcessingTierOption[]>(() => {
|
||||
const knownKeys = new Set<string>(KNOWN_PROCESSING_TIERS.map(tier => tier.key))
|
||||
const options: ProcessingTierOption[] = [{
|
||||
@@ -636,6 +854,7 @@ watch(
|
||||
: 'absent'
|
||||
processingTierKeysEdited.value = false
|
||||
resetScopeState()
|
||||
initializeProcessingTierMultiplierDrafts()
|
||||
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
|
||||
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, clonedValue)
|
||||
for (const [key, config] of Object.entries(processingTierConfigs.value)) {
|
||||
@@ -660,6 +879,7 @@ watch(
|
||||
processingTierKeysEdited.value = false
|
||||
originalEmptyProcessingTiers.value = 'absent'
|
||||
resetScopeState()
|
||||
initializeProcessingTierMultiplierDrafts()
|
||||
initializeScopeCacheState(STANDARD_PRICING_SCOPE, standardTiers.value)
|
||||
initializeScopeImagePricingState(STANDARD_PRICING_SCOPE, {})
|
||||
activePricingScope.value = STANDARD_PRICING_SCOPE
|
||||
@@ -668,6 +888,16 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.showProcessingTierControls,
|
||||
(showProcessingTierControls) => {
|
||||
if (showProcessingTierControls) return
|
||||
activePricingScope.value = STANDARD_PRICING_SCOPE
|
||||
resetCustomThresholdState()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function processingTierScope(key: string): string {
|
||||
return `${PROCESSING_PRICING_SCOPE_PREFIX}${key}`
|
||||
}
|
||||
@@ -701,11 +931,53 @@ function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
function processingTierHasExplicitPricingData(config: ProcessingTierPricingConfig): boolean {
|
||||
return (Array.isArray(config.tiers) && config.tiers.length > 0)
|
||||
|| (typeof config.image_output_price_default === 'number'
|
||||
&& Number.isFinite(config.image_output_price_default))
|
||||
|| [
|
||||
'image_output_prices',
|
||||
'image_output_price_ranges',
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
].some(key => valueHasEntries(config[key]))
|
||||
}
|
||||
|
||||
function valueHasEntries(value: unknown): boolean {
|
||||
return (Array.isArray(value) && value.length > 0)
|
||||
|| (isRecord(value) && Object.keys(value).length > 0)
|
||||
}
|
||||
|
||||
function initializeProcessingTierMultiplierDrafts() {
|
||||
const keys = new Set<string>([
|
||||
...KNOWN_PROCESSING_TIERS.map(tier => tier.key),
|
||||
...Object.keys(processingTierConfigs.value),
|
||||
])
|
||||
for (const key of keys) {
|
||||
const config = processingTierConfigs.value[key]
|
||||
const hasMultiplier = isRecord(config)
|
||||
&& !processingTierHasExplicitPricingData(config)
|
||||
&& hasOwn(config, 'price_multiplier')
|
||||
processingTierMultiplierDrafts[key] = {
|
||||
enabled: config !== undefined,
|
||||
mode: hasMultiplier ? 'multiplier' : 'custom',
|
||||
value: hasMultiplier && config.price_multiplier != null
|
||||
? String(config.price_multiplier)
|
||||
: '',
|
||||
}
|
||||
if (config === undefined) {
|
||||
processingTierMultiplierDrafts[key].mode = 'multiplier'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetScopeState() {
|
||||
for (const scope of Object.keys(cacheManualStateByScope)) delete cacheManualStateByScope[scope]
|
||||
for (const scope of Object.keys(cachePriceModesByScope)) delete cachePriceModesByScope[scope]
|
||||
for (const scope of Object.keys(cacheMultiplierDraftsByScope)) delete cacheMultiplierDraftsByScope[scope]
|
||||
for (const scope of Object.keys(imagePricingStateByScope)) delete imagePricingStateByScope[scope]
|
||||
for (const key of Object.keys(processingTierMultiplierDrafts)) delete processingTierMultiplierDrafts[key]
|
||||
resetCustomThresholdState()
|
||||
}
|
||||
|
||||
@@ -868,6 +1140,104 @@ function selectPricingScope(scope: string) {
|
||||
resetCustomThresholdState()
|
||||
}
|
||||
|
||||
function setProcessingTierConfig(key: string, config: ProcessingTierPricingConfig | null) {
|
||||
processingTierConfigs.value = Object.fromEntries(
|
||||
config === null
|
||||
? Object.entries(processingTierConfigs.value).filter(([existingKey]) => existingKey !== key)
|
||||
: [
|
||||
...Object.entries(processingTierConfigs.value)
|
||||
.filter(([existingKey]) => existingKey !== key),
|
||||
[key, config],
|
||||
],
|
||||
)
|
||||
processingTierKeysEdited.value = true
|
||||
}
|
||||
|
||||
function requireProcessingTierMultiplierDraft(key: string): ProcessingTierMultiplierDraft {
|
||||
if (!processingTierMultiplierDrafts[key]) {
|
||||
processingTierMultiplierDrafts[key] = {
|
||||
enabled: false,
|
||||
mode: 'multiplier',
|
||||
value: '',
|
||||
}
|
||||
}
|
||||
return processingTierMultiplierDrafts[key]
|
||||
}
|
||||
|
||||
function parseProcessingTierMultiplier(value: string | number): number | null {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return null
|
||||
const multiplier = Number(raw)
|
||||
return Number.isFinite(multiplier) && multiplier >= 0 ? multiplier : null
|
||||
}
|
||||
|
||||
function startProcessingTierMultiplier(key: string) {
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
draft.enabled = true
|
||||
draft.mode = 'multiplier'
|
||||
draft.value = ''
|
||||
// Keep an existing explicit catalog intact until a valid multiplier is entered.
|
||||
// This lets validation stop an incomplete conversion without silently deleting
|
||||
// the catalog when the parent form is submitted.
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function startActiveProcessingTierMultiplier() {
|
||||
const key = activeProcessingTierKey.value
|
||||
if (key !== null) startProcessingTierMultiplier(key)
|
||||
}
|
||||
|
||||
function updateProcessingTierMultiplier(key: string, value: string | number) {
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
draft.enabled = true
|
||||
draft.mode = 'multiplier'
|
||||
draft.value = String(value ?? '')
|
||||
const multiplier = parseProcessingTierMultiplier(value)
|
||||
if (multiplier !== null) {
|
||||
setProcessingTierConfig(key, { price_multiplier: multiplier })
|
||||
const scope = processingTierScope(key)
|
||||
initializeScopeCacheState(scope, [])
|
||||
initializeScopeImagePricingState(scope, {})
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateActiveProcessingTierMultiplier(value: string | number) {
|
||||
const key = activeProcessingTierKey.value
|
||||
if (key !== null) updateProcessingTierMultiplier(key, value)
|
||||
}
|
||||
|
||||
function setCompactProcessingTierEnabled(key: string, enabled: boolean) {
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
if (!enabled) {
|
||||
draft.enabled = false
|
||||
draft.mode = 'multiplier'
|
||||
draft.value = ''
|
||||
setProcessingTierConfig(key, null)
|
||||
syncToParent()
|
||||
return
|
||||
}
|
||||
if (draft.enabled) return
|
||||
startProcessingTierMultiplier(key)
|
||||
}
|
||||
|
||||
function useCustomPricingForActiveProcessingTier() {
|
||||
const key = activeProcessingTierKey.value
|
||||
if (key === null) return
|
||||
const existingConfig = processingTierConfigs.value[key]
|
||||
const restoredConfig = existingConfig && processingTierHasExplicitPricingData(existingConfig)
|
||||
? cloneJson(existingConfig)
|
||||
: { tiers: cloneJson(standardTiers.value) }
|
||||
setProcessingTierConfig(key, restoredConfig)
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
draft.enabled = true
|
||||
draft.mode = 'custom'
|
||||
draft.value = ''
|
||||
initializeScopeCacheState(activePricingScope.value, restoredConfig.tiers ?? [])
|
||||
initializeScopeImagePricingState(activePricingScope.value, restoredConfig)
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function addActiveProcessingTier() {
|
||||
const key = activeProcessingTierKey.value
|
||||
if (key === null || hasOwn(processingTierConfigs.value, key)) return
|
||||
@@ -878,6 +1248,10 @@ function addActiveProcessingTier() {
|
||||
[key, { tiers }],
|
||||
])
|
||||
processingTierKeysEdited.value = true
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
draft.enabled = true
|
||||
draft.mode = 'custom'
|
||||
draft.value = ''
|
||||
initializeScopeCacheState(activePricingScope.value, tiers)
|
||||
initializeScopeImagePricingState(activePricingScope.value, {})
|
||||
syncToParent()
|
||||
@@ -895,6 +1269,10 @@ function removeActiveProcessingTier() {
|
||||
delete cacheMultiplierDraftsByScope[activePricingScope.value]
|
||||
delete imagePricingStateByScope[activePricingScope.value]
|
||||
processingTierKeysEdited.value = true
|
||||
const draft = requireProcessingTierMultiplierDraft(key)
|
||||
draft.enabled = false
|
||||
draft.mode = 'multiplier'
|
||||
draft.value = ''
|
||||
if (!KNOWN_PROCESSING_TIERS.some(tier => tier.key === key)) {
|
||||
activePricingScope.value = STANDARD_PRICING_SCOPE
|
||||
}
|
||||
@@ -934,9 +1312,14 @@ function replaceCacheTtlPrice(
|
||||
}
|
||||
|
||||
const validationError = computed(() => {
|
||||
const multiplierError = validateProcessingTierMultipliers()
|
||||
if (multiplierError) return multiplierError
|
||||
|
||||
const scopes = [
|
||||
STANDARD_PRICING_SCOPE,
|
||||
...Object.keys(processingTierConfigs.value).map(processingTierScope),
|
||||
...(props.showProcessingTierControls
|
||||
? Object.keys(processingTierConfigs.value).map(processingTierScope)
|
||||
: []),
|
||||
]
|
||||
for (const scope of new Set(scopes)) {
|
||||
const error = validatePricingScope(scope)
|
||||
@@ -945,6 +1328,39 @@ const validationError = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
function processingTierDisplayLabel(key: string): string {
|
||||
const compactTier = COMPACT_PROCESSING_TIERS.find(tier => tier.key === key)
|
||||
if (compactTier) {
|
||||
return [
|
||||
'group' in compactTier ? compactTier.group : null,
|
||||
compactTier.label,
|
||||
'detail' in compactTier ? compactTier.detail : null,
|
||||
].filter((part): part is string => Boolean(part)).join(' · ')
|
||||
}
|
||||
return KNOWN_PROCESSING_TIERS.find(tier => tier.key === key)?.label ?? key
|
||||
}
|
||||
|
||||
function validateProcessingTierMultipliers(): string | null {
|
||||
const keys = new Set<string>([
|
||||
...Object.keys(processingTierConfigs.value),
|
||||
...Object.keys(processingTierMultiplierDrafts),
|
||||
...(props.showProcessingTierMultiplierControls
|
||||
? COMPACT_PROCESSING_TIERS.map(tier => tier.key)
|
||||
: []),
|
||||
])
|
||||
for (const key of keys) {
|
||||
const draft = processingTierMultiplierDrafts[key]
|
||||
if (!draft?.enabled || draft.mode !== 'multiplier') continue
|
||||
if (!draft.value.trim()) {
|
||||
return `${processingTierDisplayLabel(key)}: 请输入层级倍率`
|
||||
}
|
||||
if (parseProcessingTierMultiplier(draft.value) === null) {
|
||||
return `${processingTierDisplayLabel(key)}: 层级倍率必须是非负有限数值`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validatePricingScope(scope: string): string | null {
|
||||
const processingTierKey = processingTierKeyFromScope(scope)
|
||||
const tierError = validatePricingTiers(tiersForScope(scope), pricingScopePolicy(scope))
|
||||
@@ -1152,25 +1568,37 @@ function buildPricingConfig(includeAutomaticCache: boolean): TieredPricingConfig
|
||||
const config = cloneJson(basePricingConfig.value) as TieredPricingConfig
|
||||
config.tiers = buildTiersForScope(STANDARD_PRICING_SCOPE, includeAutomaticCache)
|
||||
|
||||
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
|
||||
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
|
||||
const serializedOverlay = cloneJson(overlay)
|
||||
if (Array.isArray(overlay.tiers)) {
|
||||
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
|
||||
if (props.showProcessingTierControls) {
|
||||
const processingTierEntries: Array<[string, ProcessingTierPricingConfig]> = []
|
||||
for (const [key, overlay] of Object.entries(processingTierConfigs.value)) {
|
||||
const serializedOverlay = cloneJson(overlay)
|
||||
if (Array.isArray(overlay.tiers)) {
|
||||
serializedOverlay.tiers = buildTiersForScope(processingTierScope(key), includeAutomaticCache)
|
||||
}
|
||||
if (props.showImagePricing) {
|
||||
applyImagePricing(serializedOverlay, processingTierScope(key))
|
||||
}
|
||||
processingTierEntries.push([key, serializedOverlay])
|
||||
}
|
||||
if (props.showImagePricing) {
|
||||
applyImagePricing(serializedOverlay, processingTierScope(key))
|
||||
const processingTiers = Object.fromEntries(processingTierEntries)
|
||||
delete config.processing_tiers
|
||||
if (Object.keys(processingTiers).length > 0) {
|
||||
config.processing_tiers = processingTiers
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
|
||||
config.processing_tiers = null
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
|
||||
config.processing_tiers = {}
|
||||
}
|
||||
} else {
|
||||
const processingTiers = cloneJson(processingTierConfigs.value)
|
||||
delete config.processing_tiers
|
||||
if (Object.keys(processingTiers).length > 0) {
|
||||
config.processing_tiers = processingTiers
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
|
||||
config.processing_tiers = null
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
|
||||
config.processing_tiers = {}
|
||||
}
|
||||
processingTierEntries.push([key, serializedOverlay])
|
||||
}
|
||||
const processingTiers = Object.fromEntries(processingTierEntries)
|
||||
delete config.processing_tiers
|
||||
if (Object.keys(processingTiers).length > 0) {
|
||||
config.processing_tiers = processingTiers
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'null') {
|
||||
config.processing_tiers = null
|
||||
} else if (!processingTierKeysEdited.value && originalEmptyProcessingTiers.value === 'object') {
|
||||
config.processing_tiers = {}
|
||||
}
|
||||
|
||||
if (props.showImagePricing) {
|
||||
|
||||
+32
@@ -167,6 +167,10 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
findButton('Stale Model').click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-processing-tier="priority"]')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('自定义价格')
|
||||
|
||||
await setInput(
|
||||
document.body.querySelector<HTMLInputElement>('input[placeholder="如 0.01"]'),
|
||||
'0.25',
|
||||
@@ -246,4 +250,32 @@ describe('GlobalModelFormDialog preset replacement', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('submits a compact processing-tier multiplier without a Standard overlay', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
findButton('Fresh Model').click()
|
||||
await settle()
|
||||
|
||||
const priorityToggle = document.body.querySelector(
|
||||
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
|
||||
) as HTMLInputElement
|
||||
priorityToggle.click()
|
||||
await nextTick()
|
||||
await setInput(
|
||||
document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
),
|
||||
'2.5',
|
||||
)
|
||||
|
||||
findExactButton('添加').click()
|
||||
await settle()
|
||||
|
||||
const payload = globalModelMocks.createGlobalModel.mock.calls[0][0]
|
||||
expect(payload.default_tiered_pricing.processing_tiers).toEqual({
|
||||
priority: { price_multiplier: 2.5 },
|
||||
})
|
||||
expect(payload.default_tiered_pricing.processing_tiers).not.toHaveProperty('standard')
|
||||
})
|
||||
})
|
||||
|
||||
+31
@@ -31,6 +31,37 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ProcessingTierPricingSummary', () => {
|
||||
it('shows multiplier-only processing tiers', async () => {
|
||||
const root = mountSummary({
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2.5 },
|
||||
fast: { price_multiplier: 2 },
|
||||
flex: {
|
||||
price_multiplier: 99,
|
||||
tiers: [{ up_to: null, input_price_per_1m: 2.5, output_price_per_1m: 15 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(root.querySelector('[data-processing-tier="priority"]')?.textContent)
|
||||
.toContain('Fast(OpenAI)')
|
||||
expect(root.querySelector('[data-processing-tier="fast"]')?.textContent)
|
||||
.toContain('Fast(Claude)')
|
||||
expect(root.textContent).not.toContain('Priority')
|
||||
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
|
||||
.toContain('2.5×')
|
||||
clickTier(root, 'fast')
|
||||
await nextTick()
|
||||
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')?.textContent)
|
||||
.toContain('2×')
|
||||
|
||||
clickTier(root, 'flex')
|
||||
await nextTick()
|
||||
expect(root.querySelector('[data-testid="processing-tier-price-multiplier"]')).toBeNull()
|
||||
expect(root.querySelectorAll('[data-testid="processing-token-tier-row"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows finite and unbounded token tiers in stable processing-tier order', () => {
|
||||
const root = mountSummary({
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
|
||||
+302
-9
@@ -27,12 +27,15 @@ function mountEditor(
|
||||
showImagePricing?: boolean
|
||||
showTokenPricing?: boolean
|
||||
showImageEditor?: boolean
|
||||
showProcessingTierControls?: boolean
|
||||
showProcessingTierMultiplierControls?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const onUpdate = vi.fn()
|
||||
const currentModelValue = shallowRef(modelValue)
|
||||
const showProcessingTierControls = shallowRef(options.showProcessingTierControls)
|
||||
let editor: TieredPricingEditorExposed | null = null
|
||||
|
||||
const app = createApp(defineComponent({
|
||||
@@ -47,6 +50,8 @@ function mountEditor(
|
||||
showImagePricing: options.showImagePricing,
|
||||
showTokenPricing: options.showTokenPricing,
|
||||
showImageEditor: options.showImageEditor,
|
||||
showProcessingTierControls: showProcessingTierControls.value,
|
||||
showProcessingTierMultiplierControls: options.showProcessingTierMultiplierControls,
|
||||
'onUpdate:modelValue': onUpdate,
|
||||
})
|
||||
},
|
||||
@@ -61,6 +66,9 @@ function mountEditor(
|
||||
setModelValue: (value: TieredPricingConfig) => {
|
||||
currentModelValue.value = value
|
||||
},
|
||||
setShowProcessingTierControls: (value: boolean) => {
|
||||
showProcessingTierControls.value = value
|
||||
},
|
||||
getFinalPricing: () => {
|
||||
if (!editor) throw new Error('TieredPricingEditor ref was not mounted')
|
||||
return editor.getFinalPricing()
|
||||
@@ -87,6 +95,288 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('TieredPricingEditor processing tiers', () => {
|
||||
it('hides processing-tier controls while editing Standard and preserving overlays', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: {
|
||||
price_multiplier: 999,
|
||||
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
|
||||
future_overlay_option: 'keep-hidden-overlay',
|
||||
},
|
||||
},
|
||||
} as TieredPricingConfig
|
||||
const {
|
||||
root,
|
||||
onUpdate,
|
||||
getFinalPricing,
|
||||
setShowProcessingTierControls,
|
||||
} = mountEditor(pricing)
|
||||
|
||||
click(root.querySelector('[data-processing-tier="priority"]'))
|
||||
await nextTick()
|
||||
expect(root.querySelector<HTMLInputElement>('[data-testid="tier-input-price"]')?.value)
|
||||
.toBe('10')
|
||||
|
||||
setShowProcessingTierControls(false)
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[data-processing-tier]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('处理层级')
|
||||
|
||||
const input = root.querySelector('[data-testid="tier-input-price"]') as HTMLInputElement | null
|
||||
if (!input) throw new Error('Expected the Standard input-price control')
|
||||
input.value = '7.5'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
const emitted = onUpdate.mock.lastCall?.[0] as TieredPricingConfig
|
||||
expect(emitted.tiers[0].input_price_per_1m).toBe(7.5)
|
||||
expect(emitted.processing_tiers).toEqual(pricing.processing_tiers)
|
||||
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
|
||||
})
|
||||
|
||||
it('edits compact processing-tier multipliers without writing a Standard overlay', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
} as TieredPricingConfig
|
||||
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
|
||||
showProcessingTierControls: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
|
||||
expect(root.querySelector('[data-testid="processing-tier-group-fast"]')?.textContent)
|
||||
.toBe('Fast')
|
||||
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
|
||||
.toContain('OpenAI')
|
||||
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
|
||||
.toContain('Chat / Responses')
|
||||
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
|
||||
.toContain('Claude')
|
||||
expect(root.querySelector('[data-processing-tier-group="fast"]')?.textContent)
|
||||
.toContain('Messages')
|
||||
|
||||
const priorityToggle = root.querySelector(
|
||||
'input[aria-label="启用 Fast · OpenAI · Chat / Responses 层级倍率"]',
|
||||
) as HTMLInputElement
|
||||
priorityToggle.click()
|
||||
await nextTick()
|
||||
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
) as HTMLInputElement
|
||||
multiplier.value = '2.5'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(getValidationError()).toBeNull()
|
||||
expect(getFinalPricing().processing_tiers).toEqual({
|
||||
priority: { price_multiplier: 2.5 },
|
||||
})
|
||||
expect(getFinalPricing().processing_tiers).not.toHaveProperty('standard')
|
||||
|
||||
priorityToggle.click()
|
||||
await nextTick()
|
||||
expect(getFinalPricing()).not.toHaveProperty('processing_tiers')
|
||||
})
|
||||
|
||||
it('keeps the grouped Claude Fast option mapped to the internal fast key', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
} as TieredPricingConfig
|
||||
const { root, getFinalPricing } = mountEditor(pricing, {
|
||||
showProcessingTierControls: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
const fastToggle = root.querySelector(
|
||||
'input[aria-label="启用 Fast · Claude · Messages 层级倍率"]',
|
||||
) as HTMLInputElement
|
||||
|
||||
fastToggle.click()
|
||||
await nextTick()
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-fast"]',
|
||||
) as HTMLInputElement
|
||||
multiplier.value = '2'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(getFinalPricing().processing_tiers).toEqual({
|
||||
fast: { price_multiplier: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('requires an enabled processing-tier multiplier instead of clearing the saved value', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: { priority: { price_multiplier: 2.5 } },
|
||||
} as TieredPricingConfig
|
||||
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
|
||||
showProcessingTierControls: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
) as HTMLInputElement
|
||||
|
||||
multiplier.value = ''
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a custom catalog until the user explicitly replaces it with a multiplier', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 60 }],
|
||||
future_overlay_option: 'replace-with-catalog',
|
||||
},
|
||||
hyperlane: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
|
||||
future_overlay_option: 'keep-unknown',
|
||||
},
|
||||
},
|
||||
} as TieredPricingConfig
|
||||
const { root, onUpdate, getFinalPricing, getValidationError } = mountEditor(pricing, {
|
||||
showProcessingTierControls: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
|
||||
expect(root.textContent).toContain('自定义价格')
|
||||
expect(getFinalPricing().processing_tiers).toEqual(pricing.processing_tiers)
|
||||
|
||||
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
|
||||
await nextTick()
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
) as HTMLInputElement
|
||||
multiplier.value = '2'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(getFinalPricing().processing_tiers).toEqual({
|
||||
priority: { price_multiplier: 2 },
|
||||
hyperlane: pricing.processing_tiers?.hyperlane,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates compact multipliers as finite non-negative numbers', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: { flex: { price_multiplier: 0.5 } },
|
||||
} as TieredPricingConfig
|
||||
const { root, getValidationError } = mountEditor(pricing, {
|
||||
showProcessingTierControls: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-flex"]',
|
||||
) as HTMLInputElement
|
||||
expect(multiplier.value).toBe('0.5')
|
||||
|
||||
multiplier.value = '-1'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(getValidationError()).toContain('必须是非负有限数值')
|
||||
})
|
||||
|
||||
it('requires a full-editor multiplier before persisting the new tier', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
} as TieredPricingConfig
|
||||
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
|
||||
autoFillMissingCachePrices: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
|
||||
click(root.querySelector('[data-processing-tier="priority"]'))
|
||||
await nextTick()
|
||||
click(root.querySelector('[data-testid="processing-tier-add-multiplier"]'))
|
||||
await nextTick()
|
||||
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-input"]',
|
||||
) as HTMLInputElement
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
|
||||
|
||||
multiplier.value = '-1'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(getValidationError()).toContain('必须是非负有限数值')
|
||||
|
||||
multiplier.value = ''
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
expect(() => getFinalPricing()).toThrow('请输入层级倍率')
|
||||
})
|
||||
|
||||
it('restores an explicit catalog when an incomplete multiplier conversion is cancelled', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 11, output_price_per_1m: 66 }],
|
||||
future_overlay_option: 'keep-on-cancel',
|
||||
},
|
||||
},
|
||||
} as TieredPricingConfig
|
||||
const { root, getFinalPricing, getValidationError } = mountEditor(pricing, {
|
||||
autoFillMissingCachePrices: false,
|
||||
showProcessingTierMultiplierControls: true,
|
||||
})
|
||||
|
||||
click(root.querySelector('[data-testid="processing-tier-convert-priority"]'))
|
||||
click(root.querySelector('[data-processing-tier="priority"]'))
|
||||
await nextTick()
|
||||
expect(getValidationError()).toContain('请输入层级倍率')
|
||||
|
||||
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
|
||||
await nextTick()
|
||||
|
||||
expect(getValidationError()).toBeNull()
|
||||
expect(getFinalPricing().processing_tiers?.priority).toEqual(
|
||||
pricing.processing_tiers?.priority,
|
||||
)
|
||||
})
|
||||
|
||||
it('lets the full Provider editor edit a multiplier or replace it with explicit prices', async () => {
|
||||
const pricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: { priority: { price_multiplier: 2.5 } },
|
||||
} as TieredPricingConfig
|
||||
const { root, getFinalPricing } = mountEditor(pricing)
|
||||
|
||||
click(root.querySelector('[data-processing-tier="priority"]'))
|
||||
await nextTick()
|
||||
const multiplier = root.querySelector(
|
||||
'[data-testid="processing-tier-multiplier-input"]',
|
||||
) as HTMLInputElement
|
||||
expect(multiplier.value).toBe('2.5')
|
||||
multiplier.value = '3'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(getFinalPricing().processing_tiers?.priority).toEqual({ price_multiplier: 3 })
|
||||
|
||||
click(root.querySelector('[data-testid="processing-tier-use-custom"]'))
|
||||
await nextTick()
|
||||
expect(getFinalPricing().processing_tiers?.priority).toMatchObject({
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
})
|
||||
expect(getFinalPricing().processing_tiers?.priority).not.toHaveProperty('price_multiplier')
|
||||
})
|
||||
|
||||
it('round-trips root, overlay and pricing-tier extension fields', () => {
|
||||
const pricing = {
|
||||
tiers: [{
|
||||
@@ -140,9 +430,11 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
} as TieredPricingConfig
|
||||
const { root, onUpdate } = mountEditor(pricing)
|
||||
|
||||
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(5)
|
||||
expect(root.querySelectorAll('[data-processing-tier]')).toHaveLength(6)
|
||||
expect(root.textContent).toContain('Standard')
|
||||
expect(root.textContent).toContain('Priority')
|
||||
expect(root.textContent).toContain('Fast(OpenAI)')
|
||||
expect(root.textContent).toContain('Fast(Claude)')
|
||||
expect(root.textContent).not.toContain('Priority')
|
||||
expect(root.textContent).toContain('Flex')
|
||||
expect(root.textContent).toContain('Batch')
|
||||
expect(root.textContent).toContain('hyperlane')
|
||||
@@ -283,7 +575,7 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
click(root.querySelector('[data-processing-tier="priority"]'))
|
||||
await nextTick()
|
||||
const multiplier = root.querySelector(
|
||||
'input[aria-label="Priority 阶梯 1 缓存创建倍率"]',
|
||||
'input[aria-label="Fast(OpenAI) 阶梯 1 缓存创建倍率"]',
|
||||
) as HTMLInputElement
|
||||
multiplier.value = '2'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
@@ -453,7 +745,8 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(root.querySelector('[data-testid="tier-input-price"]')).toBeNull()
|
||||
expect(root.querySelector('input[aria-label="Priority 图像输出默认价格"]')).not.toBeNull()
|
||||
expect(root.querySelector('input[aria-label="Fast(OpenAI) 图像输出默认价格"]'))
|
||||
.not.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts a finite terminal tier for any processing overlay', () => {
|
||||
@@ -500,7 +793,7 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
await nextTick()
|
||||
|
||||
const terminal = root.querySelector(
|
||||
'select[aria-label="Priority 阶梯 1 上限"]',
|
||||
'select[aria-label="Fast(OpenAI) 阶梯 1 上限"]',
|
||||
) as HTMLSelectElement
|
||||
expect(terminal.value).toBe('272000')
|
||||
|
||||
@@ -535,7 +828,7 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
|
||||
.toEqual([272000, null])
|
||||
|
||||
click(root.querySelector('button[aria-label="删除 Priority 阶梯 2"]'))
|
||||
click(root.querySelector('button[aria-label="删除 Fast(OpenAI) 阶梯 2"]'))
|
||||
await nextTick()
|
||||
expect(getFinalPricing().processing_tiers?.priority.tiers?.map(tier => tier.up_to))
|
||||
.toEqual([272000])
|
||||
@@ -650,7 +943,7 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
await nextTick()
|
||||
|
||||
const priorityDefault = root.querySelector(
|
||||
'input[aria-label="Priority 图像输出默认价格"]',
|
||||
'input[aria-label="Fast(OpenAI) 图像输出默认价格"]',
|
||||
) as HTMLInputElement
|
||||
const priorityHigh = root.querySelector(
|
||||
'input[aria-label="1024x1024 high 图像输出价格"]',
|
||||
@@ -754,9 +1047,9 @@ describe('TieredPricingEditor processing tiers', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(getValidationError()).toContain('Priority')
|
||||
expect(getValidationError()).toContain('Fast(OpenAI)')
|
||||
expect(getValidationError()).toContain('上限必须大于前一个阶梯')
|
||||
expect(() => getFinalPricing()).toThrow('Priority')
|
||||
expect(() => getFinalPricing()).toThrow('Fast(OpenAI)')
|
||||
})
|
||||
|
||||
it('rejects negative known prices before they reach the billing contract', () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type {
|
||||
ProviderTieredPricingConfig,
|
||||
ProcessingTierPricingConfig,
|
||||
TieredPricingConfig,
|
||||
} from '@/api/endpoints/types'
|
||||
|
||||
type PricingCatalog = TieredPricingConfig | ProcessingTierPricingConfig
|
||||
type PricingCatalog = ProviderTieredPricingConfig | ProcessingTierPricingConfig
|
||||
type RootPricingCatalog = TieredPricingConfig | ProviderTieredPricingConfig
|
||||
|
||||
export function comparePricingUpperBounds(
|
||||
left: number | null,
|
||||
@@ -15,7 +17,7 @@ export function comparePricingUpperBounds(
|
||||
return left - right
|
||||
}
|
||||
|
||||
function pricingCatalogs(pricing: TieredPricingConfig | null | undefined): PricingCatalog[] {
|
||||
function pricingCatalogs(pricing: RootPricingCatalog | null | undefined): PricingCatalog[] {
|
||||
if (!pricing) return []
|
||||
const processingTiers = pricing.processing_tiers
|
||||
? Object.values(pricing.processing_tiers).filter(isRecord)
|
||||
@@ -24,7 +26,7 @@ function pricingCatalogs(pricing: TieredPricingConfig | null | undefined): Prici
|
||||
}
|
||||
|
||||
export function tieredPricingHasImageOutputPricing(
|
||||
pricing: TieredPricingConfig | null | undefined,
|
||||
pricing: RootPricingCatalog | null | undefined,
|
||||
): boolean {
|
||||
return pricingCatalogs(pricing).some((catalog) => {
|
||||
if (toFinitePrice(catalog.image_output_price_default) !== null) return true
|
||||
@@ -41,7 +43,7 @@ export function tieredPricingHasImageOutputPricing(
|
||||
}
|
||||
|
||||
export function tieredPricingHasCacheTtl(
|
||||
pricing: TieredPricingConfig | null | undefined,
|
||||
pricing: RootPricingCatalog | null | undefined,
|
||||
ttlMinutes: number,
|
||||
): boolean {
|
||||
return pricingCatalogs(pricing).some(catalog => (
|
||||
|
||||
@@ -160,122 +160,169 @@
|
||||
</div>
|
||||
|
||||
<!-- 价格配置 -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold text-sm border-b pb-2">
|
||||
价格配置
|
||||
<section class="space-y-3 rounded-lg border bg-card p-4">
|
||||
<h4 class="font-medium text-sm">
|
||||
选择计费模式
|
||||
</h4>
|
||||
<TieredPricingEditor
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
/>
|
||||
<Tabs
|
||||
v-model="billingMode"
|
||||
@update:model-value="handleBillingModeChange"
|
||||
>
|
||||
<TabsList class="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="token">
|
||||
Token
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="request">
|
||||
按次
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="image">
|
||||
图片
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="video">
|
||||
视频
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- 按次计费 -->
|
||||
<div class="flex items-center gap-3 pt-2 border-t">
|
||||
<Label class="text-xs whitespace-nowrap">按次计费 ($/次)</Label>
|
||||
<Input
|
||||
:model-value="form.price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="w-32"
|
||||
placeholder="留空使用默认值"
|
||||
@update:model-value="(v) => form.price_per_request = parseNumberInput(v, { allowFloat: true })"
|
||||
<TieredPricingEditor
|
||||
v-show="billingMode === 'token' || billingMode === 'image'"
|
||||
ref="tieredPricingEditorRef"
|
||||
v-model="tieredPricing"
|
||||
class="mt-3"
|
||||
:auto-fill-missing-cache-prices="autoFillMissingCachePrices"
|
||||
:show-token-pricing="billingMode === 'token'"
|
||||
:show-image-pricing="isImageGenerationEnabled"
|
||||
:show-image-editor="billingMode === 'image'"
|
||||
:show-processing-tier-multiplier-controls="true"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">每次请求固定费用,留空使用全局模型默认值</span>
|
||||
</div>
|
||||
|
||||
<!-- 视频计费(可选覆盖) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(可选覆盖)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
||||
>
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
<TabsContent
|
||||
value="request"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
<div class="rounded-lg border bg-muted/20 p-4 space-y-2">
|
||||
<Label class="text-xs">每次请求价格(美元)</Label>
|
||||
<Input
|
||||
:model-value="form.price_per_request ?? ''"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
class="max-w-48"
|
||||
placeholder="留空使用全局模型默认值"
|
||||
@update:model-value="updatePricePerRequest"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按每次 API 请求收取固定费用;未修改时继续继承全局模型。
|
||||
</p>
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
@update:model-value="() => { configTouched = true }"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="video"
|
||||
class="pt-2"
|
||||
>
|
||||
<div class="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
根据输出分辨率配置每秒视频价格;未修改时继续继承全局模型。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('common'); configTouched = true }"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
通用
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('sora'); configTouched = true }"
|
||||
>
|
||||
Sora
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { fillVideoResolutionPricePreset('veo'); configTouched = true }"
|
||||
>
|
||||
Veo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-7 text-xs"
|
||||
@click="() => { addVideoResolutionPriceRow(); configTouched = true }"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-0.5" />
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="videoResolutionPrices.length > 0"
|
||||
class="rounded-lg border border-border overflow-hidden"
|
||||
>
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
v-for="(row, idx) in videoResolutionPrices"
|
||||
:key="idx"
|
||||
class="grid grid-cols-[1fr_1fr_32px] gap-2 items-center px-3 py-1.5"
|
||||
>
|
||||
<Input
|
||||
v-model="row.resolution"
|
||||
class="h-7 text-sm"
|
||||
placeholder="如 720p"
|
||||
@update:model-value="() => { configTouched = true }"
|
||||
/>
|
||||
<Input
|
||||
:model-value="row.price_per_second ?? ''"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0"
|
||||
class="h-7 text-sm"
|
||||
placeholder="0"
|
||||
@update:model-value="(v) => { row.price_per_second = parseNumberInput(v, { allowFloat: true }); configTouched = true }"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
title="删除"
|
||||
@click="() => { removeVideoResolutionPriceRow(idx); configTouched = true }"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed py-8 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
选择一个价格预设或添加自定义分辨率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -315,17 +362,32 @@ import {
|
||||
SelectItem,
|
||||
Badge,
|
||||
Checkbox,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||
import { createModel, updateModel, getProviderModels } from '@/api/endpoints/models'
|
||||
import { createGlobalModel, listGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
||||
import {
|
||||
createGlobalModel,
|
||||
getGlobalModel,
|
||||
listGlobalModels,
|
||||
type GlobalModelResponse,
|
||||
} from '@/api/global-models'
|
||||
import TieredPricingEditor from '@/features/models/components/TieredPricingEditor.vue'
|
||||
import { tieredPricingHasImageOutputPricing } from '@/features/models/utils/tiered-pricing'
|
||||
import type { Model, TieredPricingConfig } from '@/api/endpoints'
|
||||
import type {
|
||||
Model,
|
||||
ProviderTieredPricingConfig,
|
||||
TieredPricingConfig,
|
||||
} from '@/api/endpoints'
|
||||
import {
|
||||
buildProviderTieredPricingOverride,
|
||||
buildProviderModelCreatePayload,
|
||||
buildProviderModelUpdatePayload,
|
||||
mergeProviderTieredPricingForEditing,
|
||||
modelSupportsEmbedding,
|
||||
} from './provider-model-form-helpers'
|
||||
|
||||
@@ -385,6 +447,7 @@ const submitting = ref(false)
|
||||
const loadingGlobalModels = ref(false)
|
||||
const availableGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const manualGlobalModelMode = ref(false)
|
||||
const billingMode = ref('token')
|
||||
|
||||
// 阶梯计费配置
|
||||
const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
@@ -392,6 +455,10 @@ const tieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
const tieredPricingModified = ref(false)
|
||||
// 保存原始配置用于比较
|
||||
const originalTieredPricing = ref<string>('')
|
||||
const originalEditorTieredPricing = ref<TieredPricingConfig | null>(null)
|
||||
const originalProviderTieredPricing = ref<ProviderTieredPricingConfig | null>(null)
|
||||
const pricePerRequestModified = ref(false)
|
||||
const originalPricePerRequest = ref<number | undefined>(undefined)
|
||||
|
||||
type VideoResolutionPriceRow = { resolution: string; price_per_second: number | undefined }
|
||||
|
||||
@@ -439,6 +506,7 @@ const form = ref({
|
||||
is_active: true
|
||||
})
|
||||
const imageGenerationExplicitOverride = ref<boolean | null>(null)
|
||||
const autoFillMissingCachePrices = computed(() => !isEditing.value && manualGlobalModelMode.value)
|
||||
|
||||
const canSubmitCreate = computed(() => {
|
||||
if (isEditing.value) return true
|
||||
@@ -455,7 +523,6 @@ watch(() => props.open, async (newOpen) => {
|
||||
// 编辑模式:填充表单
|
||||
// 使用有效配置(合并全局模型的默认值)供用户查看和编辑
|
||||
const effectiveConfig = props.editingModel.effective_config || props.editingModel.config || {}
|
||||
const supportsImageGeneration = modelSupportsImageGeneration(props.editingModel)
|
||||
form.value = {
|
||||
global_model_id: props.editingModel.global_model_id || '',
|
||||
provider_model_name: props.editingModel.provider_model_name || '',
|
||||
@@ -468,16 +535,45 @@ watch(() => props.open, async (newOpen) => {
|
||||
supports_function_calling: props.editingModel.supports_function_calling ?? undefined,
|
||||
supports_streaming: props.editingModel.supports_streaming ?? undefined,
|
||||
supports_extended_thinking: props.editingModel.supports_extended_thinking ?? undefined,
|
||||
supports_image_generation: supportsImageGeneration ? true : props.editingModel.supports_image_generation ?? undefined,
|
||||
supports_image_generation: props.editingModel.supports_image_generation ?? undefined,
|
||||
is_active: props.editingModel.is_active
|
||||
}
|
||||
// 从有效配置中加载视频费用
|
||||
loadVideoPricingFromConfig(effectiveConfig)
|
||||
// 加载阶梯计费配置:优先使用 Provider 自定义配置,否则使用有效配置(继承自全局模型)
|
||||
const pricing = props.editingModel.tiered_pricing || props.editingModel.effective_tiered_pricing
|
||||
// Provider 可以只覆盖 processing_tiers。此时后端 effective_tiered_pricing
|
||||
// 仍是原始 partial JSON,需要取 GlobalModel 默认目录合成完整编辑视图。
|
||||
const providerPricing = props.editingModel.tiered_pricing
|
||||
? JSON.parse(JSON.stringify(props.editingModel.tiered_pricing)) as ProviderTieredPricingConfig
|
||||
: null
|
||||
let globalDefaultPricing = providerPricing
|
||||
? null
|
||||
: props.editingModel.effective_tiered_pricing
|
||||
if (providerPricing && props.editingModel.global_model_id) {
|
||||
try {
|
||||
const globalModel = await getGlobalModel(props.editingModel.global_model_id)
|
||||
globalDefaultPricing = globalModel.default_tiered_pricing
|
||||
} catch (err: unknown) {
|
||||
if (!providerPricing.tiers?.length) {
|
||||
showError(parseApiError(err, '加载 GlobalModel 默认价格失败'), '错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
const pricing = mergeProviderTieredPricingForEditing(globalDefaultPricing, providerPricing)
|
||||
|| (props.editingModel.effective_tiered_pricing?.tiers?.length
|
||||
? props.editingModel.effective_tiered_pricing
|
||||
: null)
|
||||
if (pricing) {
|
||||
tieredPricing.value = JSON.parse(JSON.stringify(pricing))
|
||||
}
|
||||
originalEditorTieredPricing.value = tieredPricing.value
|
||||
? JSON.parse(JSON.stringify(tieredPricing.value))
|
||||
: null
|
||||
originalProviderTieredPricing.value = providerPricing
|
||||
originalTieredPricing.value = JSON.stringify(tieredPricing.value)
|
||||
tieredPricingModified.value = false
|
||||
originalPricePerRequest.value = form.value.price_per_request
|
||||
pricePerRequestModified.value = false
|
||||
selectInitialBillingMode()
|
||||
} else {
|
||||
// 添加模式:加载可用全局模型
|
||||
await loadAvailableGlobalModels()
|
||||
@@ -497,21 +593,30 @@ watch(() => form.value.global_model_id, (newId) => {
|
||||
// 深拷贝阶梯计费配置用于预览
|
||||
const pricingCopy = JSON.parse(JSON.stringify(selectedModel.default_tiered_pricing))
|
||||
tieredPricing.value = pricingCopy
|
||||
originalEditorTieredPricing.value = JSON.parse(JSON.stringify(pricingCopy))
|
||||
originalProviderTieredPricing.value = null
|
||||
// 保存原始配置用于比较
|
||||
originalTieredPricing.value = JSON.stringify(pricingCopy)
|
||||
} else {
|
||||
tieredPricing.value = null
|
||||
originalTieredPricing.value = ''
|
||||
originalEditorTieredPricing.value = null
|
||||
originalProviderTieredPricing.value = null
|
||||
originalTieredPricing.value = JSON.stringify(null)
|
||||
}
|
||||
tieredPricingModified.value = false
|
||||
// 同时继承按次计费(仅供预览)
|
||||
form.value.price_per_request = selectedModel?.default_price_per_request ?? undefined
|
||||
originalPricePerRequest.value = form.value.price_per_request
|
||||
pricePerRequestModified.value = false
|
||||
loadVideoPricingFromConfig(selectedModel?.config || {})
|
||||
configTouched.value = false
|
||||
selectInitialBillingMode()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听阶梯配置变化,标记为已修改
|
||||
watch(tieredPricing, (newValue) => {
|
||||
if (!isEditing.value && originalTieredPricing.value) {
|
||||
if (originalTieredPricing.value) {
|
||||
const newJson = JSON.stringify(newValue)
|
||||
tieredPricingModified.value = newJson !== originalTieredPricing.value
|
||||
}
|
||||
@@ -539,8 +644,37 @@ function resetForm() {
|
||||
tieredPricing.value = null
|
||||
tieredPricingModified.value = false
|
||||
originalTieredPricing.value = ''
|
||||
originalEditorTieredPricing.value = null
|
||||
originalProviderTieredPricing.value = null
|
||||
pricePerRequestModified.value = false
|
||||
originalPricePerRequest.value = undefined
|
||||
availableGlobalModels.value = []
|
||||
manualGlobalModelMode.value = false
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
|
||||
function updatePricePerRequest(value: string | number) {
|
||||
form.value.price_per_request = parseNumberInput(value, { allowFloat: true })
|
||||
pricePerRequestModified.value = form.value.price_per_request !== originalPricePerRequest.value
|
||||
}
|
||||
|
||||
function handleBillingModeChange(mode: string) {
|
||||
billingMode.value = mode
|
||||
if (mode === 'image' && !isImageGenerationEnabled.value) {
|
||||
setImageGenerationEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
function selectInitialBillingMode() {
|
||||
if (videoResolutionPrices.value.length > 0) {
|
||||
billingMode.value = 'video'
|
||||
} else if (isImageGenerationEnabled.value) {
|
||||
billingMode.value = 'image'
|
||||
} else if (form.value.price_per_request !== undefined) {
|
||||
billingMode.value = 'request'
|
||||
} else {
|
||||
billingMode.value = 'token'
|
||||
}
|
||||
}
|
||||
|
||||
function handleGlobalModelSelect(value: string) {
|
||||
@@ -556,8 +690,8 @@ function modelSupportsImageGeneration(model: {
|
||||
supports_image_generation?: boolean | null
|
||||
effective_supports_image_generation?: boolean | null
|
||||
default_tiered_pricing?: TieredPricingConfig | null
|
||||
tiered_pricing?: TieredPricingConfig | null
|
||||
effective_tiered_pricing?: TieredPricingConfig | null
|
||||
tiered_pricing?: ProviderTieredPricingConfig | null
|
||||
effective_tiered_pricing?: ProviderTieredPricingConfig | null
|
||||
config?: Record<string, unknown> | null
|
||||
} | null | undefined): boolean {
|
||||
if (!model) return false
|
||||
@@ -798,8 +932,14 @@ async function handleSubmit() {
|
||||
try {
|
||||
// 获取包含自动计算缓存价格的最终数据
|
||||
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||
const supportsImageGeneration = isImageGenerationEnabled.value
|
||||
|| tieredPricingHasImageOutputPricing(finalTieredPricing)
|
||||
const providerTieredPricingOverride = tieredPricingModified.value
|
||||
? buildProviderTieredPricingOverride(
|
||||
finalTieredPricing,
|
||||
originalEditorTieredPricing.value,
|
||||
originalProviderTieredPricing.value,
|
||||
)
|
||||
: null
|
||||
const supportsImageGeneration = form.value.supports_image_generation
|
||||
|
||||
// Apply billing (video) pricing into config.
|
||||
applyVideoPricingToConfig(form.value.config)
|
||||
@@ -809,11 +949,14 @@ async function handleSubmit() {
|
||||
|
||||
if (isEditing.value && props.editingModel) {
|
||||
// 编辑模式
|
||||
// 注意:使用 null 而不是 undefined 来显式清空字段(undefined 会被 JSON 序列化忽略)
|
||||
// 仅提交实际修改的 Provider 覆盖;未修改字段继续继承全局模型。
|
||||
await updateModel(props.providerId, props.editingModel.id, buildProviderModelUpdatePayload({
|
||||
finalTieredPricing,
|
||||
finalTieredPricing: providerTieredPricingOverride,
|
||||
tieredPricingModified: tieredPricingModified.value,
|
||||
pricePerRequest: form.value.price_per_request,
|
||||
pricePerRequestModified: pricePerRequestModified.value,
|
||||
cleanConfig,
|
||||
configTouched: configTouched.value,
|
||||
supportsVision: form.value.supports_vision,
|
||||
supportsFunctionCalling: form.value.supports_function_calling,
|
||||
supportsStreaming: form.value.supports_streaming,
|
||||
@@ -834,9 +977,10 @@ async function handleSubmit() {
|
||||
await createModel(props.providerId, buildProviderModelCreatePayload({
|
||||
globalModelId: selectedModel.id,
|
||||
providerModelName: form.value.provider_model_name.trim(),
|
||||
finalTieredPricing,
|
||||
finalTieredPricing: providerTieredPricingOverride,
|
||||
tieredPricingModified: manualGlobalModelMode.value ? false : tieredPricingModified.value,
|
||||
pricePerRequest: manualGlobalModelMode.value ? undefined : form.value.price_per_request,
|
||||
pricePerRequestModified: manualGlobalModelMode.value ? false : pricePerRequestModified.value,
|
||||
cleanConfig,
|
||||
configTouched: manualGlobalModelMode.value ? false : configTouched.value,
|
||||
supportsVision: form.value.supports_vision,
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App } from 'vue'
|
||||
|
||||
import type { Model } from '@/api/endpoints'
|
||||
import ProviderModelFormDialog from '../ProviderModelFormDialog.vue'
|
||||
|
||||
const modelMocks = vi.hoisted(() => ({
|
||||
createModel: vi.fn(),
|
||||
updateModel: vi.fn(),
|
||||
getProviderModels: vi.fn(),
|
||||
}))
|
||||
|
||||
const globalModelMocks = vi.hoisted(() => ({
|
||||
createGlobalModel: vi.fn(),
|
||||
getGlobalModel: vi.fn(),
|
||||
listGlobalModels: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/endpoints/models', () => modelMocks)
|
||||
vi.mock('@/api/global-models', () => globalModelMocks)
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
const editingModel = {
|
||||
id: 'provider-model-1',
|
||||
provider_id: 'provider-1',
|
||||
global_model_id: 'global-model-1',
|
||||
provider_model_name: 'gpt-test',
|
||||
tiered_pricing: null,
|
||||
price_per_request: null,
|
||||
effective_price_per_request: 0.25,
|
||||
config: null,
|
||||
effective_config: {
|
||||
billing: {
|
||||
video: {
|
||||
price_per_second_by_resolution: { '720p': 0.1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
effective_tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2.5 },
|
||||
fast: { price_multiplier: 2 },
|
||||
hyperlane: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 8, output_price_per_1m: 48 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
is_active: true,
|
||||
is_available: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
} as Model
|
||||
|
||||
function mountDialog(model: Model | null = editingModel) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const open = ref(false)
|
||||
const app = createApp(defineComponent({
|
||||
setup() {
|
||||
return () => h(ProviderModelFormDialog, {
|
||||
open: open.value,
|
||||
providerId: 'provider-1',
|
||||
editingModel: model,
|
||||
})
|
||||
},
|
||||
}))
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement {
|
||||
const button = [...document.body.querySelectorAll('button')]
|
||||
.find(candidate => candidate.textContent?.trim() === text)
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing button: ${text}`)
|
||||
return button
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
modelMocks.createModel.mockReset()
|
||||
modelMocks.updateModel.mockReset()
|
||||
modelMocks.updateModel.mockResolvedValue(editingModel)
|
||||
modelMocks.getProviderModels.mockReset()
|
||||
globalModelMocks.createGlobalModel.mockReset()
|
||||
globalModelMocks.getGlobalModel.mockReset()
|
||||
globalModelMocks.listGlobalModels.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ProviderModelFormDialog processing-tier pricing', () => {
|
||||
it('uses the same compact Fast grouping for inherited global-model pricing', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('选择计费模式')
|
||||
for (const tab of ['Token', '按次', '图片', '视频']) {
|
||||
expect(findButton(tab)).toBeDefined()
|
||||
}
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
|
||||
expect(document.body.querySelector('[data-processing-tier="standard"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
|
||||
const fastGroup = document.body.querySelector('[data-processing-tier-group="fast"]')
|
||||
expect(fastGroup?.textContent).toContain('Fast')
|
||||
expect(fastGroup?.textContent).toContain('OpenAI')
|
||||
expect(fastGroup?.textContent).toContain('Chat / Responses')
|
||||
expect(fastGroup?.textContent).toContain('Claude')
|
||||
expect(fastGroup?.textContent).toContain('Messages')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)?.value).toBe('2.5')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-fast"]',
|
||||
)?.value).toBe('2')
|
||||
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('creates a Provider price override only after the inherited price is edited', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
const multiplier = document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)
|
||||
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
|
||||
|
||||
multiplier.value = '3'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.tiered_pricing.processing_tiers.priority).toEqual({
|
||||
price_multiplier: 3,
|
||||
})
|
||||
expect(payload.tiered_pricing).not.toHaveProperty('tiers')
|
||||
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('fast')
|
||||
expect(payload.tiered_pricing.processing_tiers).not.toHaveProperty('hyperlane')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('reopens a processing-only override with inherited Standard and keeps the next save partial', async () => {
|
||||
const partialOverride = {
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
}
|
||||
const reopenedModel = {
|
||||
...editingModel,
|
||||
tiered_pricing: partialOverride,
|
||||
// The current backend returns raw-or-global here, so a partial raw value has no tiers.
|
||||
effective_tiered_pricing: partialOverride,
|
||||
} as Model
|
||||
globalModelMocks.getGlobalModel.mockResolvedValue({
|
||||
id: 'global-model-1',
|
||||
name: 'gpt-test',
|
||||
display_name: 'GPT Test',
|
||||
is_active: true,
|
||||
default_tiered_pricing: editingModel.effective_tiered_pricing,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
total_models: 1,
|
||||
total_providers: 1,
|
||||
price_range: {},
|
||||
})
|
||||
|
||||
mountDialog(reopenedModel)
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
|
||||
expect(globalModelMocks.getGlobalModel).toHaveBeenCalledWith('global-model-1')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Standard 阶梯 1 输入价格(美元/百万 Token)"]',
|
||||
)?.value).toBe('5')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)?.value).toBe('3')
|
||||
expect(document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-fast"]',
|
||||
)?.value).toBe('2')
|
||||
expect(document.body.querySelector('[data-processing-tier="hyperlane"]')).not.toBeNull()
|
||||
|
||||
const multiplier = document.body.querySelector<HTMLInputElement>(
|
||||
'[data-testid="processing-tier-multiplier-priority"]',
|
||||
)
|
||||
if (!multiplier) throw new Error('Missing OpenAI Fast multiplier')
|
||||
multiplier.value = '4'
|
||||
multiplier.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('reopens and edits an explicit unknown Provider processing tier', async () => {
|
||||
const providerHyperlane = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 9, output_price_per_1m: 54 }],
|
||||
future_overlay_option: 'keep-provider-hyperlane',
|
||||
}
|
||||
const partialOverride = {
|
||||
processing_tiers: {
|
||||
hyperlane: providerHyperlane,
|
||||
},
|
||||
}
|
||||
const reopenedModel = {
|
||||
...editingModel,
|
||||
tiered_pricing: partialOverride,
|
||||
effective_tiered_pricing: partialOverride,
|
||||
} as Model
|
||||
globalModelMocks.getGlobalModel.mockResolvedValue({
|
||||
id: 'global-model-1',
|
||||
name: 'gpt-test',
|
||||
display_name: 'GPT Test',
|
||||
is_active: true,
|
||||
default_tiered_pricing: editingModel.effective_tiered_pricing,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
total_models: 1,
|
||||
total_providers: 1,
|
||||
price_range: {},
|
||||
})
|
||||
|
||||
mountDialog(reopenedModel)
|
||||
await settle()
|
||||
findButton('Token').click()
|
||||
await nextTick()
|
||||
const hyperlane = document.body.querySelector<HTMLButtonElement>(
|
||||
'[data-processing-tier="hyperlane"]',
|
||||
)
|
||||
if (!hyperlane) throw new Error('Missing hyperlane pricing entry')
|
||||
hyperlane.click()
|
||||
await nextTick()
|
||||
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="hyperlane 阶梯 1 输入价格(美元/百万 Token)"]',
|
||||
)
|
||||
if (!input) throw new Error('Missing hyperlane input-price editor')
|
||||
expect(input.value).toBe('9')
|
||||
input.value = '10'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
expect(modelMocks.updateModel.mock.calls[0][2].tiered_pricing).toEqual({
|
||||
processing_tiers: {
|
||||
hyperlane: {
|
||||
...providerHyperlane,
|
||||
tiers: [{ up_to: null, input_price_per_1m: 10, output_price_per_1m: 54 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('edits the per-request override through the same billing-mode tabs', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
findButton('按次').click()
|
||||
await nextTick()
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="留空使用全局模型默认值"]',
|
||||
)
|
||||
if (!input) throw new Error('Missing per-request price input')
|
||||
|
||||
input.value = '0.5'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.price_per_request).toBe(0.5)
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('enables the Provider image capability when the Image tab is explicitly selected', async () => {
|
||||
mountDialog()
|
||||
await settle()
|
||||
|
||||
findButton('图片').click()
|
||||
await nextTick()
|
||||
findButton('保存').click()
|
||||
await settle()
|
||||
|
||||
const payload = modelMocks.updateModel.mock.calls[0][2]
|
||||
expect(payload.supports_image_generation).toBe(true)
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
})
|
||||
+152
-1
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildProviderModelCreatePayload,
|
||||
buildProviderModelUpdatePayload,
|
||||
buildProviderTieredPricingOverride,
|
||||
mergeProviderTieredPricingForEditing,
|
||||
modelSupportsEmbedding,
|
||||
} from '../provider-model-form-helpers'
|
||||
|
||||
@@ -29,7 +31,8 @@ describe('provider model form embedding helpers', () => {
|
||||
providerModelName: 'text-embedding-3-small',
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequest: 0.25,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: {
|
||||
embedding: true,
|
||||
model_type: 'embedding',
|
||||
@@ -44,6 +47,7 @@ describe('provider model form embedding helpers', () => {
|
||||
global_model_id: 'gm-embedding',
|
||||
provider_model_name: 'text-embedding-3-small',
|
||||
tiered_pricing: undefined,
|
||||
price_per_request: undefined,
|
||||
config: undefined,
|
||||
supports_streaming: false,
|
||||
})
|
||||
@@ -57,6 +61,7 @@ describe('provider model form embedding helpers', () => {
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
@@ -75,13 +80,16 @@ describe('provider model form embedding helpers', () => {
|
||||
it('preserves edited provider embedding config without posting unsupported embedding controls', () => {
|
||||
const payload = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: true,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: {
|
||||
streaming: false,
|
||||
embedding: true,
|
||||
model_type: 'embedding',
|
||||
api_formats: ['gemini:embedding'],
|
||||
},
|
||||
configTouched: true,
|
||||
supportsStreaming: false,
|
||||
isActive: true,
|
||||
})
|
||||
@@ -92,7 +100,150 @@ describe('provider model form embedding helpers', () => {
|
||||
model_type: 'embedding',
|
||||
api_formats: ['gemini:embedding'],
|
||||
})
|
||||
expect(payload.tiered_pricing).toEqual(pricing)
|
||||
expect(payload.supports_streaming).toBe(false)
|
||||
expect('supports_embedding' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps inherited pricing and config out of an unchanged provider update', () => {
|
||||
const payload = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: 0.25,
|
||||
pricePerRequestModified: false,
|
||||
cleanConfig: { billing: { video: { price_per_second_by_resolution: { '720p': 0.1 } } } },
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(payload).not.toHaveProperty('tiered_pricing')
|
||||
expect(payload).not.toHaveProperty('price_per_request')
|
||||
expect(payload).not.toHaveProperty('config')
|
||||
})
|
||||
|
||||
it('writes an explicitly edited per-request price and supports clearing it', () => {
|
||||
const edited = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: 0.5,
|
||||
pricePerRequestModified: true,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
const cleared = buildProviderModelUpdatePayload({
|
||||
finalTieredPricing: pricing,
|
||||
tieredPricingModified: false,
|
||||
pricePerRequest: undefined,
|
||||
pricePerRequestModified: true,
|
||||
cleanConfig: undefined,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(edited.price_per_request).toBe(0.5)
|
||||
expect(cleared.price_per_request).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('provider model pricing override helpers', () => {
|
||||
const inheritedPricing = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
future_global_option: 'inherit-only',
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 2.5 },
|
||||
fast: { price_multiplier: 2 },
|
||||
},
|
||||
}
|
||||
|
||||
it('projects a processing-tier edit without freezing inherited Standard or other tiers', () => {
|
||||
const finalPricing = structuredClone(inheritedPricing)
|
||||
finalPricing.processing_tiers.priority.price_multiplier = 3
|
||||
|
||||
const override = buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
inheritedPricing,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(override).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
})
|
||||
expect(override).not.toHaveProperty('tiers')
|
||||
expect(override?.processing_tiers).not.toHaveProperty('fast')
|
||||
expect(override).not.toHaveProperty('future_global_option')
|
||||
|
||||
const createPayload = buildProviderModelCreatePayload({
|
||||
globalModelId: 'global-model-1',
|
||||
providerModelName: 'gpt-test',
|
||||
finalTieredPricing: override,
|
||||
tieredPricingModified: true,
|
||||
pricePerRequestModified: false,
|
||||
configTouched: false,
|
||||
isActive: true,
|
||||
})
|
||||
expect(createPayload.tiered_pricing).toEqual(override)
|
||||
expect(createPayload.tiered_pricing).not.toHaveProperty('tiers')
|
||||
})
|
||||
|
||||
it('keeps an existing Provider Standard override while adding only the edited tier', () => {
|
||||
const providerStandard = {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 7, output_price_per_1m: 42 }],
|
||||
provider_contract: 'keep-provider-standard',
|
||||
}
|
||||
const editorPricing = {
|
||||
...structuredClone(providerStandard),
|
||||
processing_tiers: structuredClone(inheritedPricing.processing_tiers),
|
||||
}
|
||||
const finalPricing = structuredClone(editorPricing)
|
||||
finalPricing.processing_tiers.priority.price_multiplier = 3
|
||||
|
||||
const override = buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
editorPricing,
|
||||
providerStandard,
|
||||
)
|
||||
|
||||
expect(override).toEqual({
|
||||
...providerStandard,
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
})
|
||||
expect(override?.processing_tiers).not.toHaveProperty('fast')
|
||||
})
|
||||
|
||||
it('merges a saved processing-only override for editing and stays partial on the next save', () => {
|
||||
const savedOverride = {
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
},
|
||||
}
|
||||
const reopenedEditorPricing = mergeProviderTieredPricingForEditing(
|
||||
inheritedPricing,
|
||||
savedOverride,
|
||||
)
|
||||
|
||||
expect(reopenedEditorPricing).toEqual({
|
||||
...inheritedPricing,
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 3 },
|
||||
fast: { price_multiplier: 2 },
|
||||
},
|
||||
})
|
||||
|
||||
const finalPricing = structuredClone(reopenedEditorPricing!)
|
||||
finalPricing.processing_tiers!.priority.price_multiplier = 4
|
||||
expect(buildProviderTieredPricingOverride(
|
||||
finalPricing,
|
||||
reopenedEditorPricing,
|
||||
savedOverride,
|
||||
)).toEqual({
|
||||
processing_tiers: {
|
||||
priority: { price_multiplier: 4 },
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ModelCreate, ModelUpdate, TieredPricingConfig } from '@/api/endpoints'
|
||||
import type {
|
||||
ModelCreate,
|
||||
ModelUpdate,
|
||||
ProviderTieredPricingConfig,
|
||||
TieredPricingConfig,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
interface EmbeddingMetadataCarrier {
|
||||
supported_capabilities?: string[] | null
|
||||
@@ -15,9 +20,10 @@ function isEmbeddingApiFormat(format: unknown): boolean {
|
||||
export interface ProviderModelCreatePayloadInput {
|
||||
globalModelId: string
|
||||
providerModelName: string
|
||||
finalTieredPricing: TieredPricingConfig | null
|
||||
finalTieredPricing: ProviderTieredPricingConfig | null
|
||||
tieredPricingModified: boolean
|
||||
pricePerRequest?: number
|
||||
pricePerRequestModified: boolean
|
||||
cleanConfig?: Record<string, unknown>
|
||||
configTouched: boolean
|
||||
supportsVision?: boolean
|
||||
@@ -29,9 +35,12 @@ export interface ProviderModelCreatePayloadInput {
|
||||
}
|
||||
|
||||
export interface ProviderModelUpdatePayloadInput {
|
||||
finalTieredPricing: TieredPricingConfig | null
|
||||
finalTieredPricing: ProviderTieredPricingConfig | null
|
||||
tieredPricingModified: boolean
|
||||
pricePerRequest?: number
|
||||
pricePerRequestModified: boolean
|
||||
cleanConfig?: Record<string, unknown>
|
||||
configTouched: boolean
|
||||
supportsVision?: boolean
|
||||
supportsFunctionCalling?: boolean
|
||||
supportsStreaming?: boolean
|
||||
@@ -53,12 +62,180 @@ export function modelSupportsEmbedding(model: EmbeddingMetadataCarrier | null |
|
||||
|| (Array.isArray(config.api_formats) && config.api_formats.some(isEmbeddingApiFormat))
|
||||
}
|
||||
|
||||
const STANDARD_PRICING_KEYS = new Set([
|
||||
'tiers',
|
||||
'image_output_prices',
|
||||
'image_output_price_default',
|
||||
'image_output_price_ranges',
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
])
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function cloneJson<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T
|
||||
}
|
||||
|
||||
function hasOwn(object: object, key: PropertyKey): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(object, key)
|
||||
}
|
||||
|
||||
function valueHasEntries(value: unknown): boolean {
|
||||
return (Array.isArray(value) && value.length > 0)
|
||||
|| (isRecord(value) && Object.keys(value).length > 0)
|
||||
}
|
||||
|
||||
function hasStandardPricingData(pricing: ProviderTieredPricingConfig): boolean {
|
||||
return (Array.isArray(pricing.tiers) && pricing.tiers.length > 0)
|
||||
|| (typeof pricing.image_output_price_default === 'number'
|
||||
&& Number.isFinite(pricing.image_output_price_default))
|
||||
|| [
|
||||
'image_output_prices',
|
||||
'image_output_price_ranges',
|
||||
'image_output_price_per_image',
|
||||
'image_output_price_matrix',
|
||||
'image_prices',
|
||||
].some(key => valueHasEntries(pricing[key]))
|
||||
}
|
||||
|
||||
function pricingRoot(pricing: ProviderTieredPricingConfig): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(pricing).filter(([key]) => key !== 'processing_tiers'),
|
||||
)
|
||||
}
|
||||
|
||||
function processingTierEntries(pricing: ProviderTieredPricingConfig | null | undefined) {
|
||||
return isRecord(pricing?.processing_tiers)
|
||||
? Object.entries(pricing.processing_tiers)
|
||||
: []
|
||||
}
|
||||
|
||||
function jsonValuesEqual(left: unknown, right: unknown): boolean {
|
||||
if (Object.is(left, right)) return true
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left)
|
||||
&& Array.isArray(right)
|
||||
&& left.length === right.length
|
||||
&& left.every((value, index) => jsonValuesEqual(value, right[index]))
|
||||
}
|
||||
if (!isRecord(left) || !isRecord(right)) return false
|
||||
const leftKeys = Object.keys(left).sort()
|
||||
const rightKeys = Object.keys(right).sort()
|
||||
return leftKeys.length === rightKeys.length
|
||||
&& leftKeys.every((key, index) => (
|
||||
key === rightKeys[index]
|
||||
&& jsonValuesEqual(left[key], right[key])
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete catalog shown by the editor from the two independent
|
||||
* runtime sources: GlobalModel Standard/default overlays and Provider overrides.
|
||||
*/
|
||||
export function mergeProviderTieredPricingForEditing(
|
||||
globalDefault: ProviderTieredPricingConfig | null | undefined,
|
||||
providerOverride: ProviderTieredPricingConfig | null | undefined,
|
||||
): TieredPricingConfig | null {
|
||||
if (!providerOverride) {
|
||||
return Array.isArray(globalDefault?.tiers)
|
||||
? cloneJson(globalDefault) as TieredPricingConfig
|
||||
: null
|
||||
}
|
||||
|
||||
const providerHasStandard = hasStandardPricingData(providerOverride)
|
||||
const providerRoot = pricingRoot(providerOverride)
|
||||
let mergedRoot: Record<string, unknown>
|
||||
if (providerHasStandard) {
|
||||
mergedRoot = providerRoot
|
||||
} else if (globalDefault) {
|
||||
const providerMetadata = Object.fromEntries(
|
||||
Object.entries(providerRoot).filter(([key]) => !STANDARD_PRICING_KEYS.has(key)),
|
||||
)
|
||||
mergedRoot = {
|
||||
...pricingRoot(globalDefault),
|
||||
...providerMetadata,
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Array.isArray(mergedRoot.tiers)) return null
|
||||
|
||||
const mergedProcessingTiers = Object.fromEntries([
|
||||
...processingTierEntries(globalDefault),
|
||||
...processingTierEntries(providerOverride),
|
||||
])
|
||||
if (Object.keys(mergedProcessingTiers).length > 0) {
|
||||
mergedRoot.processing_tiers = mergedProcessingTiers
|
||||
}
|
||||
return cloneJson(mergedRoot) as TieredPricingConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the complete editor catalog back to the smallest Provider override.
|
||||
* Unchanged Standard and processing-tier values keep inheriting from GlobalModel.
|
||||
*/
|
||||
export function buildProviderTieredPricingOverride(
|
||||
finalPricing: TieredPricingConfig | null,
|
||||
originalEditorPricing: TieredPricingConfig | null,
|
||||
originalProviderOverride: ProviderTieredPricingConfig | null | undefined,
|
||||
): ProviderTieredPricingConfig | null {
|
||||
if (!finalPricing) return null
|
||||
if (!originalEditorPricing) return cloneJson(finalPricing)
|
||||
|
||||
const originalProcessingTiers = originalProviderOverride?.processing_tiers
|
||||
const preservedProcessingTiers = hasOwn(originalProviderOverride || {}, 'processing_tiers')
|
||||
? originalProcessingTiers === undefined
|
||||
? undefined
|
||||
: cloneJson(originalProcessingTiers)
|
||||
: undefined
|
||||
let result = cloneJson(originalProviderOverride || {})
|
||||
|
||||
if (!jsonValuesEqual(pricingRoot(finalPricing), pricingRoot(originalEditorPricing))) {
|
||||
result = cloneJson(pricingRoot(finalPricing)) as ProviderTieredPricingConfig
|
||||
if (preservedProcessingTiers !== undefined || originalProcessingTiers === null) {
|
||||
result.processing_tiers = preservedProcessingTiers ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const finalProcessingTiers = new Map(processingTierEntries(finalPricing))
|
||||
const baselineProcessingTiers = new Map(processingTierEntries(originalEditorPricing))
|
||||
const nextProcessingTiers = new Map(processingTierEntries(result))
|
||||
let processingTiersChanged = false
|
||||
const keys = new Set([
|
||||
...finalProcessingTiers.keys(),
|
||||
...baselineProcessingTiers.keys(),
|
||||
])
|
||||
for (const key of keys) {
|
||||
const finalOverlay = finalProcessingTiers.get(key)
|
||||
const baselineOverlay = baselineProcessingTiers.get(key)
|
||||
if (jsonValuesEqual(finalOverlay, baselineOverlay)) continue
|
||||
processingTiersChanged = true
|
||||
if (finalOverlay === undefined) nextProcessingTiers.delete(key)
|
||||
else nextProcessingTiers.set(key, cloneJson(finalOverlay))
|
||||
}
|
||||
|
||||
if (processingTiersChanged) {
|
||||
if (nextProcessingTiers.size > 0) {
|
||||
result.processing_tiers = Object.fromEntries(nextProcessingTiers)
|
||||
} else {
|
||||
delete result.processing_tiers
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloadInput): ModelCreate {
|
||||
return {
|
||||
global_model_id: input.globalModelId,
|
||||
provider_model_name: input.providerModelName,
|
||||
tiered_pricing: input.tieredPricingModified && input.finalTieredPricing ? input.finalTieredPricing : undefined,
|
||||
price_per_request: input.pricePerRequest,
|
||||
price_per_request: input.pricePerRequestModified ? input.pricePerRequest : undefined,
|
||||
config: input.configTouched ? input.cleanConfig : undefined,
|
||||
supports_vision: input.supportsVision,
|
||||
supports_function_calling: input.supportsFunctionCalling,
|
||||
@@ -71,9 +248,11 @@ export function buildProviderModelCreatePayload(input: ProviderModelCreatePayloa
|
||||
|
||||
export function buildProviderModelUpdatePayload(input: ProviderModelUpdatePayloadInput): ModelUpdate {
|
||||
return {
|
||||
tiered_pricing: input.finalTieredPricing,
|
||||
price_per_request: input.pricePerRequest ?? null,
|
||||
config: input.cleanConfig || null,
|
||||
...(input.tieredPricingModified ? { tiered_pricing: input.finalTieredPricing } : {}),
|
||||
...(input.pricePerRequestModified
|
||||
? { price_per_request: input.pricePerRequest ?? null }
|
||||
: {}),
|
||||
...(input.configTouched ? { config: input.cleanConfig || null } : {}),
|
||||
supports_vision: input.supportsVision,
|
||||
supports_function_calling: input.supportsFunctionCalling,
|
||||
supports_streaming: input.supportsStreaming,
|
||||
|
||||
@@ -250,11 +250,12 @@
|
||||
</span>
|
||||
</div>
|
||||
<ServiceTierFacts
|
||||
v-if="hasServiceTierFacts"
|
||||
v-if="hasServiceTierFacts || processingTierPriceMultiplier !== null"
|
||||
class="mt-3"
|
||||
:requested="serviceTierFacts.requested"
|
||||
:actual="serviceTierFacts.actual"
|
||||
:billing="serviceTierFacts.billing"
|
||||
:price-multiplier="processingTierPriceMultiplier"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -313,13 +314,13 @@
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-muted-foreground sm:hidden">
|
||||
<div class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1">
|
||||
<span>输入</span>
|
||||
<span class="text-right">${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span class="text-right">{{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-[max-content_1fr] items-baseline gap-x-1"
|
||||
>
|
||||
<span>输出</span>
|
||||
<span class="text-right">${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span class="text-right">{{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
|
||||
</div>
|
||||
<template v-if="getTierActiveCacheCreationDisplay(tier) || shouldShowCacheReadPrice(tier)">
|
||||
<div
|
||||
@@ -341,8 +342,8 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-muted-foreground hidden items-center gap-2 flex-wrap sm:flex">
|
||||
<span>输入 ${{ formatPrice(tier.input_price_per_1m) }}/M</span>
|
||||
<span>输出 ${{ formatPrice(tier.output_price_per_1m) }}/M</span>
|
||||
<span>输入 {{ formatPricePerMillion(tier.input_price_per_1m) }}</span>
|
||||
<span>输出 {{ formatPricePerMillion(tier.output_price_per_1m) }}</span>
|
||||
<span v-if="getTierActiveCacheCreationDisplay(tier)">
|
||||
{{ getTierActiveCacheCreationDisplay(tier)?.label }}
|
||||
${{ formatPrice(getTierActiveCacheCreationDisplay(tier)?.price || 0) }}/M
|
||||
@@ -906,6 +907,12 @@ import {
|
||||
resolveUsageStreamLabelSegments,
|
||||
} from '../utils/status'
|
||||
import { resolveRequestFailureNotice } from '../utils/errorNotice'
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
resolveSettlementPricingSourceLabel,
|
||||
resolveSettlementPricingTiers,
|
||||
} from '../utils/settlement-pricing'
|
||||
|
||||
// 子组件
|
||||
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
|
||||
@@ -1355,6 +1362,9 @@ const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
|
||||
const serviceTierFacts = computed(() => resolveServiceTierFacts(detail.value))
|
||||
const hasServiceTierFacts = computed(() => hasServiceTierFact(serviceTierFacts.value))
|
||||
const processingTierPriceMultiplier = computed(() => (
|
||||
resolveProcessingTierPriceMultiplier(detail.value)
|
||||
))
|
||||
|
||||
const settlementInfo = computed<JsonRecord | null>(() =>
|
||||
asRecord(detail.value?.settlement ?? null),
|
||||
@@ -1643,20 +1653,10 @@ const hasValidConversation = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
// 价格来源标签
|
||||
// tiered_pricing.source 表示定价来源: 'provider' 或 'global'
|
||||
// 价格来源优先使用 v3 结算快照,旧 tiered_pricing.source 仅作回退。
|
||||
const priceSourceLabel = computed(() => {
|
||||
if (!detail.value) return '历史定价'
|
||||
|
||||
const source = detail.value.tiered_pricing?.source
|
||||
if (source === 'provider') {
|
||||
return '提供商定价'
|
||||
} else if (source === 'global') {
|
||||
return '全局定价'
|
||||
}
|
||||
|
||||
// 没有 tiered_pricing 时,使用历史价格
|
||||
return '历史定价'
|
||||
return resolveSettlementPricingSourceLabel(detail.value) ?? '历史定价'
|
||||
})
|
||||
|
||||
const cacheCreationInputTokens5m = computed(() => {
|
||||
@@ -1896,10 +1896,9 @@ const activeCacheTtlMinutes = computed(() => {
|
||||
const displayTiers = computed(() => {
|
||||
if (!detail.value) return []
|
||||
|
||||
// 如果有阶梯定价数据,直接使用
|
||||
if (detail.value.tiered_pricing?.tiers && detail.value.tiered_pricing.tiers.length > 0) {
|
||||
return detail.value.tiered_pricing.tiers
|
||||
}
|
||||
// 优先展示结算快照中已解析的价格目录,旧字段仅作回退。
|
||||
const resolvedTiers = resolveSettlementPricingTiers(detail.value)
|
||||
if (resolvedTiers) return resolvedTiers as PricingTierLike[]
|
||||
|
||||
// 否则用历史价格构建单阶梯(无上限)
|
||||
return [{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<dl
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3"
|
||||
class="grid grid-cols-1 gap-x-4 gap-y-1.5 text-xs"
|
||||
:class="hasPriceMultiplier ? 'sm:grid-cols-4' : 'sm:grid-cols-3'"
|
||||
data-testid="service-tier-facts"
|
||||
>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -9,9 +10,9 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="requested || '-'"
|
||||
:title="formatServiceTierFact(requested) || '-'"
|
||||
>
|
||||
{{ requested || '-' }}
|
||||
{{ formatServiceTierFact(requested) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -20,9 +21,9 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="actual || '-'"
|
||||
:title="formatServiceTierFact(actual) || '-'"
|
||||
>
|
||||
{{ actual || '-' }}
|
||||
{{ formatServiceTierFact(actual) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-baseline justify-between gap-3 sm:block">
|
||||
@@ -31,18 +32,48 @@
|
||||
</dt>
|
||||
<dd
|
||||
class="truncate font-mono font-medium text-foreground sm:mt-0.5"
|
||||
:title="billing || '-'"
|
||||
:title="formatServiceTierFact(billing) || '-'"
|
||||
>
|
||||
{{ billing || '-' }}
|
||||
{{ formatServiceTierFact(billing) || '-' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasPriceMultiplier"
|
||||
class="flex min-w-0 items-baseline justify-between gap-3 sm:block"
|
||||
data-testid="service-tier-price-multiplier"
|
||||
>
|
||||
<dt class="text-muted-foreground">
|
||||
{{ multiplierTierLabel }} 倍率
|
||||
</dt>
|
||||
<dd class="truncate font-mono font-medium text-foreground sm:mt-0.5">
|
||||
{{ formattedPriceMultiplier }}×
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
import { computed } from 'vue'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
|
||||
const props = defineProps<{
|
||||
requested: string | null
|
||||
actual: string | null
|
||||
billing: string | null
|
||||
priceMultiplier?: number | null
|
||||
}>()
|
||||
|
||||
const hasPriceMultiplier = computed(() => (
|
||||
typeof props.priceMultiplier === 'number'
|
||||
&& Number.isFinite(props.priceMultiplier)
|
||||
&& props.priceMultiplier >= 0
|
||||
))
|
||||
|
||||
const multiplierTierLabel = computed(() => (
|
||||
formatServiceTierFact(props.billing ?? props.actual ?? props.requested) ?? '处理层级'
|
||||
))
|
||||
|
||||
const formattedPriceMultiplier = computed(() => (
|
||||
hasPriceMultiplier.value ? String(props.priceMultiplier) : ''
|
||||
))
|
||||
</script>
|
||||
|
||||
@@ -1105,6 +1105,7 @@ import { useRowClick } from '@/composables/useRowClick'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { API_FORMAT_ORDER, formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatClientFamily } from '@/features/usage/utils/clientFamily'
|
||||
import { formatServiceTierFact } from '../utils/service-tier'
|
||||
import type { DateRangeParams, UsageRecord } from '../types'
|
||||
import { MultiSelect, TimeRangePicker } from '@/components/common'
|
||||
import type { MultiSelectOption } from '@/components/common/MultiSelect.vue'
|
||||
@@ -1635,6 +1636,9 @@ function canonicalServiceTier(value: string | null): string | null {
|
||||
if (value === 'auto' || value === 'default' || value === 'standard') {
|
||||
return 'standard'
|
||||
}
|
||||
if (value === 'fast') {
|
||||
return 'priority'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -1661,10 +1665,13 @@ function buildServiceTierBadgePresentation(
|
||||
billingTier: string | null,
|
||||
): ServiceTierBadgePresentation {
|
||||
const titleLines: string[] = []
|
||||
if (requestedRaw) titleLines.push(`请求档位:${requestedRaw}`)
|
||||
if (actualRaw) titleLines.push(`实际档位:${actualRaw}`)
|
||||
if (billingTier) {
|
||||
titleLines.push(`计费档位:${billingTier}`)
|
||||
const requestedLabel = formatServiceTierFact(requestedRaw)
|
||||
const actualLabel = formatServiceTierFact(actualRaw)
|
||||
const billingLabel = formatServiceTierFact(billingTier)
|
||||
if (requestedLabel) titleLines.push(`请求档位:${requestedLabel}`)
|
||||
if (actualLabel) titleLines.push(`实际档位:${actualLabel}`)
|
||||
if (billingLabel) {
|
||||
titleLines.push(`计费档位:${billingLabel}`)
|
||||
} else {
|
||||
titleLines.push(`计费档位:${state === 'pending' ? '待上游确认' : '未确认'}`)
|
||||
}
|
||||
@@ -1689,7 +1696,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
if (actual) {
|
||||
if (requestedFast && !actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
`fast → ${actual}`,
|
||||
`Fast → ${actual}`,
|
||||
'downgraded',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1699,7 +1706,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
if (!requestedFast && actualFast) {
|
||||
const requestedLabel = requested ?? 'standard'
|
||||
return buildServiceTierBadgePresentation(
|
||||
requested ? `${requestedLabel} → fast` : 'fast',
|
||||
requested ? `${requestedLabel} → Fast` : 'Fast',
|
||||
requested ? 'upgraded' : 'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1708,7 +1715,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
}
|
||||
if (actualFast) {
|
||||
return buildServiceTierBadgePresentation(
|
||||
'fast',
|
||||
'Fast',
|
||||
'confirmed',
|
||||
requestedRaw,
|
||||
actualRaw,
|
||||
@@ -1722,7 +1729,7 @@ function getServiceTierBadge(record: UsageRecord): ServiceTierBadgePresentation
|
||||
const displayStatus = getDisplayStatus(record)
|
||||
const isActive = displayStatus === 'pending' || displayStatus === 'streaming'
|
||||
return buildServiceTierBadgePresentation(
|
||||
isActive ? 'fast · 待确认' : 'fast · 未确认',
|
||||
isActive ? 'Fast · 待确认' : 'Fast · 未确认',
|
||||
isActive ? 'pending' : 'unconfirmed',
|
||||
requestedRaw,
|
||||
null,
|
||||
@@ -1734,8 +1741,8 @@ function getServiceTierTitle(record: UsageRecord): string {
|
||||
const badge = getServiceTierBadge(record)
|
||||
if (badge) return badge.title
|
||||
|
||||
const requested = normalizeServiceTier(record.service_tier)
|
||||
const actual = normalizeServiceTier(record.actual_service_tier)
|
||||
const requested = formatServiceTierFact(record.service_tier)
|
||||
const actual = formatServiceTierFact(record.actual_service_tier)
|
||||
return [
|
||||
requested ? `请求档位:${requested}` : null,
|
||||
actual ? `实际档位:${actual}` : null,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, ref, type App, type Ref } from 'vue'
|
||||
|
||||
import type { RequestDetail } from '@/api/dashboard'
|
||||
import RequestDetailDrawer from '../RequestDetailDrawer.vue'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getRequestDetail: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/dashboard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/api/dashboard')>()
|
||||
return {
|
||||
...actual,
|
||||
dashboardApi: {
|
||||
...actual.dashboardApi,
|
||||
getRequestDetail: apiMocks.getRequestDetail,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
apiMocks.getRequestDetail.mockReset()
|
||||
})
|
||||
|
||||
function buildEmbeddingDetail(): RequestDetail {
|
||||
return {
|
||||
id: 'usage-embedding-1',
|
||||
request_id: 'req-embedding-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'embedding-user',
|
||||
email: 'embedding@example.com',
|
||||
},
|
||||
api_key: {
|
||||
id: 'key-1',
|
||||
name: 'test-key',
|
||||
display: 'test-key',
|
||||
},
|
||||
provider: 'embedding-provider',
|
||||
model: 'embedding-model',
|
||||
tokens: { input: 100, output: 0, total: 100 },
|
||||
cost: { input: 0.00001, output: 0, total: 0.00001 },
|
||||
request_type: 'embedding',
|
||||
is_stream: false,
|
||||
status: 'completed',
|
||||
status_code: 200,
|
||||
response_time_ms: 10,
|
||||
created_at: '2026-07-16T00:00:00Z',
|
||||
request_headers: { 'content-type': 'application/json' },
|
||||
settlement: {
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
pricing_source: 'global_default',
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('RequestDetailDrawer settlement pricing', () => {
|
||||
it('renders an input-only embedding tier without treating the missing output price as zero', async () => {
|
||||
apiMocks.getRequestDetail.mockResolvedValue(buildEmbeddingDetail())
|
||||
|
||||
let isOpen!: Ref<boolean>
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
isOpen = ref(false)
|
||||
return () => h(RequestDetailDrawer, {
|
||||
isOpen: isOpen.value,
|
||||
requestId: 'usage-embedding-1',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(Host)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
isOpen.value = true
|
||||
await nextTick()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.body.textContent).toContain('输入 $0.1/M')
|
||||
expect(document.body.textContent).toContain('输出 -')
|
||||
})
|
||||
expect(document.body.textContent).not.toContain('输出 $0/M')
|
||||
})
|
||||
})
|
||||
@@ -33,9 +33,75 @@ describe('ServiceTierFacts', () => {
|
||||
'计费层级',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'priority',
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'-',
|
||||
'flex',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the same Fast label for raw priority and fast facts', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'priority',
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.textContent?.trim())).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
expect([...root.querySelectorAll('dd')].map(node => node.getAttribute('title'))).toEqual([
|
||||
'Fast',
|
||||
'Fast',
|
||||
'Fast',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders the processing-tier multiplier with the billing tier label', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: 'fast',
|
||||
billing: 'fast',
|
||||
priceMultiplier: 2.5,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
const multiplier = root.querySelector('[data-testid="service-tier-price-multiplier"]')
|
||||
expect(multiplier?.textContent).toContain('Fast 倍率')
|
||||
expect(multiplier?.textContent).toContain('2.5×')
|
||||
})
|
||||
|
||||
it('does not render an empty or invalid processing-tier multiplier', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ServiceTierFacts, {
|
||||
requested: 'priority',
|
||||
actual: null,
|
||||
billing: null,
|
||||
priceMultiplier: null,
|
||||
}),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
|
||||
expect(root.querySelector('[data-testid="service-tier-price-multiplier"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,11 +162,12 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
|
||||
return root
|
||||
}
|
||||
|
||||
function expectServiceTierBadge(root: HTMLElement, label: string) {
|
||||
const labels = [...root.querySelectorAll('span')]
|
||||
.map((element) => element.textContent?.trim())
|
||||
function expectServiceTierBadge(root: HTMLElement, label: string): HTMLElement {
|
||||
const badge = [...root.querySelectorAll<HTMLElement>('span')]
|
||||
.find(element => element.textContent?.trim() === label)
|
||||
|
||||
expect(labels).toContain(label)
|
||||
expect(badge).toBeDefined()
|
||||
return badge as HTMLElement
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -295,13 +296,26 @@ describe('UsageRecordsTable', () => {
|
||||
expect(root.textContent).toContain('xhigh')
|
||||
})
|
||||
|
||||
it('shows confirmed fast when requested and actual service tiers are priority', () => {
|
||||
it.each([
|
||||
['priority', 'priority'],
|
||||
['fast', 'fast'],
|
||||
['priority', 'fast'],
|
||||
['fast', 'priority'],
|
||||
])('shows confirmed Fast for requested %s and actual %s', (requested, actual) => {
|
||||
const root = mountUsageRecordsTable([buildRecord({
|
||||
service_tier: 'priority',
|
||||
actual_service_tier: 'priority',
|
||||
service_tier: requested,
|
||||
actual_service_tier: actual,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast')
|
||||
const badge = expectServiceTierBadge(root, 'Fast')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:Fast',
|
||||
'计费档位:Fast',
|
||||
].join('\n'))
|
||||
expect(badge.getAttribute('aria-label')).toBe(
|
||||
'请求档位:Fast,实际档位:Fast,计费档位:Fast',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows fast to standard when the provider downgrades a priority request', () => {
|
||||
@@ -310,7 +324,12 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'default',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast → standard')
|
||||
const badge = expectServiceTierBadge(root, 'Fast → standard')
|
||||
expect(badge.getAttribute('title')).toBe([
|
||||
'请求档位:Fast',
|
||||
'实际档位:default',
|
||||
'计费档位:standard',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('shows fast to flex when the provider moves a priority request to flex', () => {
|
||||
@@ -319,7 +338,7 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'flex',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast → flex')
|
||||
expectServiceTierBadge(root, 'Fast → flex')
|
||||
})
|
||||
|
||||
it('shows standard to fast when the provider upgrades a default request', () => {
|
||||
@@ -328,7 +347,7 @@ describe('UsageRecordsTable', () => {
|
||||
actual_service_tier: 'priority',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'standard → fast')
|
||||
expectServiceTierBadge(root, 'standard → Fast')
|
||||
})
|
||||
|
||||
it.each(['pending', 'streaming'] as const)(
|
||||
@@ -340,7 +359,7 @@ describe('UsageRecordsTable', () => {
|
||||
status,
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast · 待确认')
|
||||
expectServiceTierBadge(root, 'Fast · 待确认')
|
||||
},
|
||||
)
|
||||
|
||||
@@ -351,7 +370,7 @@ describe('UsageRecordsTable', () => {
|
||||
status: 'completed',
|
||||
})])
|
||||
|
||||
expectServiceTierBadge(root, 'fast · 未确认')
|
||||
expectServiceTierBadge(root, 'Fast · 未确认')
|
||||
})
|
||||
|
||||
it('offers embedding API formats in the usage record filter', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatServiceTierFact,
|
||||
hasServiceTierFact,
|
||||
normalizeServiceTierFact,
|
||||
resolveServiceTierFacts,
|
||||
@@ -38,4 +39,16 @@ describe('service tier facts', () => {
|
||||
expect(normalizeServiceTierFact(' ')).toBeNull()
|
||||
expect(normalizeServiceTierFact(0)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['priority', 'fast', ' Priority ', 'FAST'])(
|
||||
'displays the raw %s tier as Fast',
|
||||
(tier) => {
|
||||
expect(formatServiceTierFact(tier)).toBe('Fast')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps non-fast tier labels unchanged', () => {
|
||||
expect(formatServiceTierFact(' Batch ')).toBe('Batch')
|
||||
expect(formatServiceTierFact(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatPricePerMillion,
|
||||
resolveProcessingTierPriceMultiplier,
|
||||
resolveSettlementPricingSnapshot,
|
||||
resolveSettlementPricingSourceLabel,
|
||||
resolveSettlementPricingTiers,
|
||||
} from '../settlement-pricing'
|
||||
|
||||
function buildSource(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
settlement: {
|
||||
rate_multiplier: 9,
|
||||
settlement_snapshot: {
|
||||
pricing_snapshot: {
|
||||
billing_processing_tier: 'priority',
|
||||
pricing_source: 'provider_override',
|
||||
tiered_pricing_source: 'global_default',
|
||||
processing_tier_price_multiplier: 2.5,
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 }],
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
},
|
||||
tiered_pricing: {
|
||||
source: 'global',
|
||||
tiers: [{ up_to: null, input_price_per_1m: 1 }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('settlement pricing presentation', () => {
|
||||
it('reads the resolved pricing snapshot and prefers its catalog over legacy tiers', () => {
|
||||
const source = buildSource()
|
||||
|
||||
expect(resolveSettlementPricingSnapshot(source)?.billing_processing_tier).toBe('priority')
|
||||
expect(resolveSettlementPricingTiers(source)).toEqual([
|
||||
{ up_to: null, input_price_per_1m: 5, output_price_per_1m: 30 },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['provider_override', '提供商定价'],
|
||||
['global_default', '全局定价'],
|
||||
['mixed', '混合定价'],
|
||||
])('maps the resolved %s pricing source', (pricingSource, label) => {
|
||||
expect(resolveSettlementPricingSourceLabel(buildSource({
|
||||
pricing_source: pricingSource,
|
||||
}))).toBe(label)
|
||||
})
|
||||
|
||||
it('falls back from pricing_source to tiered_pricing_source and then legacy source', () => {
|
||||
expect(resolveSettlementPricingSourceLabel(buildSource({
|
||||
pricing_source: null,
|
||||
tiered_pricing_source: 'global_default',
|
||||
}))).toBe('全局定价')
|
||||
|
||||
expect(resolveSettlementPricingSourceLabel({
|
||||
tiered_pricing: { source: 'provider' },
|
||||
})).toBe('提供商定价')
|
||||
})
|
||||
|
||||
it('uses only processing_tier_price_multiplier, never settlement.rate_multiplier', () => {
|
||||
expect(resolveProcessingTierPriceMultiplier(buildSource())).toBe(2.5)
|
||||
expect(resolveProcessingTierPriceMultiplier(buildSource({
|
||||
processing_tier_price_multiplier: null,
|
||||
}))).toBeNull()
|
||||
expect(resolveProcessingTierPriceMultiplier({
|
||||
settlement: { rate_multiplier: 9 },
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to legacy tiers when the resolved snapshot has no catalog', () => {
|
||||
expect(resolveSettlementPricingTiers(buildSource({ tiered_pricing: null }))).toEqual([
|
||||
{ up_to: null, input_price_per_1m: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('formats an input-only embedding tier without inventing an output price', () => {
|
||||
const tiers = resolveSettlementPricingTiers(buildSource({
|
||||
tiered_pricing: {
|
||||
tiers: [{ up_to: null, input_price_per_1m: 0.1 }],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(formatPricePerMillion(tiers?.[0]?.input_price_per_1m)).toBe('$0.1/M')
|
||||
expect(formatPricePerMillion(tiers?.[0]?.output_price_per_1m)).toBe('-')
|
||||
expect(formatPricePerMillion(null)).toBe('-')
|
||||
expect(formatPricePerMillion(0)).toBe('$0/M')
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,21 @@ export function normalizeServiceTierFact(value: unknown): string | null {
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider contracts use both `priority` (OpenAI) and `fast` (Claude) for the
|
||||
* same user-facing processing mode. Keep the raw fact in data structures and
|
||||
* normalize only at the presentation boundary.
|
||||
*/
|
||||
export function formatServiceTierFact(value: unknown): string | null {
|
||||
const normalized = normalizeServiceTierFact(value)
|
||||
if (normalized === null) return null
|
||||
|
||||
const canonical = normalized.toLowerCase()
|
||||
return canonical === 'priority' || canonical === 'fast'
|
||||
? 'Fast'
|
||||
: normalized
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
interface SettlementPricingSource {
|
||||
settlement?: unknown
|
||||
tiered_pricing?: unknown
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const PRICING_SOURCE_LABELS: Record<string, string> = {
|
||||
provider_override: '提供商定价',
|
||||
global_default: '全局定价',
|
||||
mixed: '混合定价',
|
||||
provider: '提供商定价',
|
||||
global: '全局定价',
|
||||
unpriced: '未定价',
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingSnapshot(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): JsonRecord | null {
|
||||
const settlement = asRecord(source?.settlement)
|
||||
const settlementSnapshot = asRecord(settlement?.settlement_snapshot)
|
||||
return asRecord(settlementSnapshot?.pricing_snapshot)
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingSourceLabel(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): string | null {
|
||||
const snapshot = resolveSettlementPricingSnapshot(source)
|
||||
const legacyPricing = asRecord(source?.tiered_pricing)
|
||||
for (const value of [
|
||||
snapshot?.pricing_source,
|
||||
snapshot?.tiered_pricing_source,
|
||||
legacyPricing?.source,
|
||||
]) {
|
||||
const key = normalizeString(value)?.toLowerCase()
|
||||
if (key && PRICING_SOURCE_LABELS[key]) return PRICING_SOURCE_LABELS[key]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveSettlementPricingTiers(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): JsonRecord[] | null {
|
||||
const snapshot = resolveSettlementPricingSnapshot(source)
|
||||
const snapshotPricing = asRecord(snapshot?.tiered_pricing)
|
||||
const snapshotTiers = nonEmptyRecordArray(snapshotPricing?.tiers)
|
||||
if (snapshotTiers) return snapshotTiers
|
||||
|
||||
const legacyPricing = asRecord(source?.tiered_pricing)
|
||||
return nonEmptyRecordArray(legacyPricing?.tiers)
|
||||
}
|
||||
|
||||
export function resolveProcessingTierPriceMultiplier(
|
||||
source: SettlementPricingSource | null | undefined,
|
||||
): number | null {
|
||||
const value = resolveSettlementPricingSnapshot(source)?.processing_tier_price_multiplier
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: null
|
||||
}
|
||||
|
||||
export function formatPricePerMillion(value: unknown): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '-'
|
||||
|
||||
const fixed = value.toFixed(4)
|
||||
return `$${Number.parseFloat(fixed).toString()}/M`
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nonEmptyRecordArray(value: unknown): JsonRecord[] | null {
|
||||
if (!Array.isArray(value) || value.length === 0) return null
|
||||
const records = value.filter((item): item is JsonRecord => asRecord(item) !== null)
|
||||
return records.length > 0 ? records : null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonRecord
|
||||
: null
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MODEL_DIRECTIVE_API_FORMATS,
|
||||
MODEL_DIRECTIVE_SUFFIX_METADATA,
|
||||
MODEL_DIRECTIVE_SUFFIXES,
|
||||
REASONING_EFFORTS,
|
||||
createDefaultModelDirectivesConfig,
|
||||
@@ -31,6 +32,7 @@ describe('modelDirectivesConfig', () => {
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('openai:search')).toEqual(MODEL_DIRECTIVE_SUFFIXES)
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('claude:messages')).not.toContain('ultra')
|
||||
expect(defaultModelDirectiveSuffixesForApiFormat('gemini:generate_content')).not.toContain('ultra')
|
||||
expect(MODEL_DIRECTIVE_SUFFIX_METADATA.fast.description).toBe('Fast 服务层级')
|
||||
})
|
||||
|
||||
it('creates a config whose mappings contain overrides only', () => {
|
||||
|
||||
@@ -44,7 +44,7 @@ export const MODEL_DIRECTIVE_SUFFIX_METADATA: Readonly<
|
||||
xhigh: { label: 'xhigh', description: '超高推理投入' },
|
||||
max: { label: 'max', description: '模型支持时使用最大推理投入' },
|
||||
ultra: { label: 'ultra', description: 'Codex Ultra 预设,请求推理强度为 max' },
|
||||
fast: { label: 'fast', description: 'Priority 服务层级' },
|
||||
fast: { label: 'fast', description: 'Fast 服务层级' },
|
||||
}
|
||||
|
||||
export const MODEL_DIRECTIVE_API_FORMATS = [
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 定价信息 -->
|
||||
|
||||
Reference in New Issue
Block a user