mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(billing): add image output range pricing support
This commit is contained in:
@@ -30,13 +30,16 @@ impl DefaultBillingRuleGenerator {
|
|||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let image_output_price_entries = explicit_image_output_price_entries(pricing_config)
|
let explicit_image_output_price_default =
|
||||||
.filter(|entries| !entries.is_empty())
|
explicit_image_output_price_default(pricing_config);
|
||||||
.unwrap_or_default();
|
|
||||||
let explicit_image_output_price_default = explicit_image_output_price_default(pricing_config);
|
|
||||||
let image_output_price_default = explicit_image_output_price_default.unwrap_or(0.0);
|
let image_output_price_default = explicit_image_output_price_default.unwrap_or(0.0);
|
||||||
let has_image_output_pricing =
|
let has_image_output_matrix = explicit_image_output_price_entries(pricing_config)
|
||||||
!image_output_price_entries.is_empty() || explicit_image_output_price_default.is_some();
|
.is_some_and(|entries| !entries.is_empty());
|
||||||
|
let has_image_output_ranges = explicit_image_output_price_ranges(pricing_config)
|
||||||
|
.is_some_and(|ranges| !ranges.is_empty());
|
||||||
|
let has_image_output_pricing = has_image_output_matrix
|
||||||
|
|| has_image_output_ranges
|
||||||
|
|| explicit_image_output_price_default.is_some();
|
||||||
|
|
||||||
if tiers.is_empty()
|
if tiers.is_empty()
|
||||||
&& pricing.effective_price_per_request().is_none()
|
&& pricing.effective_price_per_request().is_none()
|
||||||
@@ -104,6 +107,11 @@ impl DefaultBillingRuleGenerator {
|
|||||||
("image_count", "image_count", json!(0)),
|
("image_count", "image_count", json!(0)),
|
||||||
("image_count_unmetered", "image_count_unmetered", json!(0)),
|
("image_count_unmetered", "image_count_unmetered", json!(0)),
|
||||||
("image_price_key", "image_price_key", json!("default")),
|
("image_price_key", "image_price_key", json!("default")),
|
||||||
|
(
|
||||||
|
"image_output_price_per_image",
|
||||||
|
"image_output_price_per_image",
|
||||||
|
json!(image_output_price_default),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
dimension_mappings.insert(
|
dimension_mappings.insert(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
@@ -156,18 +164,6 @@ impl DefaultBillingRuleGenerator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !image_output_price_entries.is_empty() {
|
|
||||||
dimension_mappings.insert(
|
|
||||||
"image_output_price_per_image".to_string(),
|
|
||||||
json!({
|
|
||||||
"source": "matrix",
|
|
||||||
"key": "image_price_key",
|
|
||||||
"entries": image_output_price_entries,
|
|
||||||
"default": image_output_price_default,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !tiers.is_empty() {
|
if !tiers.is_empty() {
|
||||||
dimension_mappings.insert(
|
dimension_mappings.insert(
|
||||||
"input_price_per_1m".to_string(),
|
"input_price_per_1m".to_string(),
|
||||||
@@ -302,7 +298,7 @@ fn build_tier_entries(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn explicit_image_output_price_entries(
|
pub(crate) fn explicit_image_output_price_entries(
|
||||||
pricing_config: Option<&Value>,
|
pricing_config: Option<&Value>,
|
||||||
) -> Option<BTreeMap<String, Value>> {
|
) -> Option<BTreeMap<String, Value>> {
|
||||||
let pricing_config = pricing_config?;
|
let pricing_config = pricing_config?;
|
||||||
@@ -320,7 +316,93 @@ fn explicit_image_output_price_entries(
|
|||||||
Some(entries)
|
Some(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
|
pub(crate) fn explicit_image_output_price_ranges(
|
||||||
|
pricing_config: Option<&Value>,
|
||||||
|
) -> Option<Vec<Value>> {
|
||||||
|
let pricing_config = pricing_config?;
|
||||||
|
let Some(value) = pricing_config.get("image_output_price_ranges") else {
|
||||||
|
return Some(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
let Some(object) = item.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut range = serde_json::Map::new();
|
||||||
|
if let Some(up_to_pixels) = object
|
||||||
|
.get("up_to_pixels")
|
||||||
|
.or_else(|| object.get("up_to"))
|
||||||
|
.or_else(|| object.get("max_pixels"))
|
||||||
|
{
|
||||||
|
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
|
||||||
|
}
|
||||||
|
if let Some(label) = object.get("label").cloned() {
|
||||||
|
range.insert("label".to_string(), label);
|
||||||
|
}
|
||||||
|
if let Some(prices) = object.get("prices") {
|
||||||
|
range.insert("prices".to_string(), prices.clone());
|
||||||
|
} else {
|
||||||
|
let mut prices = serde_json::Map::new();
|
||||||
|
for quality in ["low", "medium", "high"] {
|
||||||
|
if let Some(price) = object.get(quality).and_then(Value::as_f64) {
|
||||||
|
prices.insert(quality.to_string(), json!(price));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prices.is_empty() {
|
||||||
|
if let Some(price) = object
|
||||||
|
.get("price_per_image")
|
||||||
|
.or_else(|| object.get("price"))
|
||||||
|
.or_else(|| object.get("value"))
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
{
|
||||||
|
prices.insert("default".to_string(), json!(price));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !prices.is_empty() {
|
||||||
|
range.insert("prices".to_string(), Value::Object(prices));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !range.is_empty() {
|
||||||
|
ranges.push(Value::Object(range));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
for (key, item) in object {
|
||||||
|
let Some(entry) = item.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut range = serde_json::Map::new();
|
||||||
|
if let Some(up_to_pixels) = entry
|
||||||
|
.get("up_to_pixels")
|
||||||
|
.or_else(|| entry.get("up_to"))
|
||||||
|
.or_else(|| entry.get("max_pixels"))
|
||||||
|
{
|
||||||
|
range.insert("up_to_pixels".to_string(), up_to_pixels.clone());
|
||||||
|
} else if let Ok(parsed) = key.parse::<u64>() {
|
||||||
|
range.insert("up_to_pixels".to_string(), json!(parsed));
|
||||||
|
}
|
||||||
|
if let Some(label) = entry.get("label").cloned() {
|
||||||
|
range.insert("label".to_string(), label);
|
||||||
|
}
|
||||||
|
if let Some(prices) = entry.get("prices") {
|
||||||
|
range.insert("prices".to_string(), prices.clone());
|
||||||
|
}
|
||||||
|
if !range.is_empty() {
|
||||||
|
ranges.push(Value::Object(range));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(ranges)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
|
||||||
let pricing_config = pricing_config?;
|
let pricing_config = pricing_config?;
|
||||||
pricing_config
|
pricing_config
|
||||||
.get("image_output_price_default")
|
.get("image_output_price_default")
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ fn has_pricing_data(value: &Value) -> bool {
|
|||||||
.is_some()
|
.is_some()
|
||||||
|| [
|
|| [
|
||||||
"image_output_prices",
|
"image_output_prices",
|
||||||
|
"image_output_price_ranges",
|
||||||
"image_output_price_per_image",
|
"image_output_price_per_image",
|
||||||
"image_output_price_matrix",
|
"image_output_price_matrix",
|
||||||
"image_prices",
|
"image_prices",
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::default_rule::{normalize_task_type, DefaultBillingRuleGenerator};
|
use crate::default_rule::{
|
||||||
|
explicit_image_output_price_default, explicit_image_output_price_entries,
|
||||||
|
explicit_image_output_price_ranges, normalize_task_type, DefaultBillingRuleGenerator,
|
||||||
|
};
|
||||||
use crate::precision::quantize_cost;
|
use crate::precision::quantize_cost;
|
||||||
use crate::pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
|
use crate::pricing::{BillingComputation, BillingModelPricingSnapshot, BillingUsageInput};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
@@ -30,7 +33,6 @@ impl BillingService {
|
|||||||
pricing: &BillingModelPricingSnapshot,
|
pricing: &BillingModelPricingSnapshot,
|
||||||
input: &BillingUsageInput,
|
input: &BillingUsageInput,
|
||||||
) -> Result<BillingComputation, ExpressionEvaluationError> {
|
) -> Result<BillingComputation, ExpressionEvaluationError> {
|
||||||
let image_output_pricing = image_output_pricing_state(pricing);
|
|
||||||
let Some(rule) =
|
let Some(rule) =
|
||||||
DefaultBillingRuleGenerator::generate_for_pricing(pricing, &input.task_type)
|
DefaultBillingRuleGenerator::generate_for_pricing(pricing, &input.task_type)
|
||||||
else {
|
else {
|
||||||
@@ -44,7 +46,7 @@ impl BillingService {
|
|||||||
rule_name: None,
|
rule_name: None,
|
||||||
scope: None,
|
scope: None,
|
||||||
expression: None,
|
expression: None,
|
||||||
resolved_dimensions: build_dimensions(input, image_output_pricing),
|
resolved_dimensions: build_dimensions(input, pricing),
|
||||||
resolved_variables: BTreeMap::new(),
|
resolved_variables: BTreeMap::new(),
|
||||||
cost_breakdown: BTreeMap::new(),
|
cost_breakdown: BTreeMap::new(),
|
||||||
total_cost: 0.0,
|
total_cost: 0.0,
|
||||||
@@ -63,7 +65,7 @@ impl BillingService {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let dims = build_dimensions(input, image_output_pricing);
|
let dims = build_dimensions(input, pricing);
|
||||||
let result = self.engine.evaluate(
|
let result = self.engine.evaluate(
|
||||||
&rule.expression,
|
&rule.expression,
|
||||||
Some(&rule.variables),
|
Some(&rule.variables),
|
||||||
@@ -126,7 +128,7 @@ impl Default for BillingService {
|
|||||||
|
|
||||||
fn build_dimensions(
|
fn build_dimensions(
|
||||||
input: &BillingUsageInput,
|
input: &BillingUsageInput,
|
||||||
image_output_pricing: ImageOutputPricingState,
|
pricing: &BillingModelPricingSnapshot,
|
||||||
) -> BTreeMap<String, Value> {
|
) -> BTreeMap<String, Value> {
|
||||||
let normalized_input_tokens = normalize_input_tokens_for_billing(
|
let normalized_input_tokens = normalize_input_tokens_for_billing(
|
||||||
input.api_format.as_deref(),
|
input.api_format.as_deref(),
|
||||||
@@ -146,6 +148,8 @@ fn build_dimensions(
|
|||||||
input.cache_creation_tokens,
|
input.cache_creation_tokens,
|
||||||
input.cache_read_tokens,
|
input.cache_read_tokens,
|
||||||
);
|
);
|
||||||
|
let image_output_pricing = image_output_pricing_state(pricing);
|
||||||
|
let image_output_resolution = resolve_image_output_price_resolution(pricing, input);
|
||||||
|
|
||||||
let mut out = BTreeMap::from([
|
let mut out = BTreeMap::from([
|
||||||
("input_tokens".to_string(), json!(normalized_input_tokens)),
|
("input_tokens".to_string(), json!(normalized_input_tokens)),
|
||||||
@@ -191,15 +195,17 @@ fn build_dimensions(
|
|||||||
"image_output_matrix_enabled".to_string(),
|
"image_output_matrix_enabled".to_string(),
|
||||||
json!(image_output_pricing.matrix_enabled),
|
json!(image_output_pricing.matrix_enabled),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"image_output_range_enabled".to_string(),
|
||||||
|
json!(image_output_pricing.range_enabled),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"image_output_pricing_mode".to_string(),
|
"image_output_pricing_mode".to_string(),
|
||||||
json!(if image_output_pricing.matrix_enabled {
|
json!(image_output_resolution.pricing_mode),
|
||||||
"matrix"
|
),
|
||||||
} else if image_output_pricing.enabled {
|
(
|
||||||
"per_image"
|
"image_output_price_per_image".to_string(),
|
||||||
} else {
|
json!(image_output_resolution.price_per_image),
|
||||||
"none"
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"total_input_context".to_string(),
|
"total_input_context".to_string(),
|
||||||
@@ -226,6 +232,12 @@ fn build_dimensions(
|
|||||||
json!(cache_ttl_minutes.max(0)),
|
json!(cache_ttl_minutes.max(0)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(image_pixels) = image_output_resolution.image_pixels {
|
||||||
|
out.insert("image_pixels".to_string(), json!(image_pixels));
|
||||||
|
}
|
||||||
|
if let Some(price_bucket) = image_output_resolution.price_bucket.as_ref() {
|
||||||
|
out.insert("image_output_price_bucket".to_string(), json!(price_bucket));
|
||||||
|
}
|
||||||
if input.image_count > 0 {
|
if input.image_count > 0 {
|
||||||
let image_size = input
|
let image_size = input
|
||||||
.image_size
|
.image_size
|
||||||
@@ -252,8 +264,8 @@ fn build_dimensions(
|
|||||||
"image_price_key".to_string(),
|
"image_price_key".to_string(),
|
||||||
json!(format!(
|
json!(format!(
|
||||||
"{}:{}",
|
"{}:{}",
|
||||||
image_size.to_ascii_lowercase().replace(' ', ""),
|
normalize_image_output_size(image_size),
|
||||||
image_quality.to_ascii_lowercase()
|
normalize_image_output_quality(image_quality)
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,14 +285,99 @@ fn build_dimensions(
|
|||||||
struct ImageOutputPricingState {
|
struct ImageOutputPricingState {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
matrix_enabled: bool,
|
matrix_enabled: bool,
|
||||||
|
range_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ImageOutputPriceResolution {
|
||||||
|
price_per_image: f64,
|
||||||
|
pricing_mode: &'static str,
|
||||||
|
price_bucket: Option<String>,
|
||||||
|
image_pixels: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ParsedImageOutputPriceRange {
|
||||||
|
up_to_pixels: Option<i64>,
|
||||||
|
label: Option<String>,
|
||||||
|
prices: BTreeMap<String, f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
|
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
|
||||||
let matrix_enabled = pricing_has_image_output_matrix(pricing);
|
let matrix_enabled = pricing_has_image_output_matrix(pricing);
|
||||||
|
let range_enabled = pricing_has_image_output_ranges(pricing);
|
||||||
let default_enabled = pricing_has_image_output_default_price(pricing);
|
let default_enabled = pricing_has_image_output_default_price(pricing);
|
||||||
ImageOutputPricingState {
|
ImageOutputPricingState {
|
||||||
enabled: matrix_enabled || default_enabled,
|
enabled: matrix_enabled || range_enabled || default_enabled,
|
||||||
matrix_enabled,
|
matrix_enabled,
|
||||||
|
range_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_image_output_price_resolution(
|
||||||
|
pricing: &BillingModelPricingSnapshot,
|
||||||
|
input: &BillingUsageInput,
|
||||||
|
) -> ImageOutputPriceResolution {
|
||||||
|
let pricing_config = pricing.effective_tiered_pricing();
|
||||||
|
let default_price = explicit_image_output_price_default(pricing_config);
|
||||||
|
let image_size = input
|
||||||
|
.image_size
|
||||||
|
.as_deref()
|
||||||
|
.map(normalize_image_output_size)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let image_quality = input
|
||||||
|
.image_quality
|
||||||
|
.as_deref()
|
||||||
|
.map(normalize_image_output_quality)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let image_pixels = image_size.as_deref().and_then(parse_image_size_pixels);
|
||||||
|
|
||||||
|
if let (Some(size), Some(entries)) = (
|
||||||
|
image_size.as_deref(),
|
||||||
|
explicit_image_output_price_entries(pricing_config),
|
||||||
|
) {
|
||||||
|
for key in image_price_lookup_keys(size, image_quality.as_deref()) {
|
||||||
|
if let Some(price) = entries.get(&key).and_then(Value::as_f64) {
|
||||||
|
return ImageOutputPriceResolution {
|
||||||
|
price_per_image: price,
|
||||||
|
pricing_mode: "matrix",
|
||||||
|
price_bucket: None,
|
||||||
|
image_pixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(pixels) = image_pixels {
|
||||||
|
if let Some((price, bucket)) = resolve_image_output_range_price(
|
||||||
|
explicit_image_output_price_ranges(pricing_config).unwrap_or_default(),
|
||||||
|
pixels,
|
||||||
|
image_quality.as_deref(),
|
||||||
|
default_price,
|
||||||
|
) {
|
||||||
|
return ImageOutputPriceResolution {
|
||||||
|
price_per_image: price,
|
||||||
|
pricing_mode: "pixel_tiers",
|
||||||
|
price_bucket: Some(bucket),
|
||||||
|
image_pixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(price) = default_price {
|
||||||
|
return ImageOutputPriceResolution {
|
||||||
|
price_per_image: price,
|
||||||
|
pricing_mode: "per_image",
|
||||||
|
price_bucket: Some("default".to_string()),
|
||||||
|
image_pixels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageOutputPriceResolution {
|
||||||
|
price_per_image: 0.0,
|
||||||
|
pricing_mode: "none",
|
||||||
|
price_bucket: None,
|
||||||
|
image_pixels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +399,11 @@ fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> boo
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pricing_has_image_output_ranges(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||||
|
explicit_image_output_price_ranges(pricing.effective_tiered_pricing())
|
||||||
|
.is_some_and(|ranges| !ranges.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
|
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||||
let Some(config) = pricing.effective_tiered_pricing() else {
|
let Some(config) = pricing.effective_tiered_pricing() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -329,6 +431,147 @@ fn image_price_entries_have_matrix_values(value: &Value) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_image_output_range_price(
|
||||||
|
ranges: Vec<Value>,
|
||||||
|
image_pixels: i64,
|
||||||
|
image_quality: Option<&str>,
|
||||||
|
default_price: Option<f64>,
|
||||||
|
) -> Option<(f64, String)> {
|
||||||
|
let mut parsed_ranges = ranges
|
||||||
|
.iter()
|
||||||
|
.filter_map(parse_image_output_price_range)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
parsed_ranges.sort_by(
|
||||||
|
|left, right| match (left.up_to_pixels, right.up_to_pixels) {
|
||||||
|
(Some(left), Some(right)) => left.cmp(&right),
|
||||||
|
(Some(_), None) => std::cmp::Ordering::Less,
|
||||||
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||||
|
(None, None) => std::cmp::Ordering::Equal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for range in parsed_ranges {
|
||||||
|
if !range
|
||||||
|
.up_to_pixels
|
||||||
|
.map(|up_to| image_pixels <= up_to)
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(price) =
|
||||||
|
image_output_price_for_quality(&range.prices, image_quality).or(default_price)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
return Some((price, image_output_range_bucket(&range)));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_image_output_price_range(value: &Value) -> Option<ParsedImageOutputPriceRange> {
|
||||||
|
let object = value.as_object()?;
|
||||||
|
let prices = object
|
||||||
|
.get("prices")
|
||||||
|
.and_then(Value::as_object)?
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
value
|
||||||
|
.as_f64()
|
||||||
|
.map(|price| (key.to_ascii_lowercase(), price))
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
if prices.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(ParsedImageOutputPriceRange {
|
||||||
|
up_to_pixels: object.get("up_to_pixels").and_then(value_as_positive_i64),
|
||||||
|
label: object
|
||||||
|
.get("label")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
prices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_output_price_for_quality(
|
||||||
|
prices: &BTreeMap<String, f64>,
|
||||||
|
image_quality: Option<&str>,
|
||||||
|
) -> Option<f64> {
|
||||||
|
for key in image_quality_lookup_keys(image_quality) {
|
||||||
|
if let Some(price) = prices.get(&key) {
|
||||||
|
return Some(*price);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_price_lookup_keys(size: &str, image_quality: Option<&str>) -> Vec<String> {
|
||||||
|
image_quality_lookup_keys(image_quality)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|quality| quality != "default")
|
||||||
|
.map(|quality| format!("{}:{}", size, quality))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_quality_lookup_keys(image_quality: Option<&str>) -> Vec<String> {
|
||||||
|
let quality = image_quality
|
||||||
|
.map(normalize_image_output_quality)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "medium".to_string());
|
||||||
|
let mut keys = vec![quality.clone()];
|
||||||
|
if quality == "auto" {
|
||||||
|
keys.push("medium".to_string());
|
||||||
|
}
|
||||||
|
keys.push("default".to_string());
|
||||||
|
keys
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_output_range_bucket(range: &ParsedImageOutputPriceRange) -> String {
|
||||||
|
range
|
||||||
|
.label
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| match range.up_to_pixels {
|
||||||
|
Some(up_to_pixels) => format!("<={up_to_pixels}px"),
|
||||||
|
None => "unbounded".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_image_output_size(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace('×', "x")
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !ch.is_whitespace())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_image_output_quality(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_image_size_pixels(size: &str) -> Option<i64> {
|
||||||
|
let (width, height) = size.split_once('x')?;
|
||||||
|
let width = width.parse::<i64>().ok()?;
|
||||||
|
let height = height.parse::<i64>().ok()?;
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
width.checked_mul(height)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_as_positive_i64(value: &Value) -> Option<i64> {
|
||||||
|
let parsed = value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| value.as_u64().and_then(|value| i64::try_from(value).ok()))
|
||||||
|
.or_else(|| value.as_f64().map(|value| value as i64))
|
||||||
|
.or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))?;
|
||||||
|
(parsed > 0).then_some(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
fn now_marker() -> String {
|
fn now_marker() -> String {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -607,6 +850,61 @@ mod tests {
|
|||||||
assert_eq!(result.cost_result.cost, 0.1);
|
assert_eq!(result.cost_result.cost, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_pixel_ranges_generate_rule_without_token_tiers() {
|
||||||
|
let pricing = BillingModelPricingSnapshot {
|
||||||
|
default_price_per_request: None,
|
||||||
|
default_tiered_pricing: Some(json!({
|
||||||
|
"image_output_price_ranges": [{
|
||||||
|
"up_to_pixels": null,
|
||||||
|
"prices": { "medium": 0.04 }
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
..pricing()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = BillingService::new()
|
||||||
|
.calculate(
|
||||||
|
&pricing,
|
||||||
|
&BillingUsageInput {
|
||||||
|
task_type: "image".to_string(),
|
||||||
|
api_format: Some("openai:image".to_string()),
|
||||||
|
request_count: 1,
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_creation_ephemeral_5m_tokens: 0,
|
||||||
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
image_count: 2,
|
||||||
|
image_size: Some("1024x1024".to_string()),
|
||||||
|
image_quality: Some("medium".to_string()),
|
||||||
|
image_output_format: Some("png".to_string()),
|
||||||
|
cache_ttl_minutes: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("billing should calculate");
|
||||||
|
|
||||||
|
assert_eq!(result.cost_result.status, BillingSnapshotStatus::Complete);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_pricing_mode"),
|
||||||
|
Some(&json!("pixel_tiers"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.08)
|
||||||
|
);
|
||||||
|
assert_eq!(result.cost_result.cost, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_token_usage_with_matrix_adds_matrix_image_cost() {
|
fn image_token_usage_with_matrix_adds_matrix_image_cost() {
|
||||||
let pricing = BillingModelPricingSnapshot {
|
let pricing = BillingModelPricingSnapshot {
|
||||||
@@ -665,6 +963,96 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_token_usage_with_pixel_ranges_adds_range_image_cost() {
|
||||||
|
let pricing = BillingModelPricingSnapshot {
|
||||||
|
default_price_per_request: None,
|
||||||
|
default_tiered_pricing: Some(json!({
|
||||||
|
"tiers": [{
|
||||||
|
"up_to": null,
|
||||||
|
"input_price_per_1m": 1.0,
|
||||||
|
"output_price_per_1m": 2.0
|
||||||
|
}],
|
||||||
|
"image_output_price_default": 0.01,
|
||||||
|
"image_output_price_ranges": [
|
||||||
|
{
|
||||||
|
"up_to_pixels": 1_048_576,
|
||||||
|
"prices": { "medium": 0.04 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"up_to_pixels": 2_097_152,
|
||||||
|
"prices": { "medium": 0.08 }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})),
|
||||||
|
..pricing()
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = BillingService::new()
|
||||||
|
.calculate(
|
||||||
|
&pricing,
|
||||||
|
&BillingUsageInput {
|
||||||
|
task_type: "image".to_string(),
|
||||||
|
api_format: Some("openai:image".to_string()),
|
||||||
|
request_count: 1,
|
||||||
|
input_tokens: 1_000,
|
||||||
|
output_tokens: 20_000,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_creation_ephemeral_5m_tokens: 0,
|
||||||
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
image_count: 1,
|
||||||
|
image_size: Some("1536 x 1024".to_string()),
|
||||||
|
image_quality: Some("medium".to_string()),
|
||||||
|
image_output_format: Some("png".to_string()),
|
||||||
|
cache_ttl_minutes: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("billing should calculate");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_pricing_mode"),
|
||||||
|
Some(&json!("pixel_tiers"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_pixels"),
|
||||||
|
Some(&json!(1_572_864))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_price_bucket"),
|
||||||
|
Some(&json!("<=2097152px"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_variables
|
||||||
|
.get("image_output_price_per_image"),
|
||||||
|
Some(&json!(0.08))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.08)
|
||||||
|
);
|
||||||
|
assert_eq!(result.cost_result.cost, 0.121);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn five_minute_cache_ttl_uses_base_cache_prices() {
|
fn five_minute_cache_ttl_uses_base_cache_prices() {
|
||||||
let pricing = BillingModelPricingSnapshot {
|
let pricing = BillingModelPricingSnapshot {
|
||||||
|
|||||||
@@ -687,7 +687,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(usage.request_count, 2);
|
assert_eq!(usage.request_count, 2);
|
||||||
assert_eq!(usage.dimensions.get("image_count"), Some(&serde_json::json!(2)));
|
assert_eq!(
|
||||||
|
usage.dimensions.get("image_count"),
|
||||||
|
Some(&serde_json::json!(2))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -707,6 +710,9 @@ mod tests {
|
|||||||
assert_eq!(usage.input_tokens, 11);
|
assert_eq!(usage.input_tokens, 11);
|
||||||
assert_eq!(usage.output_tokens, 22);
|
assert_eq!(usage.output_tokens, 22);
|
||||||
assert_eq!(usage.request_count, 1);
|
assert_eq!(usage.request_count, 1);
|
||||||
assert_eq!(usage.dimensions.get("image_count"), Some(&serde_json::json!(1)));
|
assert_eq!(
|
||||||
|
usage.dimensions.get("image_count"),
|
||||||
|
Some(&serde_json::json!(1))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,20 @@ export interface PricingTier {
|
|||||||
cache_ttl_pricing?: CacheTTLPricing[]
|
cache_ttl_pricing?: CacheTTLPricing[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
|
export interface ImageOutputPriceRange {
|
||||||
|
up_to_pixels: number | null
|
||||||
|
prices: Partial<Record<ImageOutputQuality, number>>
|
||||||
|
label?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/** 阶梯计费配置 */
|
/** 阶梯计费配置 */
|
||||||
export interface TieredPricingConfig {
|
export interface TieredPricingConfig {
|
||||||
tiers: PricingTier[]
|
tiers: PricingTier[]
|
||||||
image_output_prices?: Record<string, Record<string, number>> | null
|
image_output_prices?: Record<string, Record<string, number>> | null
|
||||||
image_output_price_default?: number | null
|
image_output_price_default?: number | null
|
||||||
|
image_output_price_ranges?: ImageOutputPriceRange[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
|
|||||||
@@ -948,9 +948,16 @@ async function handleSubmit() {
|
|||||||
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
||||||
if (!pricing) return false
|
if (!pricing) return false
|
||||||
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
||||||
return Object.values(pricing.image_output_prices || {}).some((prices) => {
|
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
|
||||||
if (!prices || typeof prices !== 'object') return false
|
if (!prices || typeof prices !== 'object') return false
|
||||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||||
|
})) return true
|
||||||
|
return (pricing.image_output_price_ranges || []).some((range) => {
|
||||||
|
if (!range || typeof range !== 'object') return false
|
||||||
|
const prices = range.prices && typeof range.prices === 'object'
|
||||||
|
? range.prices
|
||||||
|
: range as Record<string, unknown>
|
||||||
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,13 @@
|
|||||||
>
|
>
|
||||||
矩阵
|
矩阵
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="imagePriceRangeEntries.length > 0"
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] h-5 px-1.5"
|
||||||
|
>
|
||||||
|
区间
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="imageOutputDefaultPrice !== null"
|
v-if="imageOutputDefaultPrice !== null"
|
||||||
@@ -206,6 +213,45 @@
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="imagePriceRangeEntries.length > 0"
|
||||||
|
class="border rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow class="bg-muted/30">
|
||||||
|
<TableHead class="text-xs h-9">
|
||||||
|
上限像素
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||||
|
:key="quality"
|
||||||
|
class="text-xs h-9 text-right"
|
||||||
|
>
|
||||||
|
{{ quality }}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="entry in imagePriceRangeEntries"
|
||||||
|
:key="entry.key"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<TableCell class="py-2 font-mono">
|
||||||
|
{{ formatPixelLimit(entry.upToPixels) }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||||
|
:key="`${entry.key}-${quality}`"
|
||||||
|
class="py-2 text-right font-mono"
|
||||||
|
>
|
||||||
|
{{ formatImagePrice(entry.prices[quality]) }}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 单阶梯(固定价格)展示 -->
|
<!-- 单阶梯(固定价格)展示 -->
|
||||||
@@ -640,8 +686,26 @@ const imagePricingEntries = computed(() => {
|
|||||||
})).filter(entry => Object.values(entry.prices).some(price => price !== null))
|
})).filter(entry => Object.values(entry.prices).some(price => price !== null))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imagePriceRangeEntries = computed(() => {
|
||||||
|
const ranges = props.model?.default_tiered_pricing?.image_output_price_ranges
|
||||||
|
if (!Array.isArray(ranges)) return []
|
||||||
|
return ranges.map((range, index) => {
|
||||||
|
const object = range && typeof range === 'object' ? range as Record<string, unknown> : {}
|
||||||
|
const rawPrices = object.prices && typeof object.prices === 'object'
|
||||||
|
? object.prices
|
||||||
|
: object
|
||||||
|
return {
|
||||||
|
key: `${object.up_to_pixels ?? 'unbounded'}-${index}`,
|
||||||
|
upToPixels: toFiniteNumber(object.up_to_pixels),
|
||||||
|
prices: normalizeImageQualityPrices(rawPrices),
|
||||||
|
}
|
||||||
|
}).filter(entry => Object.values(entry.prices).some(price => price !== null))
|
||||||
|
})
|
||||||
|
|
||||||
const hasImagePricing = computed(() =>
|
const hasImagePricing = computed(() =>
|
||||||
imageOutputDefaultPrice.value !== null || imagePricingEntries.value.length > 0,
|
imageOutputDefaultPrice.value !== null
|
||||||
|
|| imagePricingEntries.value.length > 0
|
||||||
|
|| imagePriceRangeEntries.value.length > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
function normalizeImageQualityPrices(value: unknown): Record<typeof IMAGE_OUTPUT_QUALITIES[number], number | null> {
|
function normalizeImageQualityPrices(value: unknown): Record<typeof IMAGE_OUTPUT_QUALITIES[number], number | null> {
|
||||||
@@ -665,6 +729,20 @@ function formatImageSize(value: string): string {
|
|||||||
return value.replace(/\s*[xX×]\s*/g, ' x ')
|
return value.replace(/\s*[xX×]\s*/g, ' x ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPixelLimit(value: number | null): string {
|
||||||
|
return value === null ? '无上限' : `<= ${formatPixels(value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPixels(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${(value / 1_000).toFixed(0)}K px`
|
||||||
|
}
|
||||||
|
return `${value} px`
|
||||||
|
}
|
||||||
|
|
||||||
const detailTab = ref('basic')
|
const detailTab = ref('basic')
|
||||||
|
|
||||||
// 处理背景点击
|
// 处理背景点击
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
class="rounded-lg border bg-muted/10 p-3 space-y-3"
|
class="rounded-lg border bg-muted/10 p-3 space-y-3"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<Label class="text-xs font-medium">图像输出矩阵 ($/张)</Label>
|
<Label class="text-xs font-medium">图像输出计费 ($/张)</Label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Label class="text-xs text-muted-foreground">默认价</Label>
|
<Label class="text-xs text-muted-foreground">默认价</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -158,6 +158,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label class="text-xs text-muted-foreground">精确分辨率覆盖</Label>
|
||||||
|
<span class="text-[11px] text-muted-foreground">优先匹配 size + quality</span>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
|
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
|
||||||
<span>分辨率</span>
|
<span>分辨率</span>
|
||||||
<span>low</span>
|
<span>low</span>
|
||||||
@@ -208,6 +212,64 @@
|
|||||||
添加分辨率
|
添加分辨率
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 border-t pt-3">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label class="text-xs text-muted-foreground">像素区间</Label>
|
||||||
|
<span class="text-[11px] text-muted-foreground">矩阵未命中时按宽×高落档</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>上限像素</span>
|
||||||
|
<span>low</span>
|
||||||
|
<span>medium</span>
|
||||||
|
<span>high</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in imageOutputPriceRangeRows"
|
||||||
|
:key="row.id"
|
||||||
|
class="grid grid-cols-[minmax(120px,1.1fr)_repeat(3,minmax(0,1fr))_32px] gap-2 items-center"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
:model-value="row.upToPixels"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="h-8 font-mono text-xs"
|
||||||
|
placeholder="空=无上限"
|
||||||
|
@update:model-value="(v) => updateImageOutputRangeLimit(row.id, v)"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||||
|
:key="`${row.id}-${quality}`"
|
||||||
|
:model-value="getImageOutputRangePrice(row, quality)"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
class="h-8"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="(v) => updateImageOutputRangePrice(row.id, quality, v)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 w-8 p-0"
|
||||||
|
@click="removeImageOutputRangeRow(row.id)"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
@click="addImageOutputRangeRow"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4 mr-2" />
|
||||||
|
添加像素区间
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 验证提示 -->
|
<!-- 验证提示 -->
|
||||||
@@ -224,7 +286,7 @@
|
|||||||
import { ref, computed, watch, reactive } from 'vue'
|
import { ref, computed, watch, reactive } from 'vue'
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
import { Button, Input, Label } from '@/components/ui'
|
import { Button, Input, Label } from '@/components/ui'
|
||||||
import type { TieredPricingConfig, PricingTier } from '@/api/endpoints/types'
|
import type { TieredPricingConfig, PricingTier, ImageOutputPriceRange } from '@/api/endpoints/types'
|
||||||
|
|
||||||
type ImageOutputQuality = 'low' | 'medium' | 'high'
|
type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||||
type ImageOutputPriceRow = {
|
type ImageOutputPriceRow = {
|
||||||
@@ -232,8 +294,14 @@ type ImageOutputPriceRow = {
|
|||||||
size: string
|
size: string
|
||||||
prices: Partial<Record<ImageOutputQuality, number>>
|
prices: Partial<Record<ImageOutputQuality, number>>
|
||||||
}
|
}
|
||||||
|
type ImageOutputPriceRangeRow = {
|
||||||
|
id: string
|
||||||
|
upToPixels: string
|
||||||
|
prices: Partial<Record<ImageOutputQuality, number>>
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
|
const DEFAULT_IMAGE_OUTPUT_SIZES = ['1024x1024', '1536x1024', '1024x1536']
|
||||||
|
const DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS = [1_048_576, 1_572_864, 2_097_152]
|
||||||
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -249,9 +317,11 @@ const emit = defineEmits<{
|
|||||||
// 本地状态
|
// 本地状态
|
||||||
const localTiers = ref<PricingTier[]>([])
|
const localTiers = ref<PricingTier[]>([])
|
||||||
const imageOutputPriceRows = ref<ImageOutputPriceRow[]>([])
|
const imageOutputPriceRows = ref<ImageOutputPriceRow[]>([])
|
||||||
|
const imageOutputPriceRangeRows = ref<ImageOutputPriceRangeRow[]>([])
|
||||||
const imageOutputPriceDefault = ref<string>('')
|
const imageOutputPriceDefault = ref<string>('')
|
||||||
const lastEmittedPricingJson = ref<string>('')
|
const lastEmittedPricingJson = ref<string>('')
|
||||||
let imageOutputPriceRowId = 0
|
let imageOutputPriceRowId = 0
|
||||||
|
let imageOutputPriceRangeRowId = 0
|
||||||
|
|
||||||
// 跟踪每个阶梯的缓存价格是否被手动设置
|
// 跟踪每个阶梯的缓存价格是否被手动设置
|
||||||
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
||||||
@@ -280,6 +350,7 @@ watch(
|
|||||||
if (newValue?.tiers) {
|
if (newValue?.tiers) {
|
||||||
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
||||||
imageOutputPriceRows.value = createImageOutputPriceRows(newValue.image_output_prices)
|
imageOutputPriceRows.value = createImageOutputPriceRows(newValue.image_output_prices)
|
||||||
|
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(newValue.image_output_price_ranges)
|
||||||
imageOutputPriceDefault.value = newValue.image_output_price_default != null
|
imageOutputPriceDefault.value = newValue.image_output_price_default != null
|
||||||
? String(newValue.image_output_price_default)
|
? String(newValue.image_output_price_default)
|
||||||
: ''
|
: ''
|
||||||
@@ -299,6 +370,7 @@ watch(
|
|||||||
output_price_per_1m: 0,
|
output_price_per_1m: 0,
|
||||||
}]
|
}]
|
||||||
imageOutputPriceRows.value = createImageOutputPriceRows(null)
|
imageOutputPriceRows.value = createImageOutputPriceRows(null)
|
||||||
|
imageOutputPriceRangeRows.value = createImageOutputPriceRangeRows(null)
|
||||||
imageOutputPriceDefault.value = ''
|
imageOutputPriceDefault.value = ''
|
||||||
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
||||||
}
|
}
|
||||||
@@ -524,6 +596,10 @@ function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
|
|||||||
if (Object.keys(matrix).length > 0) {
|
if (Object.keys(matrix).length > 0) {
|
||||||
config.image_output_prices = matrix
|
config.image_output_prices = matrix
|
||||||
}
|
}
|
||||||
|
const ranges = normalizedImageOutputPriceRanges()
|
||||||
|
if (ranges.length > 0) {
|
||||||
|
config.image_output_price_ranges = ranges
|
||||||
|
}
|
||||||
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
|
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
|
||||||
if (defaultPrice != null) {
|
if (defaultPrice != null) {
|
||||||
config.image_output_price_default = defaultPrice
|
config.image_output_price_default = defaultPrice
|
||||||
@@ -551,6 +627,31 @@ function createImageOutputPriceRows(value: TieredPricingConfig['image_output_pri
|
|||||||
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
|
return DEFAULT_IMAGE_OUTPUT_SIZES.map(size => createImageOutputPriceRow(size))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createImageOutputPriceRangeRows(value: TieredPricingConfig['image_output_price_ranges']): ImageOutputPriceRangeRow[] {
|
||||||
|
const rows: ImageOutputPriceRangeRow[] = []
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
for (const range of value) {
|
||||||
|
if (!range || typeof range !== 'object') continue
|
||||||
|
const rowPrices: Partial<Record<ImageOutputQuality, number>> = {}
|
||||||
|
const rawPrices = 'prices' in range && range.prices && typeof range.prices === 'object'
|
||||||
|
? range.prices as Record<string, unknown>
|
||||||
|
: range as Record<string, unknown>
|
||||||
|
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||||
|
const price = rawPrices[quality]
|
||||||
|
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||||
|
rowPrices[quality] = price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upToPixels = 'up_to_pixels' in range && range.up_to_pixels != null
|
||||||
|
? String(range.up_to_pixels)
|
||||||
|
: ''
|
||||||
|
rows.push(createImageOutputPriceRangeRow(upToPixels, rowPrices))
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
function createImageOutputPriceRow(
|
function createImageOutputPriceRow(
|
||||||
size = '',
|
size = '',
|
||||||
prices: Partial<Record<ImageOutputQuality, number>> = {},
|
prices: Partial<Record<ImageOutputQuality, number>> = {},
|
||||||
@@ -563,6 +664,18 @@ function createImageOutputPriceRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createImageOutputPriceRangeRow(
|
||||||
|
upToPixels = '',
|
||||||
|
prices: Partial<Record<ImageOutputQuality, number>> = {},
|
||||||
|
): ImageOutputPriceRangeRow {
|
||||||
|
imageOutputPriceRangeRowId += 1
|
||||||
|
return {
|
||||||
|
id: `image-output-range-${imageOutputPriceRangeRowId}`,
|
||||||
|
upToPixels,
|
||||||
|
prices: { ...prices },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
|
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
|
||||||
const out: Record<string, Record<string, number>> = {}
|
const out: Record<string, Record<string, number>> = {}
|
||||||
for (const row of imageOutputPriceRows.value) {
|
for (const row of imageOutputPriceRows.value) {
|
||||||
@@ -578,12 +691,42 @@ function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizedImageOutputPriceRanges(): ImageOutputPriceRange[] {
|
||||||
|
const ranges: ImageOutputPriceRange[] = []
|
||||||
|
for (const row of imageOutputPriceRangeRows.value) {
|
||||||
|
const prices: Partial<Record<ImageOutputQuality, number>> = {}
|
||||||
|
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||||
|
const price = row.prices[quality]
|
||||||
|
if (price != null && Number.isFinite(price)) {
|
||||||
|
prices[quality] = price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(prices).length === 0) continue
|
||||||
|
ranges.push({
|
||||||
|
up_to_pixels: parseOptionalInteger(row.upToPixels),
|
||||||
|
prices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ranges.sort((a, b) => {
|
||||||
|
if (a.up_to_pixels == null && b.up_to_pixels == null) return 0
|
||||||
|
if (a.up_to_pixels == null) return 1
|
||||||
|
if (b.up_to_pixels == null) return -1
|
||||||
|
return a.up_to_pixels - b.up_to_pixels
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseOptionalFloat(value: string | number): number | null {
|
function parseOptionalFloat(value: string | number): number | null {
|
||||||
if (value === '' || value === null || value === undefined) return null
|
if (value === '' || value === null || value === undefined) return null
|
||||||
const number = typeof value === 'string' ? parseFloat(value) : value
|
const number = typeof value === 'string' ? parseFloat(value) : value
|
||||||
return Number.isFinite(number) ? number : null
|
return Number.isFinite(number) ? number : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOptionalInteger(value: string | number): number | null {
|
||||||
|
if (value === '' || value === null || value === undefined) return null
|
||||||
|
const number = typeof value === 'string' ? parseInt(value, 10) : value
|
||||||
|
return Number.isFinite(number) && number > 0 ? Math.trunc(number) : null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeImageOutputSize(size: string): string {
|
function normalizeImageOutputSize(size: string): string {
|
||||||
return String(size || '').trim().replace(/\s*[xX×]\s*/g, 'x')
|
return String(size || '').trim().replace(/\s*[xX×]\s*/g, 'x')
|
||||||
}
|
}
|
||||||
@@ -592,6 +735,10 @@ function getImageOutputPrice(row: ImageOutputPriceRow, quality: ImageOutputQuali
|
|||||||
return row.prices[quality] ?? ''
|
return row.prices[quality] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getImageOutputRangePrice(row: ImageOutputPriceRangeRow, quality: ImageOutputQuality): string | number {
|
||||||
|
return row.prices[quality] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
function updateImageOutputSize(rowId: string, value: string | number) {
|
function updateImageOutputSize(rowId: string, value: string | number) {
|
||||||
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
|
const row = imageOutputPriceRows.value.find(item => item.id === rowId)
|
||||||
if (!row) return
|
if (!row) return
|
||||||
@@ -625,6 +772,39 @@ function removeImageOutputSizeRow(rowId: string) {
|
|||||||
syncToParent()
|
syncToParent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateImageOutputRangeLimit(rowId: string, value: string | number) {
|
||||||
|
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
|
||||||
|
if (!row) return
|
||||||
|
row.upToPixels = String(value ?? '')
|
||||||
|
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageOutputRangePrice(rowId: string, quality: ImageOutputQuality, value: string | number) {
|
||||||
|
const row = imageOutputPriceRangeRows.value.find(item => item.id === rowId)
|
||||||
|
if (!row) return
|
||||||
|
const price = parseOptionalFloat(value)
|
||||||
|
if (price == null) {
|
||||||
|
delete row.prices[quality]
|
||||||
|
} else {
|
||||||
|
row.prices[quality] = price
|
||||||
|
}
|
||||||
|
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value]
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageOutputRangeRow() {
|
||||||
|
const usedLimits = new Set(imageOutputPriceRangeRows.value.map(row => parseOptionalInteger(row.upToPixels)).filter((value): value is number => value !== null))
|
||||||
|
const suggestedLimit = DEFAULT_IMAGE_OUTPUT_PIXEL_LIMITS.find(limit => !usedLimits.has(limit))
|
||||||
|
imageOutputPriceRangeRows.value = [...imageOutputPriceRangeRows.value, createImageOutputPriceRangeRow(suggestedLimit ? String(suggestedLimit) : '')]
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImageOutputRangeRow(rowId: string) {
|
||||||
|
imageOutputPriceRangeRows.value = imageOutputPriceRangeRows.value.filter(row => row.id !== rowId)
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
function updateImageOutputPriceDefault(value: string | number) {
|
function updateImageOutputPriceDefault(value: string | number) {
|
||||||
imageOutputPriceDefault.value = String(value ?? '')
|
imageOutputPriceDefault.value = String(value ?? '')
|
||||||
syncToParent()
|
syncToParent()
|
||||||
|
|||||||
@@ -574,9 +574,16 @@ function modelSupportsImageGeneration(model: {
|
|||||||
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
function tieredPricingHasImageOutputPricing(pricing: TieredPricingConfig | null | undefined): boolean {
|
||||||
if (!pricing) return false
|
if (!pricing) return false
|
||||||
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
if (toFinitePrice(pricing.image_output_price_default) !== null) return true
|
||||||
return Object.values(pricing.image_output_prices || {}).some((prices) => {
|
if (Object.values(pricing.image_output_prices || {}).some((prices) => {
|
||||||
if (!prices || typeof prices !== 'object') return false
|
if (!prices || typeof prices !== 'object') return false
|
||||||
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||||
|
})) return true
|
||||||
|
return (pricing.image_output_price_ranges || []).some((range) => {
|
||||||
|
if (!range || typeof range !== 'object') return false
|
||||||
|
const prices = range.prices && typeof range.prices === 'object'
|
||||||
|
? range.prices
|
||||||
|
: range as Record<string, unknown>
|
||||||
|
return Object.values(prices).some((price) => toFinitePrice(price) !== null)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -403,13 +403,9 @@
|
|||||||
{{ imageOutputBillingLabel }}
|
{{ imageOutputBillingLabel }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span
|
<span
|
||||||
v-if="imageOutputMatrixEnabled && imagePriceKey"
|
v-if="imageOutputPricingDescriptor"
|
||||||
class="text-muted-foreground font-mono"
|
class="text-muted-foreground font-mono"
|
||||||
>{{ imagePriceKey }}</span>
|
>{{ imageOutputPricingDescriptor }}</span>
|
||||||
<span
|
|
||||||
v-else-if="imageOutputSize || imageOutputQuality"
|
|
||||||
class="text-muted-foreground font-mono"
|
|
||||||
>{{ [imageOutputSize, imageOutputQuality].filter(Boolean).join(' / ') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
@@ -1482,6 +1478,15 @@ const imagePriceKey = computed(() => {
|
|||||||
return fallbackKey || null
|
return fallbackKey || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imageOutputPriceBucket = computed(() =>
|
||||||
|
getNestedString(billingResolvedDimensions.value, 'image_output_price_bucket'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputPixels = computed(() =>
|
||||||
|
getNestedNumber(billingResolvedDimensions.value, 'image_pixels')
|
||||||
|
?? parseImageSizePixels(imageOutputSize.value),
|
||||||
|
)
|
||||||
|
|
||||||
const imageOutputPricingMode = computed(() =>
|
const imageOutputPricingMode = computed(() =>
|
||||||
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
|
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
|
||||||
)
|
)
|
||||||
@@ -1489,18 +1494,43 @@ const imageOutputPricingMode = computed(() =>
|
|||||||
const imageOutputPricingEnabled = computed(() =>
|
const imageOutputPricingEnabled = computed(() =>
|
||||||
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|
||||||
|| imageOutputPricingMode.value === 'matrix'
|
|| imageOutputPricingMode.value === 'matrix'
|
||||||
|
|| imageOutputPricingMode.value === 'pixel_tiers'
|
||||||
|| imageOutputPricingMode.value === 'per_image'
|
|| imageOutputPricingMode.value === 'per_image'
|
||||||
|| imageOutputCostTotal.value > 0,
|
|| imageOutputCostTotal.value > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
const imageOutputMatrixEnabled = computed(() =>
|
const imageOutputMatrixEnabled = computed(() => {
|
||||||
getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'matrix'
|
||||||
|| imageOutputPricingMode.value === 'matrix',
|
return getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|
||||||
)
|
})
|
||||||
|
|
||||||
const imageOutputBillingLabel = computed(() =>
|
const imageOutputRangeEnabled = computed(() => {
|
||||||
imageOutputMatrixEnabled.value ? '矩阵计费' : '默认计费',
|
if (imageOutputPricingMode.value) return imageOutputPricingMode.value === 'pixel_tiers'
|
||||||
)
|
return getNestedValue(billingResolvedDimensions.value, 'image_output_range_enabled') === true
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageOutputBillingLabel = computed(() => {
|
||||||
|
if (imageOutputMatrixEnabled.value) return '矩阵计费'
|
||||||
|
if (imageOutputRangeEnabled.value) return '像素区间'
|
||||||
|
return '默认计费'
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageOutputPricingDescriptor = computed(() => {
|
||||||
|
if (imageOutputMatrixEnabled.value && imagePriceKey.value) return imagePriceKey.value
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
if (imageOutputPriceBucket.value && imageOutputPriceBucket.value !== 'default') {
|
||||||
|
parts.push(formatImagePriceBucket(imageOutputPriceBucket.value))
|
||||||
|
}
|
||||||
|
const sizeQuality = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(' / ')
|
||||||
|
if (sizeQuality) parts.push(sizeQuality)
|
||||||
|
if (imageOutputRangeEnabled.value && imageOutputPixels.value !== null) {
|
||||||
|
parts.push(formatPixels(imageOutputPixels.value))
|
||||||
|
}
|
||||||
|
if (parts.length > 0) return parts.join(' · ')
|
||||||
|
if (imageOutputPriceBucket.value === 'default') return '默认价'
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
const hasImageBillingDetail = computed(() =>
|
const hasImageBillingDetail = computed(() =>
|
||||||
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
|
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
|
||||||
@@ -2206,6 +2236,34 @@ function formatNumber(num: number): string {
|
|||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseImageSizePixels(size: string | null): number | null {
|
||||||
|
if (!size) return null
|
||||||
|
const normalized = size.trim().toLowerCase().replace(/\s+/g, '').replace(/×/g, 'x')
|
||||||
|
const [widthText, heightText] = normalized.split('x')
|
||||||
|
const width = Number(widthText)
|
||||||
|
const height = Number(heightText)
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
|
||||||
|
return Math.trunc(width * height)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImagePriceBucket(bucket: string): string {
|
||||||
|
if (bucket === 'default') return '默认价'
|
||||||
|
if (bucket === 'unbounded') return '无上限'
|
||||||
|
const match = bucket.match(/^<=([0-9]+)px$/)
|
||||||
|
if (match) return `<= ${formatPixels(Number(match[1]))}`
|
||||||
|
return bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPixels(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 2)}M px`
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${(value / 1_000).toFixed(0)}K px`
|
||||||
|
}
|
||||||
|
return `${value} px`
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化响应时间,自动选择合适的单位
|
// 格式化响应时间,自动选择合适的单位
|
||||||
function formatResponseTime(ms: number): { value: string; unit: string } {
|
function formatResponseTime(ms: number): { value: string; unit: string } {
|
||||||
if (ms >= 1_000) {
|
if (ms >= 1_000) {
|
||||||
|
|||||||
Reference in New Issue
Block a user