Merge remote-tracking branch 'origin/main' into codex/pool-key-bulk-management-20260714

This commit is contained in:
MMEXA
2026-07-17 00:17:02 +08:00
40 changed files with 5012 additions and 565 deletions
+368 -23
View File
@@ -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(
+26 -1
View File
@@ -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
+19 -1
View File
@@ -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
+2 -1
View File
@@ -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
+217 -9
View File
@@ -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'],
})
})
})
+26 -24
View File
@@ -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
+19 -4
View File
@@ -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
+86 -1
View File
@@ -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),
}
}
+24 -4
View File
@@ -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: 'FastOpenAI' },
{ key: 'fast', label: 'FastClaude' },
{ 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: 'FastOpenAI' },
{ key: 'fast', label: 'FastClaude' },
{ 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) {
@@ -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,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('FastOpenAI')
expect(root.querySelector('[data-processing-tier="fast"]')?.textContent)
.toContain('FastClaude')
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 }],
@@ -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('FastOpenAI')
expect(root.textContent).toContain('FastClaude')
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="FastOpenAI 阶梯 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="FastOpenAI 图像输出默认价格"]'))
.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="FastOpenAI 阶梯 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="删除 FastOpenAI 阶梯 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="FastOpenAI 图像输出默认价格"]',
) 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('FastOpenAI')
expect(getValidationError()).toContain('上限必须大于前一个阶梯')
expect(() => getFinalPricing()).toThrow('Priority')
expect(() => getFinalPricing()).toThrow('FastOpenAI')
})
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,
@@ -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')
})
})
@@ -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>
<!-- 定价信息 -->