mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(billing): add image quality pricing and usage tracking
This commit is contained in:
@@ -913,7 +913,7 @@ fn build_openai_image_provider_body_from_openai_chat_body(
|
|||||||
"operation".to_string(),
|
"operation".to_string(),
|
||||||
Value::String(operation.to_string()),
|
Value::String(operation.to_string()),
|
||||||
);
|
);
|
||||||
for key in ["output_format", "partial_images"] {
|
for key in ["output_format", "partial_images", "size", "quality"] {
|
||||||
if let Some(value) = tool.get(key) {
|
if let Some(value) = tool.get(key) {
|
||||||
summary.insert(key.to_string(), value.clone());
|
summary.insert(key.to_string(), value.clone());
|
||||||
}
|
}
|
||||||
@@ -943,6 +943,12 @@ fn build_chatgpt_web_image_provider_body_from_openai_chat_body(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.unwrap_or("png");
|
.unwrap_or("png");
|
||||||
|
let quality = body_json
|
||||||
|
.get("quality")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("medium");
|
||||||
let model = body_json
|
let model = body_json
|
||||||
.get("model")
|
.get("model")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -970,6 +976,8 @@ fn build_chatgpt_web_image_provider_body_from_openai_chat_body(
|
|||||||
let summary = json!({
|
let summary = json!({
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
"output_format": output_format,
|
"output_format": output_format,
|
||||||
|
"size": size,
|
||||||
|
"quality": quality,
|
||||||
});
|
});
|
||||||
Some((body, summary))
|
Some((body, summary))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -821,6 +821,7 @@ fn build_chatgpt_web_image_provider_body_from_openai_responses_body(
|
|||||||
let size = image_option_string(tool.as_ref(), object, "size").unwrap_or("1024x1024");
|
let size = image_option_string(tool.as_ref(), object, "size").unwrap_or("1024x1024");
|
||||||
let output_format =
|
let output_format =
|
||||||
image_option_string(tool.as_ref(), object, "output_format").unwrap_or("png");
|
image_option_string(tool.as_ref(), object, "output_format").unwrap_or("png");
|
||||||
|
let quality = image_option_string(tool.as_ref(), object, "quality").unwrap_or("medium");
|
||||||
let model = object
|
let model = object
|
||||||
.get("model")
|
.get("model")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -845,6 +846,8 @@ fn build_chatgpt_web_image_provider_body_from_openai_responses_body(
|
|||||||
let summary = json!({
|
let summary = json!({
|
||||||
"operation": operation,
|
"operation": operation,
|
||||||
"output_format": output_format,
|
"output_format": output_format,
|
||||||
|
"size": size,
|
||||||
|
"quality": quality,
|
||||||
});
|
});
|
||||||
Some((body, summary))
|
Some((body, summary))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -816,6 +816,11 @@ fn openai_image_stream_standardized_usage(
|
|||||||
.dimensions
|
.dimensions
|
||||||
.insert("image_size".to_string(), serde_json::json!(size));
|
.insert("image_size".to_string(), serde_json::json!(size));
|
||||||
}
|
}
|
||||||
|
if let Some(quality) = image_request_quality(report_context) {
|
||||||
|
standardized_usage
|
||||||
|
.dimensions
|
||||||
|
.insert("image_quality".to_string(), serde_json::json!(quality));
|
||||||
|
}
|
||||||
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
|
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,6 +1054,16 @@ fn image_request_size(report_context: Option<&Value>) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
|
||||||
|
report_context
|
||||||
|
.and_then(|value| value.get("image_request"))
|
||||||
|
.and_then(|value| value.get("quality"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
|
fn image_bridge_model(report_context: Option<&Value>) -> Option<String> {
|
||||||
report_context.and_then(|context| {
|
report_context.and_then(|context| {
|
||||||
context
|
context
|
||||||
|
|||||||
@@ -978,6 +978,7 @@ mod tests {
|
|||||||
let mut report_context = report_context("openai:image", "openai:chat");
|
let mut report_context = report_context("openai:image", "openai:chat");
|
||||||
report_context["image_request"] = json!({
|
report_context["image_request"] = json!({
|
||||||
"size": "1024x1024",
|
"size": "1024x1024",
|
||||||
|
"quality": "medium",
|
||||||
"output_format": "png",
|
"output_format": "png",
|
||||||
});
|
});
|
||||||
let mut observer = StreamingStandardTerminalObserver::default();
|
let mut observer = StreamingStandardTerminalObserver::default();
|
||||||
@@ -1048,5 +1049,9 @@ mod tests {
|
|||||||
usage.dimensions.get("image_output_format"),
|
usage.dimensions.get("image_output_format"),
|
||||||
Some(&json!("png"))
|
Some(&json!("png"))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usage.dimensions.get("image_quality"),
|
||||||
|
Some(&json!("medium"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,6 +471,11 @@ fn openai_image_standardized_usage(
|
|||||||
.dimensions
|
.dimensions
|
||||||
.insert("image_size".to_string(), json!(size));
|
.insert("image_size".to_string(), json!(size));
|
||||||
}
|
}
|
||||||
|
if let Some(quality) = image_request_quality(report_context) {
|
||||||
|
standardized_usage
|
||||||
|
.dimensions
|
||||||
|
.insert("image_quality".to_string(), json!(quality));
|
||||||
|
}
|
||||||
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
|
(standardized_usage.signal_score() > 0).then_some(standardized_usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,6 +551,16 @@ fn image_request_size(report_context: Option<&Value>) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn image_request_quality(report_context: Option<&Value>) -> Option<String> {
|
||||||
|
report_context
|
||||||
|
.and_then(|value| value.get("image_request"))
|
||||||
|
.and_then(|value| value.get("quality"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn mime_type_from_image_output_format(output_format: &str) -> String {
|
fn mime_type_from_image_output_format(output_format: &str) -> String {
|
||||||
match output_format.trim().to_ascii_lowercase().as_str() {
|
match output_format.trim().to_ascii_lowercase().as_str() {
|
||||||
"jpg" | "jpeg" => "image/jpeg".to_string(),
|
"jpg" | "jpeg" => "image/jpeg".to_string(),
|
||||||
@@ -999,7 +1014,8 @@ mod tests {
|
|||||||
"image_request": {
|
"image_request": {
|
||||||
"operation": "generate",
|
"operation": "generate",
|
||||||
"output_format": "png",
|
"output_format": "png",
|
||||||
"size": "1024x1024"
|
"size": "1024x1024",
|
||||||
|
"quality": "medium"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
let outcome = maybe_bridge_standard_sync_json_to_stream(
|
||||||
@@ -1045,6 +1061,10 @@ mod tests {
|
|||||||
usage.dimensions.get("image_size"),
|
usage.dimensions.get("image_size"),
|
||||||
Some(&json!("1024x1024"))
|
Some(&json!("1024x1024"))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usage.dimensions.get("image_quality"),
|
||||||
|
Some(&json!("medium"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -23,14 +23,23 @@ impl DefaultBillingRuleGenerator {
|
|||||||
pricing: &BillingModelPricingSnapshot,
|
pricing: &BillingModelPricingSnapshot,
|
||||||
task_type: &str,
|
task_type: &str,
|
||||||
) -> Option<VirtualBillingRule> {
|
) -> Option<VirtualBillingRule> {
|
||||||
|
let pricing_config = pricing.effective_tiered_pricing();
|
||||||
let tiers = pricing
|
let tiers = pricing
|
||||||
.effective_tiered_pricing()
|
.effective_tiered_pricing()
|
||||||
.and_then(|value| value.get("tiers"))
|
.and_then(|value| value.get("tiers"))
|
||||||
.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)
|
||||||
|
.filter(|entries| !entries.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let image_output_price_default =
|
||||||
|
explicit_image_output_price_default(pricing_config).unwrap_or(0.0);
|
||||||
|
|
||||||
if tiers.is_empty() && pricing.effective_price_per_request().is_none() {
|
if tiers.is_empty()
|
||||||
|
&& pricing.effective_price_per_request().is_none()
|
||||||
|
&& image_output_price_entries.is_empty()
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +72,10 @@ impl DefaultBillingRuleGenerator {
|
|||||||
json!(base_cache_read_price),
|
json!(base_cache_read_price),
|
||||||
);
|
);
|
||||||
variables.insert("price_per_request".to_string(), json!(base_request_price));
|
variables.insert("price_per_request".to_string(), json!(base_request_price));
|
||||||
|
variables.insert(
|
||||||
|
"image_output_price_per_image".to_string(),
|
||||||
|
json!(image_output_price_default),
|
||||||
|
);
|
||||||
|
|
||||||
let mut dimension_mappings = BTreeMap::new();
|
let mut dimension_mappings = BTreeMap::new();
|
||||||
for (name, key, default) in [
|
for (name, key, default) in [
|
||||||
@@ -87,6 +100,8 @@ impl DefaultBillingRuleGenerator {
|
|||||||
("cache_read_tokens", "cache_read_tokens", json!(0)),
|
("cache_read_tokens", "cache_read_tokens", json!(0)),
|
||||||
("request_count", "request_count", json!(1)),
|
("request_count", "request_count", json!(1)),
|
||||||
("image_count", "image_count", json!(0)),
|
("image_count", "image_count", json!(0)),
|
||||||
|
("image_count_unmetered", "image_count_unmetered", json!(0)),
|
||||||
|
("image_price_key", "image_price_key", json!("default")),
|
||||||
] {
|
] {
|
||||||
dimension_mappings.insert(
|
dimension_mappings.insert(
|
||||||
name.to_string(),
|
name.to_string(),
|
||||||
@@ -122,6 +137,10 @@ impl DefaultBillingRuleGenerator {
|
|||||||
"cache_read_cost",
|
"cache_read_cost",
|
||||||
"cache_read_tokens * cache_read_price_per_1m / 1000000",
|
"cache_read_tokens * cache_read_price_per_1m / 1000000",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"image_output_cost",
|
||||||
|
"image_count_unmetered * image_output_price_per_image",
|
||||||
|
),
|
||||||
("request_cost", "request_count * price_per_request"),
|
("request_cost", "request_count * price_per_request"),
|
||||||
] {
|
] {
|
||||||
dimension_mappings.insert(
|
dimension_mappings.insert(
|
||||||
@@ -135,6 +154,18 @@ 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(),
|
||||||
@@ -210,7 +241,7 @@ impl DefaultBillingRuleGenerator {
|
|||||||
id: "__default__".to_string(),
|
id: "__default__".to_string(),
|
||||||
name: format!("Default rule for {}", pricing.global_model_name),
|
name: format!("Default rule for {}", pricing.global_model_name),
|
||||||
task_type: normalize_task_type(task_type).to_string(),
|
task_type: normalize_task_type(task_type).to_string(),
|
||||||
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + request_cost".to_string(),
|
expression: "input_cost + output_cost + cache_creation_uncategorized_cost + cache_creation_ephemeral_5m_cost + cache_creation_ephemeral_1h_cost + cache_read_cost + image_output_cost + request_cost".to_string(),
|
||||||
variables,
|
variables,
|
||||||
dimension_mappings,
|
dimension_mappings,
|
||||||
scope: "default".to_string(),
|
scope: "default".to_string(),
|
||||||
@@ -268,3 +299,115 @@ fn build_tier_entries(
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn explicit_image_output_price_entries(
|
||||||
|
pricing_config: Option<&Value>,
|
||||||
|
) -> Option<BTreeMap<String, Value>> {
|
||||||
|
let pricing_config = pricing_config?;
|
||||||
|
let mut entries = BTreeMap::new();
|
||||||
|
for key in [
|
||||||
|
"image_output_prices",
|
||||||
|
"image_output_price_per_image",
|
||||||
|
"image_output_price_matrix",
|
||||||
|
"image_prices",
|
||||||
|
] {
|
||||||
|
if let Some(value) = pricing_config.get(key) {
|
||||||
|
collect_image_output_price_entries(value, &mut entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn explicit_image_output_price_default(pricing_config: Option<&Value>) -> Option<f64> {
|
||||||
|
let pricing_config = pricing_config?;
|
||||||
|
pricing_config
|
||||||
|
.get("image_output_price_default")
|
||||||
|
.or_else(|| pricing_config.get("image_price_default"))
|
||||||
|
.or_else(|| {
|
||||||
|
pricing_config
|
||||||
|
.get("image_output_prices")
|
||||||
|
.and_then(|value| value.get("default"))
|
||||||
|
})
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_image_output_price_entries(value: &Value, entries: &mut BTreeMap<String, Value>) {
|
||||||
|
if let Some(object) = value.as_object() {
|
||||||
|
for (key, value) in object {
|
||||||
|
if key.eq_ignore_ascii_case("default") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(price) = value.as_f64() {
|
||||||
|
entries.insert(normalize_image_price_key(key), json!(price));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(nested) = value.as_object() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key_is_quality = matches_quality_key(key);
|
||||||
|
for (nested_key, nested_value) in nested {
|
||||||
|
let Some(price) = nested_value.as_f64() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (size, quality) = if key_is_quality {
|
||||||
|
(nested_key.as_str(), key.as_str())
|
||||||
|
} else {
|
||||||
|
(key.as_str(), nested_key.as_str())
|
||||||
|
};
|
||||||
|
entries.insert(image_price_key(size, quality), json!(price));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(items) = value.as_array() {
|
||||||
|
for item in items.iter().filter_map(Value::as_object) {
|
||||||
|
let Some(size) = item.get("size").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let quality = item
|
||||||
|
.get("quality")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("medium");
|
||||||
|
let Some(price) = item
|
||||||
|
.get("price_per_image")
|
||||||
|
.or_else(|| item.get("price"))
|
||||||
|
.or_else(|| item.get("cost"))
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
entries.insert(image_price_key(size, quality), json!(price));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_image_price_key(value: &str) -> String {
|
||||||
|
if let Some((size, quality)) = value.split_once(':').or_else(|| value.split_once('|')) {
|
||||||
|
return image_price_key(size, quality);
|
||||||
|
}
|
||||||
|
value.trim().to_ascii_lowercase().replace(' ', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_price_key(size: &str, quality: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}:{}",
|
||||||
|
normalize_image_size(size),
|
||||||
|
normalize_image_quality(quality)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_image_size(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase().replace(' ', "")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_image_quality(value: &str) -> String {
|
||||||
|
value.trim().to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_quality_key(value: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
normalize_image_quality(value).as_str(),
|
||||||
|
"low" | "medium" | "high"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ fn calculate_billing_computation(
|
|||||||
.unwrap_or_default() as i64,
|
.unwrap_or_default() as i64,
|
||||||
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
|
cache_read_tokens: event.data.cache_read_input_tokens.unwrap_or_default() as i64,
|
||||||
image_count,
|
image_count,
|
||||||
|
image_size: usage_event_dimension_string(&event.data, "image_size"),
|
||||||
|
image_quality: usage_event_dimension_string(&event.data, "image_quality"),
|
||||||
|
image_output_format: usage_event_dimension_string(&event.data, "image_output_format"),
|
||||||
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
|
cache_ttl_minutes: pricing.provider_api_key_cache_ttl_minutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -200,6 +203,37 @@ fn usage_event_image_count(data: &aether_usage_runtime::UsageEventData) -> Optio
|
|||||||
.filter(|value| *value > 0)
|
.filter(|value| *value > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn usage_event_dimension_string(
|
||||||
|
data: &aether_usage_runtime::UsageEventData,
|
||||||
|
dimension_key: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
metadata_dimension_string(data.request_metadata.as_ref(), "dimensions", dimension_key).or_else(
|
||||||
|
|| {
|
||||||
|
metadata_dimension_string(
|
||||||
|
data.request_metadata.as_ref(),
|
||||||
|
"billing_dimensions",
|
||||||
|
dimension_key,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_dimension_string(
|
||||||
|
metadata: Option<&Value>,
|
||||||
|
bag_key: &str,
|
||||||
|
dimension_key: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
metadata
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get(bag_key))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get(dimension_key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn metadata_dimension_i64(
|
fn metadata_dimension_i64(
|
||||||
metadata: Option<&Value>,
|
metadata: Option<&Value>,
|
||||||
bag_key: &str,
|
bag_key: &str,
|
||||||
@@ -524,6 +558,100 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn image_usage_uses_configured_output_price_matrix() {
|
||||||
|
let lookup = TestLookup {
|
||||||
|
name_context: Some(
|
||||||
|
StoredBillingModelContext::new(
|
||||||
|
"provider-1".to_string(),
|
||||||
|
Some("pay_as_you_go".to_string()),
|
||||||
|
Some("key-1".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"global-image-1".to_string(),
|
||||||
|
"gpt-image-2".to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(json!({
|
||||||
|
"tiers": [{
|
||||||
|
"up_to": null,
|
||||||
|
"input_price_per_1m": 5.0,
|
||||||
|
"output_price_per_1m": 30.0,
|
||||||
|
"cache_read_price_per_1m": 1.25
|
||||||
|
}],
|
||||||
|
"image_output_price_default": 0.01,
|
||||||
|
"image_output_prices": {
|
||||||
|
"1024x1024": {"low": 0.006, "medium": 0.053, "high": 0.211},
|
||||||
|
"1536x1024": {"low": 0.005, "medium": 0.041, "high": 0.165},
|
||||||
|
"1024x1536": {"low": 0.005, "medium": 0.041, "high": 0.165}
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
Some("model-image-1".to_string()),
|
||||||
|
Some("gpt-image-2".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("billing context should build"),
|
||||||
|
),
|
||||||
|
model_id_context: None,
|
||||||
|
};
|
||||||
|
let mut event = UsageEvent::new(
|
||||||
|
UsageEventType::Completed,
|
||||||
|
"req-image-billing-matrix-1",
|
||||||
|
UsageEventData {
|
||||||
|
provider_name: "OpenAI Image".to_string(),
|
||||||
|
model: "gpt-image-2".to_string(),
|
||||||
|
provider_id: Some("provider-1".to_string()),
|
||||||
|
provider_api_key_id: Some("key-1".to_string()),
|
||||||
|
request_type: Some("chat".to_string()),
|
||||||
|
api_format: Some("openai:chat".to_string()),
|
||||||
|
endpoint_api_format: Some("openai:image".to_string()),
|
||||||
|
request_metadata: Some(json!({
|
||||||
|
"dimensions": {
|
||||||
|
"image_count": 2,
|
||||||
|
"image_size": "1536x1024",
|
||||||
|
"image_quality": "medium",
|
||||||
|
"image_output_format": "png"
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
status_code: Some(200),
|
||||||
|
..UsageEventData::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
enrich_usage_event_with_billing(&lookup, &mut event)
|
||||||
|
.await
|
||||||
|
.expect("billing should succeed");
|
||||||
|
|
||||||
|
assert_eq!(event.data.total_cost_usd, Some(0.082));
|
||||||
|
assert_eq!(event.data.actual_total_cost_usd, Some(0.082));
|
||||||
|
let metadata = event.data.request_metadata.as_ref().expect("metadata");
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.get("billing_dimensions")
|
||||||
|
.and_then(|value| value.get("image_price_key"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("1536x1024:medium")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.get("billing_snapshot")
|
||||||
|
.and_then(|value| value.get("resolved_variables"))
|
||||||
|
.and_then(|value| value.get("image_output_price_per_image"))
|
||||||
|
.and_then(Value::as_f64),
|
||||||
|
Some(0.041)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
metadata
|
||||||
|
.get("billing_snapshot")
|
||||||
|
.and_then(|value| value.get("cost_breakdown"))
|
||||||
|
.and_then(|value| value.get("image_output_cost"))
|
||||||
|
.and_then(Value::as_f64),
|
||||||
|
Some(0.082)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn enriches_cancelled_usage_event_with_billing_snapshot() {
|
async fn enriches_cancelled_usage_event_with_billing_snapshot() {
|
||||||
let lookup = TestLookup {
|
let lookup = TestLookup {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl BillingModelPricingSnapshot {
|
|||||||
pub fn effective_tiered_pricing(&self) -> Option<&Value> {
|
pub fn effective_tiered_pricing(&self) -> Option<&Value> {
|
||||||
self.model_tiered_pricing
|
self.model_tiered_pricing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|value| has_tiered_pricing_tiers(value))
|
.filter(|value| has_pricing_data(value))
|
||||||
.or(self.default_tiered_pricing.as_ref())
|
.or(self.default_tiered_pricing.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ impl BillingModelPricingSnapshot {
|
|||||||
if self
|
if self
|
||||||
.model_tiered_pricing
|
.model_tiered_pricing
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(has_tiered_pricing_tiers)
|
.is_some_and(has_pricing_data)
|
||||||
|| self.model_price_per_request.is_some()
|
|| self.model_price_per_request.is_some()
|
||||||
{
|
{
|
||||||
"provider_override"
|
"provider_override"
|
||||||
@@ -75,11 +75,28 @@ impl BillingModelPricingSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_tiered_pricing_tiers(value: &Value) -> bool {
|
fn has_pricing_data(value: &Value) -> bool {
|
||||||
value
|
value
|
||||||
.get("tiers")
|
.get("tiers")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.is_some_and(|tiers| !tiers.is_empty())
|
.is_some_and(|tiers| !tiers.is_empty())
|
||||||
|
|| value
|
||||||
|
.get("image_output_price_default")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.is_some()
|
||||||
|
|| [
|
||||||
|
"image_output_prices",
|
||||||
|
"image_output_price_per_image",
|
||||||
|
"image_output_price_matrix",
|
||||||
|
"image_prices",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|key| value.get(key).is_some_and(value_has_entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_has_entries(value: &Value) -> bool {
|
||||||
|
value.as_object().is_some_and(|object| !object.is_empty())
|
||||||
|
|| value.as_array().is_some_and(|items| !items.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -148,6 +165,9 @@ pub struct BillingUsageInput {
|
|||||||
pub cache_creation_ephemeral_1h_tokens: i64,
|
pub cache_creation_ephemeral_1h_tokens: i64,
|
||||||
pub cache_read_tokens: i64,
|
pub cache_read_tokens: i64,
|
||||||
pub image_count: i64,
|
pub image_count: i64,
|
||||||
|
pub image_size: Option<String>,
|
||||||
|
pub image_quality: Option<String>,
|
||||||
|
pub image_output_format: Option<String>,
|
||||||
pub cache_ttl_minutes: Option<i64>,
|
pub cache_ttl_minutes: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +184,9 @@ impl BillingUsageInput {
|
|||||||
cache_creation_ephemeral_1h_tokens: 0,
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 0,
|
cache_read_tokens: 0,
|
||||||
image_count: 0,
|
image_count: 0,
|
||||||
|
image_size: None,
|
||||||
|
image_quality: None,
|
||||||
|
image_output_format: None,
|
||||||
cache_ttl_minutes: None,
|
cache_ttl_minutes: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
json!(input.request_count.max(0)),
|
json!(input.request_count.max(0)),
|
||||||
),
|
),
|
||||||
("image_count".to_string(), json!(input.image_count.max(0))),
|
("image_count".to_string(), json!(input.image_count.max(0))),
|
||||||
|
(
|
||||||
|
"image_count_unmetered".to_string(),
|
||||||
|
json!(if input.output_tokens > 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
input.image_count.max(0)
|
||||||
|
}),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"total_input_context".to_string(),
|
"total_input_context".to_string(),
|
||||||
json!(total_input_context),
|
json!(total_input_context),
|
||||||
@@ -196,6 +204,46 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
json!(cache_ttl_minutes.max(0)),
|
json!(cache_ttl_minutes.max(0)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if input.image_count > 0 {
|
||||||
|
let image_size = input
|
||||||
|
.image_size
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let image_quality = input
|
||||||
|
.image_quality
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
if let Some(image_size) = image_size.as_ref() {
|
||||||
|
out.insert("image_size".to_string(), json!(image_size));
|
||||||
|
}
|
||||||
|
if let Some(image_quality) = image_quality.as_ref() {
|
||||||
|
out.insert("image_quality".to_string(), json!(image_quality));
|
||||||
|
}
|
||||||
|
if let (Some(image_size), Some(image_quality)) =
|
||||||
|
(image_size.as_ref(), image_quality.as_ref())
|
||||||
|
{
|
||||||
|
out.insert(
|
||||||
|
"image_price_key".to_string(),
|
||||||
|
json!(format!(
|
||||||
|
"{}:{}",
|
||||||
|
image_size.to_ascii_lowercase().replace(' ', ""),
|
||||||
|
image_quality.to_ascii_lowercase()
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(output_format) = input
|
||||||
|
.image_output_format
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
out.insert("image_output_format".to_string(), json!(output_format));
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,6 +306,9 @@ mod tests {
|
|||||||
cache_creation_ephemeral_1h_tokens: 0,
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 100,
|
cache_read_tokens: 100,
|
||||||
image_count: 0,
|
image_count: 0,
|
||||||
|
image_size: None,
|
||||||
|
image_quality: None,
|
||||||
|
image_output_format: None,
|
||||||
cache_ttl_minutes: Some(60),
|
cache_ttl_minutes: Some(60),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -285,6 +336,9 @@ mod tests {
|
|||||||
cache_creation_ephemeral_1h_tokens: 0,
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 800,
|
cache_read_tokens: 800,
|
||||||
image_count: 0,
|
image_count: 0,
|
||||||
|
image_size: None,
|
||||||
|
image_quality: None,
|
||||||
|
image_output_format: None,
|
||||||
cache_ttl_minutes: Some(60),
|
cache_ttl_minutes: Some(60),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -355,6 +409,9 @@ mod tests {
|
|||||||
cache_creation_ephemeral_1h_tokens: 0,
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 100,
|
cache_read_tokens: 100,
|
||||||
image_count: 0,
|
image_count: 0,
|
||||||
|
image_size: None,
|
||||||
|
image_quality: None,
|
||||||
|
image_output_format: None,
|
||||||
cache_ttl_minutes: Some(5),
|
cache_ttl_minutes: Some(5),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -425,6 +482,9 @@ mod tests {
|
|||||||
cache_creation_ephemeral_1h_tokens: 0,
|
cache_creation_ephemeral_1h_tokens: 0,
|
||||||
cache_read_tokens: 100,
|
cache_read_tokens: 100,
|
||||||
image_count: 0,
|
image_count: 0,
|
||||||
|
image_size: None,
|
||||||
|
image_quality: None,
|
||||||
|
image_output_format: None,
|
||||||
cache_ttl_minutes: Some(60),
|
cache_ttl_minutes: Some(60),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface PricingTier {
|
|||||||
/** 阶梯计费配置 */
|
/** 阶梯计费配置 */
|
||||||
export interface TieredPricingConfig {
|
export interface TieredPricingConfig {
|
||||||
tiers: PricingTier[]
|
tiers: PricingTier[]
|
||||||
|
image_output_prices?: Record<string, Record<string, number>> | null
|
||||||
|
image_output_price_default?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Model {
|
export interface Model {
|
||||||
|
|||||||
@@ -834,8 +834,7 @@ async function handleSubmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
|
||||||
|
|
||||||
if (!finalTieredPricing?.tiers?.length) {
|
if (!finalTieredPricing?.tiers?.length) {
|
||||||
showError('请配置至少一个价格阶梯')
|
showError('请配置至少一个价格阶梯')
|
||||||
|
|||||||
@@ -137,6 +137,51 @@
|
|||||||
添加价格阶梯
|
添加价格阶梯
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div class="rounded-lg border bg-muted/10 p-3 space-y-3">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<Label class="text-xs font-medium">图像输出矩阵 ($/张)</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Label class="text-xs text-muted-foreground">默认价</Label>
|
||||||
|
<Input
|
||||||
|
:model-value="imageOutputPriceDefault"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
class="h-8 w-24"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="updateImageOutputPriceDefault"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="grid grid-cols-[96px_repeat(3,minmax(0,1fr))] gap-2 text-xs text-muted-foreground">
|
||||||
|
<span />
|
||||||
|
<span>low</span>
|
||||||
|
<span>medium</span>
|
||||||
|
<span>high</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="size in IMAGE_OUTPUT_SIZES"
|
||||||
|
:key="size.value"
|
||||||
|
class="grid grid-cols-[96px_repeat(3,minmax(0,1fr))] gap-2 items-center"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-muted-foreground">{{ size.label }}</span>
|
||||||
|
<Input
|
||||||
|
v-for="quality in IMAGE_OUTPUT_QUALITIES"
|
||||||
|
:key="`${size.value}-${quality}`"
|
||||||
|
:model-value="getImageOutputPrice(size.value, quality)"
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
class="h-8"
|
||||||
|
placeholder="0"
|
||||||
|
@update:model-value="(v) => updateImageOutputPrice(size.value, quality, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 验证提示 -->
|
<!-- 验证提示 -->
|
||||||
<p
|
<p
|
||||||
v-if="validationError"
|
v-if="validationError"
|
||||||
@@ -153,6 +198,16 @@ 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 } from '@/api/endpoints/types'
|
||||||
|
|
||||||
|
type ImageOutputQuality = 'low' | 'medium' | 'high'
|
||||||
|
|
||||||
|
const IMAGE_OUTPUT_SIZES = [
|
||||||
|
{ value: '1024x1024', label: '1024 x 1024' },
|
||||||
|
{ value: '1536x1024', label: '1536 x 1024' },
|
||||||
|
{ value: '1024x1536', label: '1024 x 1536' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: TieredPricingConfig | null
|
modelValue?: TieredPricingConfig | null
|
||||||
showCache1h?: boolean
|
showCache1h?: boolean
|
||||||
@@ -164,6 +219,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
// 本地状态
|
// 本地状态
|
||||||
const localTiers = ref<PricingTier[]>([])
|
const localTiers = ref<PricingTier[]>([])
|
||||||
|
const imageOutputPrices = ref<Record<string, Record<string, number | undefined>>>({})
|
||||||
|
const imageOutputPriceDefault = ref<string>('')
|
||||||
|
|
||||||
// 跟踪每个阶梯的缓存价格是否被手动设置
|
// 跟踪每个阶梯的缓存价格是否被手动设置
|
||||||
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
const cacheManuallySet = reactive<Record<number, { creation: boolean; read: boolean; cache1h: boolean }>>({})
|
||||||
@@ -188,6 +245,10 @@ watch(
|
|||||||
(newValue) => {
|
(newValue) => {
|
||||||
if (newValue?.tiers) {
|
if (newValue?.tiers) {
|
||||||
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
localTiers.value = newValue.tiers.map(t => ({ ...t }))
|
||||||
|
imageOutputPrices.value = cloneImageOutputPrices(newValue.image_output_prices)
|
||||||
|
imageOutputPriceDefault.value = newValue.image_output_price_default != null
|
||||||
|
? String(newValue.image_output_price_default)
|
||||||
|
: ''
|
||||||
// 如果已有缓存价格,标记为手动设置
|
// 如果已有缓存价格,标记为手动设置
|
||||||
newValue.tiers.forEach((t, i) => {
|
newValue.tiers.forEach((t, i) => {
|
||||||
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
|
const has1hCache = t.cache_ttl_pricing?.some(c => c.ttl_minutes === 60) ?? false
|
||||||
@@ -203,6 +264,8 @@ watch(
|
|||||||
input_price_per_1m: 0,
|
input_price_per_1m: 0,
|
||||||
output_price_per_1m: 0,
|
output_price_per_1m: 0,
|
||||||
}]
|
}]
|
||||||
|
imageOutputPrices.value = {}
|
||||||
|
imageOutputPriceDefault.value = ''
|
||||||
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
cacheManuallySet[0] = { creation: false, read: false, cache1h: false }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -367,7 +430,7 @@ function syncToParent() {
|
|||||||
return tier
|
return tier
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('update:modelValue', { tiers })
|
emit('update:modelValue', buildPricingConfig(tiers))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取最终提交的数据(包含自动计算的缓存价格)
|
// 获取最终提交的数据(包含自动计算的缓存价格)
|
||||||
@@ -406,11 +469,90 @@ function getFinalTiers(): PricingTier[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFinalPricing(): TieredPricingConfig {
|
||||||
|
return buildPricingConfig(getFinalTiers())
|
||||||
|
}
|
||||||
|
|
||||||
// 暴露给父组件调用
|
// 暴露给父组件调用
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getFinalTiers,
|
getFinalTiers,
|
||||||
|
getFinalPricing,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
|
||||||
|
const config: TieredPricingConfig = { tiers }
|
||||||
|
const matrix = normalizedImageOutputPrices()
|
||||||
|
if (Object.keys(matrix).length > 0) {
|
||||||
|
config.image_output_prices = matrix
|
||||||
|
}
|
||||||
|
const defaultPrice = parseOptionalFloat(imageOutputPriceDefault.value)
|
||||||
|
if (defaultPrice != null) {
|
||||||
|
config.image_output_price_default = defaultPrice
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneImageOutputPrices(value: TieredPricingConfig['image_output_prices']): Record<string, Record<string, number | undefined>> {
|
||||||
|
const out: Record<string, Record<string, number | undefined>> = {}
|
||||||
|
if (!value || typeof value !== 'object') return out
|
||||||
|
for (const [size, prices] of Object.entries(value)) {
|
||||||
|
if (!prices || typeof prices !== 'object') continue
|
||||||
|
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||||
|
const price = (prices as Record<string, unknown>)[quality]
|
||||||
|
if (typeof price === 'number' && Number.isFinite(price)) {
|
||||||
|
out[size] = { ...(out[size] || {}), [quality]: price }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedImageOutputPrices(): Record<string, Record<string, number>> {
|
||||||
|
const out: Record<string, Record<string, number>> = {}
|
||||||
|
for (const [size, prices] of Object.entries(imageOutputPrices.value)) {
|
||||||
|
for (const quality of IMAGE_OUTPUT_QUALITIES) {
|
||||||
|
const price = prices[quality]
|
||||||
|
if (price != null && Number.isFinite(price)) {
|
||||||
|
out[size] = { ...(out[size] || {}), [quality]: price }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalFloat(value: string | number): number | null {
|
||||||
|
if (value === '' || value === null || value === undefined) return null
|
||||||
|
const number = typeof value === 'string' ? parseFloat(value) : value
|
||||||
|
return Number.isFinite(number) ? number : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageOutputPrice(size: string, quality: ImageOutputQuality): string | number {
|
||||||
|
return imageOutputPrices.value[size]?.[quality] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageOutputPrice(size: string, quality: ImageOutputQuality, value: string | number) {
|
||||||
|
const price = parseOptionalFloat(value)
|
||||||
|
const current = { ...(imageOutputPrices.value[size] || {}) }
|
||||||
|
if (price == null) {
|
||||||
|
delete current[quality]
|
||||||
|
} else {
|
||||||
|
current[quality] = price
|
||||||
|
}
|
||||||
|
if (Object.values(current).some(v => v != null)) {
|
||||||
|
imageOutputPrices.value = { ...imageOutputPrices.value, [size]: current }
|
||||||
|
} else {
|
||||||
|
const next = { ...imageOutputPrices.value }
|
||||||
|
delete next[size]
|
||||||
|
imageOutputPrices.value = next
|
||||||
|
}
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImageOutputPriceDefault(value: string | number) {
|
||||||
|
imageOutputPriceDefault.value = String(value ?? '')
|
||||||
|
syncToParent()
|
||||||
|
}
|
||||||
|
|
||||||
function parseFloatInput(value: string | number): number {
|
function parseFloatInput(value: string | number): number {
|
||||||
const num = typeof value === 'string' ? parseFloat(value) : value
|
const num = typeof value === 'string' ? parseFloat(value) : value
|
||||||
return isNaN(num) ? 0 : num
|
return isNaN(num) ? 0 : num
|
||||||
|
|||||||
@@ -708,8 +708,7 @@ async function handleSubmit() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
// 获取包含自动计算缓存价格的最终数据
|
// 获取包含自动计算缓存价格的最终数据
|
||||||
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
|
const finalTieredPricing = tieredPricingEditorRef.value?.getFinalPricing() ?? tieredPricing.value
|
||||||
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
|
|
||||||
|
|
||||||
// Apply billing (video) pricing into config.
|
// Apply billing (video) pricing into config.
|
||||||
applyVideoPricingToConfig(form.value.config)
|
applyVideoPricingToConfig(form.value.config)
|
||||||
|
|||||||
Reference in New Issue
Block a user