mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(billing): add image output pricing and usage tracking
This commit is contained in:
@@ -33,12 +33,14 @@ impl DefaultBillingRuleGenerator {
|
|||||||
let image_output_price_entries = explicit_image_output_price_entries(pricing_config)
|
let image_output_price_entries = explicit_image_output_price_entries(pricing_config)
|
||||||
.filter(|entries| !entries.is_empty())
|
.filter(|entries| !entries.is_empty())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let image_output_price_default =
|
let explicit_image_output_price_default = explicit_image_output_price_default(pricing_config);
|
||||||
explicit_image_output_price_default(pricing_config).unwrap_or(0.0);
|
let image_output_price_default = explicit_image_output_price_default.unwrap_or(0.0);
|
||||||
|
let has_image_output_pricing =
|
||||||
|
!image_output_price_entries.is_empty() || 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()
|
||||||
&& image_output_price_entries.is_empty()
|
&& !has_image_output_pricing
|
||||||
{
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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 {
|
||||||
@@ -43,7 +44,7 @@ impl BillingService {
|
|||||||
rule_name: None,
|
rule_name: None,
|
||||||
scope: None,
|
scope: None,
|
||||||
expression: None,
|
expression: None,
|
||||||
resolved_dimensions: build_dimensions(input),
|
resolved_dimensions: build_dimensions(input, image_output_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,
|
||||||
@@ -62,7 +63,7 @@ impl BillingService {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let dims = build_dimensions(input);
|
let dims = build_dimensions(input, image_output_pricing);
|
||||||
let result = self.engine.evaluate(
|
let result = self.engine.evaluate(
|
||||||
&rule.expression,
|
&rule.expression,
|
||||||
Some(&rule.variables),
|
Some(&rule.variables),
|
||||||
@@ -123,7 +124,10 @@ impl Default for BillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
fn build_dimensions(
|
||||||
|
input: &BillingUsageInput,
|
||||||
|
image_output_pricing: ImageOutputPricingState,
|
||||||
|
) -> 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(),
|
||||||
input.input_tokens,
|
input.input_tokens,
|
||||||
@@ -173,10 +177,28 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
("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(),
|
"image_count_unmetered".to_string(),
|
||||||
json!(if input.output_tokens > 0 {
|
json!(if image_output_pricing.enabled {
|
||||||
0
|
|
||||||
} else {
|
|
||||||
input.image_count.max(0)
|
input.image_count.max(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_output_pricing_enabled".to_string(),
|
||||||
|
json!(image_output_pricing.enabled),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_output_matrix_enabled".to_string(),
|
||||||
|
json!(image_output_pricing.matrix_enabled),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"image_output_pricing_mode".to_string(),
|
||||||
|
json!(if image_output_pricing.matrix_enabled {
|
||||||
|
"matrix"
|
||||||
|
} else if image_output_pricing.enabled {
|
||||||
|
"per_image"
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -247,6 +269,66 @@ fn build_dimensions(input: &BillingUsageInput) -> BTreeMap<String, Value> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct ImageOutputPricingState {
|
||||||
|
enabled: bool,
|
||||||
|
matrix_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_output_pricing_state(pricing: &BillingModelPricingSnapshot) -> ImageOutputPricingState {
|
||||||
|
let matrix_enabled = pricing_has_image_output_matrix(pricing);
|
||||||
|
let default_enabled = pricing_has_image_output_default_price(pricing);
|
||||||
|
ImageOutputPricingState {
|
||||||
|
enabled: matrix_enabled || default_enabled,
|
||||||
|
matrix_enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pricing_has_image_output_matrix(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||||
|
let Some(config) = pricing.effective_tiered_pricing() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
[
|
||||||
|
"image_output_prices",
|
||||||
|
"image_output_price_per_image",
|
||||||
|
"image_output_price_matrix",
|
||||||
|
"image_prices",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|key| {
|
||||||
|
config
|
||||||
|
.get(key)
|
||||||
|
.is_some_and(image_price_entries_have_matrix_values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pricing_has_image_output_default_price(pricing: &BillingModelPricingSnapshot) -> bool {
|
||||||
|
let Some(config) = pricing.effective_tiered_pricing() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
config
|
||||||
|
.get("image_output_price_default")
|
||||||
|
.or_else(|| config.get("image_price_default"))
|
||||||
|
.or_else(|| {
|
||||||
|
config
|
||||||
|
.get("image_output_prices")
|
||||||
|
.and_then(|value| value.get("default"))
|
||||||
|
})
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_price_entries_have_matrix_values(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Object(object) => object.iter().any(|(key, value)| {
|
||||||
|
!key.eq_ignore_ascii_case("default")
|
||||||
|
&& (value.as_f64().is_some() || image_price_entries_have_matrix_values(value))
|
||||||
|
}),
|
||||||
|
Value::Array(items) => items.iter().any(image_price_entries_have_matrix_values),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn now_marker() -> String {
|
fn now_marker() -> String {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -362,6 +444,227 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_token_usage_without_image_output_price_bills_tokens_only() {
|
||||||
|
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
|
||||||
|
}]
|
||||||
|
})),
|
||||||
|
..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("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
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_pricing_mode"),
|
||||||
|
Some(&json!("none"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_count_unmetered"),
|
||||||
|
Some(&json!(0))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.0)
|
||||||
|
);
|
||||||
|
assert_eq!(result.cost_result.cost, 0.041);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_default_output_price_adds_image_cost_even_with_token_usage() {
|
||||||
|
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.05
|
||||||
|
})),
|
||||||
|
..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("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
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_pricing_mode"),
|
||||||
|
Some(&json!("per_image"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.05)
|
||||||
|
);
|
||||||
|
assert_eq!(result.cost_result.cost, 0.091);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_default_output_price_generates_rule_without_token_tiers() {
|
||||||
|
let pricing = BillingModelPricingSnapshot {
|
||||||
|
default_price_per_request: None,
|
||||||
|
default_tiered_pricing: Some(json!({
|
||||||
|
"image_output_price_default": 0.05
|
||||||
|
})),
|
||||||
|
..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
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.1)
|
||||||
|
);
|
||||||
|
assert_eq!(result.cost_result.cost, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn image_token_usage_with_matrix_adds_matrix_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_prices": {
|
||||||
|
"1024x1024": { "medium": 0.05 }
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
..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("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
|
||||||
|
.snapshot
|
||||||
|
.resolved_dimensions
|
||||||
|
.get("image_output_pricing_mode"),
|
||||||
|
Some(&json!("matrix"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.cost_result
|
||||||
|
.snapshot
|
||||||
|
.cost_breakdown
|
||||||
|
.get("image_output_cost"),
|
||||||
|
Some(&0.05)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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 {
|
||||||
|
|||||||
@@ -33,10 +33,15 @@ impl UsageMapper {
|
|||||||
|
|
||||||
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
|
pub fn map_from_response(response: &serde_json::Value, api_format: &str) -> StandardizedUsage {
|
||||||
let family = api_family(api_format);
|
let family = api_family(api_format);
|
||||||
let Some(usage_value) = resolve_usage_value(response, family.as_str()) else {
|
let mut usage = if let Some(usage_value) = resolve_usage_value(response, family.as_str()) {
|
||||||
return StandardizedUsage::new();
|
Self::map(usage_value, api_format, None)
|
||||||
|
} else {
|
||||||
|
StandardizedUsage::new()
|
||||||
};
|
};
|
||||||
Self::map(usage_value, api_format, None)
|
if is_openai_image_api(api_format) {
|
||||||
|
apply_openai_image_response_dimensions(response, &mut usage);
|
||||||
|
}
|
||||||
|
usage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +65,53 @@ fn api_family(api_format: &str) -> String {
|
|||||||
.to_ascii_lowercase()
|
.to_ascii_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn api_kind(api_format: &str) -> String {
|
||||||
|
api_format
|
||||||
|
.split(':')
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_openai_image_api(api_format: &str) -> bool {
|
||||||
|
api_family(api_format).as_str() == "openai" && api_kind(api_format).as_str() == "image"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_openai_image_response_dimensions(
|
||||||
|
response: &serde_json::Value,
|
||||||
|
usage: &mut StandardizedUsage,
|
||||||
|
) {
|
||||||
|
let image_count = openai_image_response_image_count(response);
|
||||||
|
if image_count <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.request_count = image_count;
|
||||||
|
usage
|
||||||
|
.dimensions
|
||||||
|
.insert("image_count".to_string(), serde_json::json!(image_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openai_image_response_image_count(response: &serde_json::Value) -> i64 {
|
||||||
|
response
|
||||||
|
.get("data")
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.map(|items| items.len() as i64)
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.or_else(|| image_result_count(response.get("result")))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_result_count(value: Option<&serde_json::Value>) -> Option<i64> {
|
||||||
|
match value? {
|
||||||
|
serde_json::Value::Array(items) => Some(items.len() as i64).filter(|count| *count > 0),
|
||||||
|
serde_json::Value::Object(object) if !object.is_empty() => Some(1),
|
||||||
|
serde_json::Value::String(text) if !text.trim().is_empty() => Some(1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
fn base_mapping(api_format: &str) -> BTreeMap<String, String> {
|
||||||
let mut mapping = BTreeMap::new();
|
let mut mapping = BTreeMap::new();
|
||||||
match api_family(api_format).as_str() {
|
match api_family(api_format).as_str() {
|
||||||
@@ -620,4 +672,41 @@ mod tests {
|
|||||||
assert_eq!(usage.output_tokens, 6);
|
assert_eq!(usage.output_tokens, 6);
|
||||||
assert_eq!(usage.cache_read_tokens, 2);
|
assert_eq!(usage.cache_read_tokens, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_openai_image_response_dimensions_without_usage() {
|
||||||
|
let usage = map_usage_from_response(
|
||||||
|
&serde_json::json!({
|
||||||
|
"created": 1_700_000_000,
|
||||||
|
"data": [
|
||||||
|
{ "b64_json": "abc" },
|
||||||
|
{ "url": "https://example.test/image.png" }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
"openai:image",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(usage.request_count, 2);
|
||||||
|
assert_eq!(usage.dimensions.get("image_count"), Some(&serde_json::json!(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_openai_image_response_dimensions_with_native_usage() {
|
||||||
|
let usage = map_usage_from_response(
|
||||||
|
&serde_json::json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 11,
|
||||||
|
"output_tokens": 22,
|
||||||
|
"total_tokens": 33
|
||||||
|
},
|
||||||
|
"data": [{ "b64_json": "abc" }]
|
||||||
|
}),
|
||||||
|
"openai:image",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(usage.input_tokens, 11);
|
||||||
|
assert_eq!(usage.output_tokens, 22);
|
||||||
|
assert_eq!(usage.request_count, 1);
|
||||||
|
assert_eq!(usage.dimensions.get("image_count"), Some(&serde_json::json!(1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2298,6 +2298,7 @@ fn apply_completed_image_usage_estimate(data: &mut UsageEventData) {
|
|||||||
if !usage_event_data_is_image(data) {
|
if !usage_event_data_is_image(data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
apply_completed_image_dimensions(data);
|
||||||
if data
|
if data
|
||||||
.response_body
|
.response_body
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -2327,6 +2328,110 @@ fn apply_completed_image_usage_estimate(data: &mut UsageEventData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_completed_image_dimensions(data: &mut UsageEventData) {
|
||||||
|
let image_count = usage_dimension_i64(data.request_metadata.as_ref(), "image_count")
|
||||||
|
.or_else(|| image_response_count(data.response_body.as_ref()))
|
||||||
|
.or_else(|| image_request_count(data.provider_request_body.as_ref()))
|
||||||
|
.or_else(|| image_request_count(data.request_body.as_ref()));
|
||||||
|
|
||||||
|
if let Some(image_count) = image_count.filter(|value| *value > 0) {
|
||||||
|
set_usage_dimension_if_absent(data, "image_count", json!(image_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (dimension, request_key) in [
|
||||||
|
("image_size", "size"),
|
||||||
|
("image_quality", "quality"),
|
||||||
|
("image_output_format", "output_format"),
|
||||||
|
] {
|
||||||
|
if usage_dimension_string(data.request_metadata.as_ref(), dimension).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(value) = image_request_string(data.provider_request_body.as_ref(), request_key)
|
||||||
|
.or_else(|| image_request_string(data.request_body.as_ref(), request_key))
|
||||||
|
{
|
||||||
|
set_usage_dimension_if_absent(data, dimension, json!(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_dimension_i64(metadata: Option<&Value>, key: &str) -> Option<i64> {
|
||||||
|
metadata
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get("dimensions"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get(key))
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_dimension_string(metadata: Option<&Value>, key: &str) -> Option<String> {
|
||||||
|
metadata
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get("dimensions"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|object| object.get(key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_usage_dimension_if_absent(data: &mut UsageEventData, key: &str, value: Value) {
|
||||||
|
let mut metadata = match data.request_metadata.take() {
|
||||||
|
Some(Value::Object(object)) => object,
|
||||||
|
_ => Map::new(),
|
||||||
|
};
|
||||||
|
let mut dimensions = match metadata.remove("dimensions") {
|
||||||
|
Some(Value::Object(object)) => object,
|
||||||
|
_ => Map::new(),
|
||||||
|
};
|
||||||
|
dimensions.entry(key.to_string()).or_insert(value);
|
||||||
|
metadata.insert("dimensions".to_string(), Value::Object(dimensions));
|
||||||
|
data.request_metadata = Some(Value::Object(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_response_count(value: Option<&Value>) -> Option<i64> {
|
||||||
|
let value = value?;
|
||||||
|
value
|
||||||
|
.get("data")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| items.len() as i64)
|
||||||
|
.filter(|count| *count > 0)
|
||||||
|
.or_else(|| image_result_count(value.get("result")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_result_count(value: Option<&Value>) -> Option<i64> {
|
||||||
|
match value? {
|
||||||
|
Value::Array(items) => Some(items.len() as i64).filter(|count| *count > 0),
|
||||||
|
Value::Object(object) if !object.is_empty() => Some(1),
|
||||||
|
Value::String(text) if !text.trim().is_empty() => Some(1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_request_count(value: Option<&Value>) -> Option<i64> {
|
||||||
|
value
|
||||||
|
.and_then(|value| value.get("n"))
|
||||||
|
.and_then(|value| {
|
||||||
|
value
|
||||||
|
.as_i64()
|
||||||
|
.or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok()))
|
||||||
|
})
|
||||||
|
.filter(|count| *count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_request_string(value: Option<&Value>, key: &str) -> Option<String> {
|
||||||
|
value
|
||||||
|
.and_then(|value| value.get(key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
fn usage_event_data_is_image(data: &UsageEventData) -> bool {
|
fn usage_event_data_is_image(data: &UsageEventData) -> bool {
|
||||||
data.request_type
|
data.request_type
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -3791,6 +3896,144 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_completed_openai_image_usage_infers_image_dimensions_from_response() {
|
||||||
|
let request_body = json!({
|
||||||
|
"model": "gpt-image-2",
|
||||||
|
"prompt": "draw a small red cube on a clean desk",
|
||||||
|
"size": "1024x1024",
|
||||||
|
"quality": "medium",
|
||||||
|
"output_format": "png"
|
||||||
|
});
|
||||||
|
let plan = ExecutionPlan {
|
||||||
|
request_id: "req-image-sync-completed-dimensions-1".to_string(),
|
||||||
|
candidate_id: Some("cand-image-sync-completed-dimensions-1".to_string()),
|
||||||
|
provider_name: Some("OpenAI".to_string()),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
endpoint_id: "endpoint-1".to_string(),
|
||||||
|
key_id: "key-1".to_string(),
|
||||||
|
method: "POST".to_string(),
|
||||||
|
url: "https://example.com/v1/images/generations".to_string(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
content_encoding: None,
|
||||||
|
body: RequestBody::from_json(request_body.clone()),
|
||||||
|
stream: false,
|
||||||
|
client_api_format: "openai:image".to_string(),
|
||||||
|
provider_api_format: "openai:image".to_string(),
|
||||||
|
model_name: Some("gpt-image-2".to_string()),
|
||||||
|
proxy: None,
|
||||||
|
transport_profile: None,
|
||||||
|
timeouts: None,
|
||||||
|
};
|
||||||
|
let payload = GatewaySyncReportRequest {
|
||||||
|
trace_id: "trace-image-sync-completed-dimensions-1".to_string(),
|
||||||
|
report_kind: "openai_image_sync_success".to_string(),
|
||||||
|
report_context: Some(json!({
|
||||||
|
"client_api_format": "openai:image",
|
||||||
|
"provider_api_format": "openai:image",
|
||||||
|
"provider_request_body": request_body
|
||||||
|
})),
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body_json: Some(json!({
|
||||||
|
"created": 1_700_000_000,
|
||||||
|
"data": [{ "b64_json": "abc" }]
|
||||||
|
})),
|
||||||
|
client_body_json: None,
|
||||||
|
body_base64: None,
|
||||||
|
telemetry: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let event =
|
||||||
|
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||||
|
.expect("usage event should build");
|
||||||
|
|
||||||
|
let dimensions = event
|
||||||
|
.data
|
||||||
|
.request_metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| metadata.get("dimensions"))
|
||||||
|
.expect("dimensions should exist");
|
||||||
|
assert_eq!(event.event_type, UsageEventType::Completed);
|
||||||
|
assert!(event.data.input_tokens.unwrap_or_default() > 0);
|
||||||
|
assert_eq!(dimensions.get("image_count"), Some(&json!(1)));
|
||||||
|
assert_eq!(dimensions.get("image_size"), Some(&json!("1024x1024")));
|
||||||
|
assert_eq!(dimensions.get("image_quality"), Some(&json!("medium")));
|
||||||
|
assert_eq!(dimensions.get("image_output_format"), Some(&json!("png")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_completed_openai_image_usage_preserves_native_usage_with_image_count() {
|
||||||
|
let request_body = json!({
|
||||||
|
"model": "gpt-image-2",
|
||||||
|
"prompt": "draw a small red cube on a clean desk",
|
||||||
|
"size": "1024x1024",
|
||||||
|
"quality": "medium"
|
||||||
|
});
|
||||||
|
let plan = ExecutionPlan {
|
||||||
|
request_id: "req-image-sync-native-usage-1".to_string(),
|
||||||
|
candidate_id: Some("cand-image-sync-native-usage-1".to_string()),
|
||||||
|
provider_name: Some("OpenAI".to_string()),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
endpoint_id: "endpoint-1".to_string(),
|
||||||
|
key_id: "key-1".to_string(),
|
||||||
|
method: "POST".to_string(),
|
||||||
|
url: "https://example.com/v1/images/generations".to_string(),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
content_type: Some("application/json".to_string()),
|
||||||
|
content_encoding: None,
|
||||||
|
body: RequestBody::from_json(request_body.clone()),
|
||||||
|
stream: false,
|
||||||
|
client_api_format: "openai:image".to_string(),
|
||||||
|
provider_api_format: "openai:image".to_string(),
|
||||||
|
model_name: Some("gpt-image-2".to_string()),
|
||||||
|
proxy: None,
|
||||||
|
transport_profile: None,
|
||||||
|
timeouts: None,
|
||||||
|
};
|
||||||
|
let payload = GatewaySyncReportRequest {
|
||||||
|
trace_id: "trace-image-sync-native-usage-1".to_string(),
|
||||||
|
report_kind: "openai_image_sync_success".to_string(),
|
||||||
|
report_context: Some(json!({
|
||||||
|
"client_api_format": "openai:image",
|
||||||
|
"provider_api_format": "openai:image",
|
||||||
|
"provider_request_body": request_body
|
||||||
|
})),
|
||||||
|
status_code: 200,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
body_json: Some(json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 11,
|
||||||
|
"output_tokens": 22,
|
||||||
|
"total_tokens": 33
|
||||||
|
},
|
||||||
|
"data": [{ "b64_json": "abc" }]
|
||||||
|
})),
|
||||||
|
client_body_json: None,
|
||||||
|
body_base64: None,
|
||||||
|
telemetry: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let event =
|
||||||
|
build_sync_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
|
||||||
|
.expect("usage event should build");
|
||||||
|
|
||||||
|
assert_eq!(event.event_type, UsageEventType::Completed);
|
||||||
|
assert_eq!(event.data.input_tokens, Some(11));
|
||||||
|
assert_eq!(event.data.output_tokens, Some(22));
|
||||||
|
assert_eq!(event.data.total_tokens, Some(33));
|
||||||
|
assert_eq!(
|
||||||
|
event
|
||||||
|
.data
|
||||||
|
.request_metadata
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|metadata| metadata.get("dimensions"))
|
||||||
|
.and_then(|dimensions| dimensions.get("image_count")),
|
||||||
|
Some(&json!(1))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_terminal_usage_prefers_more_complete_provider_chunks_usage() {
|
fn stream_terminal_usage_prefers_more_complete_provider_chunks_usage() {
|
||||||
let plan = ExecutionPlan {
|
let plan = ExecutionPlan {
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export interface RequestDetail {
|
|||||||
total_cost?: number
|
total_cost?: number
|
||||||
cache_creation_cost?: number
|
cache_creation_cost?: number
|
||||||
cache_read_cost?: number
|
cache_read_cost?: number
|
||||||
|
image_output_cost?: number
|
||||||
request_cost?: number // 按次计费费用
|
request_cost?: number // 按次计费费用
|
||||||
// Historical pricing fields (per 1M tokens)
|
// Historical pricing fields (per 1M tokens)
|
||||||
input_price_per_1m?: number
|
input_price_per_1m?: number
|
||||||
|
|||||||
@@ -203,6 +203,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-start gap-2 border-t border-border/60 pt-3">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="isImageGenerationEnabled"
|
||||||
|
class="mt-0.5"
|
||||||
|
@update:model-value="setImageGenerationEnabled"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
图片模型
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -215,6 +230,7 @@
|
|||||||
ref="tieredPricingEditorRef"
|
ref="tieredPricingEditorRef"
|
||||||
v-model="tieredPricing"
|
v-model="tieredPricing"
|
||||||
:show-cache1h="true"
|
:show-cache1h="true"
|
||||||
|
:show-image-pricing="isImageGenerationEnabled"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-3 pt-2 border-t">
|
<div class="flex items-center gap-3 pt-2 border-t">
|
||||||
<Label class="text-xs whitespace-nowrap">按次计费</Label>
|
<Label class="text-xs whitespace-nowrap">按次计费</Label>
|
||||||
@@ -534,6 +550,14 @@ const isEmbeddingEnabled = computed(() => {
|
|||||||
|| form.value.config?.model_type === 'embedding'
|
|| form.value.config?.model_type === 'embedding'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isImageGenerationEnabled = computed(() => {
|
||||||
|
return form.value.supported_capabilities?.includes('image_generation') === true
|
||||||
|
|| form.value.config?.image_generation === true
|
||||||
|
|| form.value.config?.model_type === 'image'
|
||||||
|
|| (Array.isArray(form.value.config?.api_formats)
|
||||||
|
&& form.value.config.api_formats.some((format) => String(format).endsWith(':image')))
|
||||||
|
})
|
||||||
|
|
||||||
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
|
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
|
||||||
|
|
||||||
// 设置 config 字段
|
// 设置 config 字段
|
||||||
@@ -576,6 +600,20 @@ function setEmbeddingEnabled(enabled: boolean) {
|
|||||||
form.value.supported_capabilities = [...caps]
|
form.value.supported_capabilities = [...caps]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
||||||
|
const enabled = value === true
|
||||||
|
const caps = new Set(form.value.supported_capabilities || [])
|
||||||
|
if (enabled) {
|
||||||
|
caps.add('image_generation')
|
||||||
|
setConfigField('image_generation', true)
|
||||||
|
} else {
|
||||||
|
caps.delete('image_generation')
|
||||||
|
setConfigField('image_generation', undefined)
|
||||||
|
if (form.value.config?.model_type === 'image') setConfigField('model_type', undefined)
|
||||||
|
}
|
||||||
|
form.value.supported_capabilities = [...caps]
|
||||||
|
}
|
||||||
|
|
||||||
function getNested(obj: unknown, path: string): unknown {
|
function getNested(obj: unknown, path: string): unknown {
|
||||||
if (!obj || typeof obj !== 'object') return undefined
|
if (!obj || typeof obj !== 'object') return undefined
|
||||||
const parts = path.split('.').filter(Boolean)
|
const parts = path.split('.').filter(Boolean)
|
||||||
@@ -753,7 +791,10 @@ function selectModel(model: ModelsDevModelItem) {
|
|||||||
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
|
||||||
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
|
||||||
form.value.config = config
|
form.value.config = config
|
||||||
form.value.supported_capabilities = model.supportsEmbedding ? ['embedding'] : []
|
const supportedCapabilities = new Set<string>()
|
||||||
|
if (model.supportsEmbedding) supportedCapabilities.add('embedding')
|
||||||
|
if (model.outputModalities?.includes('image')) supportedCapabilities.add('image_generation')
|
||||||
|
form.value.supported_capabilities = [...supportedCapabilities]
|
||||||
if (model.supportsEmbedding) {
|
if (model.supportsEmbedding) {
|
||||||
setEmbeddingEnabled(true)
|
setEmbeddingEnabled(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,10 @@
|
|||||||
添加价格阶梯
|
添加价格阶梯
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div class="rounded-lg border bg-muted/10 p-3 space-y-3">
|
<div
|
||||||
|
v-if="showImagePricing"
|
||||||
|
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">
|
||||||
@@ -211,6 +214,7 @@ const IMAGE_OUTPUT_QUALITIES: ImageOutputQuality[] = ['low', 'medium', 'high']
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: TieredPricingConfig | null
|
modelValue?: TieredPricingConfig | null
|
||||||
showCache1h?: boolean
|
showCache1h?: boolean
|
||||||
|
showImagePricing?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -481,6 +485,9 @@ defineExpose({
|
|||||||
|
|
||||||
function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
|
function buildPricingConfig(tiers: PricingTier[]): TieredPricingConfig {
|
||||||
const config: TieredPricingConfig = { tiers }
|
const config: TieredPricingConfig = { tiers }
|
||||||
|
if (!props.showImagePricing) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
const matrix = normalizedImageOutputPrices()
|
const matrix = normalizedImageOutputPrices()
|
||||||
if (Object.keys(matrix).length > 0) {
|
if (Object.keys(matrix).length > 0) {
|
||||||
config.image_output_prices = matrix
|
config.image_output_prices = matrix
|
||||||
|
|||||||
@@ -126,6 +126,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="isImageGenerationEnabled"
|
||||||
|
class="mt-0.5"
|
||||||
|
@update:model-value="setImageGenerationEnabled"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
图片模型
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
启用图片输出计费,并展开尺寸 × 质量矩阵价格。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 价格配置 -->
|
<!-- 价格配置 -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h4 class="font-semibold text-sm border-b pb-2">
|
<h4 class="font-semibold text-sm border-b pb-2">
|
||||||
@@ -135,6 +153,7 @@
|
|||||||
ref="tieredPricingEditorRef"
|
ref="tieredPricingEditorRef"
|
||||||
v-model="tieredPricing"
|
v-model="tieredPricing"
|
||||||
:show-cache1h="showCache1h"
|
:show-cache1h="showCache1h"
|
||||||
|
:show-image-pricing="isImageGenerationEnabled"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 按次计费 -->
|
<!-- 按次计费 -->
|
||||||
@@ -281,6 +300,7 @@ import {
|
|||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
Badge,
|
Badge,
|
||||||
|
Checkbox,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
import { parseNumberInput, sortResolutionEntries } from '@/utils/form'
|
||||||
@@ -322,10 +342,24 @@ const selectedGlobalModel = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
|
const selectedGlobalModelSupportsEmbedding = computed(() => modelSupportsEmbedding(selectedGlobalModel.value))
|
||||||
|
const selectedGlobalModelSupportsImageGeneration = computed(() => modelSupportsImageGeneration(selectedGlobalModel.value))
|
||||||
const editingModelSupportsEmbedding = computed(() => {
|
const editingModelSupportsEmbedding = computed(() => {
|
||||||
return props.editingModel?.effective_supports_embedding === true
|
return props.editingModel?.effective_supports_embedding === true
|
||||||
|| modelSupportsEmbedding(props.editingModel)
|
|| modelSupportsEmbedding(props.editingModel)
|
||||||
})
|
})
|
||||||
|
const editingModelSupportsImageGeneration = computed(() => {
|
||||||
|
return props.editingModel?.effective_supports_image_generation === true
|
||||||
|
|| modelSupportsImageGeneration(props.editingModel)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isImageGenerationEnabled = computed(() => {
|
||||||
|
if (form.value.supports_image_generation !== undefined) {
|
||||||
|
return form.value.supports_image_generation === true
|
||||||
|
}
|
||||||
|
return isEditing.value
|
||||||
|
? editingModelSupportsImageGeneration.value
|
||||||
|
: selectedGlobalModelSupportsImageGeneration.value
|
||||||
|
})
|
||||||
|
|
||||||
// 1h 缓存定价始终显示
|
// 1h 缓存定价始终显示
|
||||||
const showCache1h = true
|
const showCache1h = true
|
||||||
@@ -506,6 +540,26 @@ function syncManualProviderName(value: string | number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelSupportsImageGeneration(model: {
|
||||||
|
supported_capabilities?: string[] | null
|
||||||
|
supports_image_generation?: boolean | null
|
||||||
|
effective_supports_image_generation?: boolean | null
|
||||||
|
config?: Record<string, unknown> | null
|
||||||
|
} | null | undefined): boolean {
|
||||||
|
if (!model) return false
|
||||||
|
if (model.effective_supports_image_generation === true) return true
|
||||||
|
if (model.supports_image_generation === true) return true
|
||||||
|
const config = model.config || {}
|
||||||
|
return model.supported_capabilities?.includes('image_generation') === true
|
||||||
|
|| config.image_generation === true
|
||||||
|
|| config.model_type === 'image'
|
||||||
|
|| (Array.isArray(config.api_formats) && config.api_formats.some((format) => String(format).endsWith(':image')))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImageGenerationEnabled(value: boolean | 'indeterminate') {
|
||||||
|
form.value.supports_image_generation = value === true
|
||||||
|
}
|
||||||
|
|
||||||
function getNested(obj: Record<string, unknown>, path: string): unknown {
|
function getNested(obj: Record<string, unknown>, path: string): unknown {
|
||||||
if (!obj || typeof obj !== 'object') return undefined
|
if (!obj || typeof obj !== 'object') return undefined
|
||||||
const parts = path.split('.').filter(Boolean)
|
const parts = path.split('.').filter(Boolean)
|
||||||
|
|||||||
@@ -234,6 +234,9 @@
|
|||||||
<template v-if="perRequestCost > 0">
|
<template v-if="perRequestCost > 0">
|
||||||
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
+ 按次费用 <span class="font-medium">${{ perRequestCost.toFixed(6) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="imageOutputCostTotal > 0">
|
||||||
|
+ 图片输出费用 <span class="font-medium">${{ imageOutputCostTotal.toFixed(6) }}</span>
|
||||||
|
</template>
|
||||||
<template v-if="videoCostTotal > 0">
|
<template v-if="videoCostTotal > 0">
|
||||||
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
|
+ {{ detail.video_billing?.task_type === 'image' ? '图像' : detail.video_billing?.task_type === 'audio' ? '音频' : '视频' }}费用 <span class="font-medium">${{ videoCostTotal.toFixed(6) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -385,7 +388,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== 4. 视频/图像/音频计费(独立隔离,与Token计费风格一致) ========== -->
|
<!-- ========== 4. 图片输出计费 ========== -->
|
||||||
|
<div
|
||||||
|
v-if="hasImageBillingDetail"
|
||||||
|
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30 mb-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 text-xs">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="font-medium text-primary">图片输出</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="text-[10px] px-1.5 py-0 h-4"
|
||||||
|
>
|
||||||
|
{{ imageOutputBillingLabel }}
|
||||||
|
</Badge>
|
||||||
|
<span
|
||||||
|
v-if="imageOutputMatrixEnabled && imagePriceKey"
|
||||||
|
class="text-muted-foreground font-mono"
|
||||||
|
>{{ imagePriceKey }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="imageOutputSize || imageOutputQuality"
|
||||||
|
class="text-muted-foreground font-mono"
|
||||||
|
>{{ [imageOutputSize, imageOutputQuality].filter(Boolean).join(' / ') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||||
|
<span
|
||||||
|
v-if="imageOutputPricePerImage !== null"
|
||||||
|
class="font-mono"
|
||||||
|
>{{ formatNumber(imageOutputCount) }} 张 × ${{ imageOutputPricePerImage.toFixed(6) }}/张 = ${{ imageOutputCostTotal.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<span class="text-xs text-muted-foreground w-[56px]">数量</span>
|
||||||
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ formatNumber(imageOutputCount) }}</span>
|
||||||
|
<span class="text-xs font-mono">${{ imageOutputCostTotal.toFixed(6) }}</span>
|
||||||
|
</div>
|
||||||
|
<Separator
|
||||||
|
orientation="vertical"
|
||||||
|
class="h-4 mx-4"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<span class="text-xs text-muted-foreground w-[56px]">格式</span>
|
||||||
|
<span class="text-sm font-semibold font-mono flex-1 text-center">{{ imageOutputFormat || '-' }}</span>
|
||||||
|
<span class="text-xs font-mono text-muted-foreground">{{ imageOutputBillingLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========== 5. 视频/图像/音频计费(独立隔离,与Token计费风格一致) ========== -->
|
||||||
<div
|
<div
|
||||||
v-if="detail.video_billing"
|
v-if="detail.video_billing"
|
||||||
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
|
class="rounded-lg p-3 space-y-2 bg-primary/5 border border-primary/30"
|
||||||
@@ -901,6 +953,11 @@ function getNestedNumber(record: JsonRecord | null, ...path: string[]): number |
|
|||||||
return toNumber(getNestedValue(record, ...path))
|
return toNumber(getNestedValue(record, ...path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNestedString(record: JsonRecord | null, ...path: string[]): string | null {
|
||||||
|
const value = getNestedValue(record, ...path)
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
|
function normalizeCacheTtlPricing(value: unknown): CacheTTLPriceEntry[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
return value
|
return value
|
||||||
@@ -1036,6 +1093,10 @@ const billingResolvedVariables = computed<JsonRecord | null>(() =>
|
|||||||
asRecord(billingSnapshot.value?.resolved_variables),
|
asRecord(billingSnapshot.value?.resolved_variables),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const billingResolvedDimensions = computed<JsonRecord | null>(() =>
|
||||||
|
asRecord(billingSnapshot.value?.resolved_dimensions),
|
||||||
|
)
|
||||||
|
|
||||||
const billingCostBreakdown = computed<JsonRecord | null>(() =>
|
const billingCostBreakdown = computed<JsonRecord | null>(() =>
|
||||||
asRecord(billingSnapshot.value?.cost_breakdown),
|
asRecord(billingSnapshot.value?.cost_breakdown),
|
||||||
)
|
)
|
||||||
@@ -1377,6 +1438,74 @@ const effectiveRequestCost = computed(() => {
|
|||||||
return 0
|
return 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const effectiveImageOutputCost = computed(() =>
|
||||||
|
getNestedNumber(billingCostBreakdown.value, 'image_output_cost')
|
||||||
|
?? toNumber(detail.value?.image_output_cost)
|
||||||
|
?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputCostTotal = computed(() => effectiveImageOutputCost.value)
|
||||||
|
|
||||||
|
const imageOutputPricePerImage = computed(() =>
|
||||||
|
getNestedNumber(billingResolvedVariables.value, 'image_output_price_per_image'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputCount = computed(() =>
|
||||||
|
getNestedNumber(billingResolvedDimensions.value, 'image_count')
|
||||||
|
?? getNestedNumber(traceRequestMetadata.value, 'billing_dimensions', 'image_count')
|
||||||
|
?? getNestedNumber(traceRequestMetadata.value, 'dimensions', 'image_count')
|
||||||
|
?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputSize = computed(() =>
|
||||||
|
getNestedString(billingResolvedDimensions.value, 'image_size')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_size')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_size'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputQuality = computed(() =>
|
||||||
|
getNestedString(billingResolvedDimensions.value, 'image_quality')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_quality')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_quality'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputFormat = computed(() =>
|
||||||
|
getNestedString(billingResolvedDimensions.value, 'image_output_format')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'billing_dimensions', 'image_output_format')
|
||||||
|
?? getNestedString(traceRequestMetadata.value, 'dimensions', 'image_output_format'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imagePriceKey = computed(() => {
|
||||||
|
const snapshotKey = getNestedString(billingResolvedDimensions.value, 'image_price_key')
|
||||||
|
if (snapshotKey) return snapshotKey
|
||||||
|
const fallbackKey = [imageOutputSize.value, imageOutputQuality.value].filter(Boolean).join(':')
|
||||||
|
return fallbackKey || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageOutputPricingMode = computed(() =>
|
||||||
|
getNestedString(billingResolvedDimensions.value, 'image_output_pricing_mode'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputPricingEnabled = computed(() =>
|
||||||
|
getNestedValue(billingResolvedDimensions.value, 'image_output_pricing_enabled') === true
|
||||||
|
|| imageOutputPricingMode.value === 'matrix'
|
||||||
|
|| imageOutputPricingMode.value === 'per_image'
|
||||||
|
|| imageOutputCostTotal.value > 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputMatrixEnabled = computed(() =>
|
||||||
|
getNestedValue(billingResolvedDimensions.value, 'image_output_matrix_enabled') === true
|
||||||
|
|| imageOutputPricingMode.value === 'matrix',
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageOutputBillingLabel = computed(() =>
|
||||||
|
imageOutputMatrixEnabled.value ? '矩阵计费' : '默认计费',
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasImageBillingDetail = computed(() =>
|
||||||
|
imageOutputPricingEnabled.value && (imageOutputCount.value > 0 || imageOutputCostTotal.value > 0),
|
||||||
|
)
|
||||||
|
|
||||||
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
|
const fallbackCacheTtlPricing = computed<CacheTTLPriceEntry[]>(() => {
|
||||||
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
|
const tierPricing = normalizeCacheTtlPricing(billingTierInfo.value?.cache_ttl_pricing)
|
||||||
if (tierPricing.length > 0) return tierPricing
|
if (tierPricing.length > 0) return tierPricing
|
||||||
|
|||||||
Reference in New Issue
Block a user