修复 ChatGPT Web 生图发起即扣额度

This commit is contained in:
Codex
2026-05-24 18:50:14 +08:00
parent 14b182d09b
commit 66837b7d7f
7 changed files with 207 additions and 51 deletions
@@ -1145,9 +1145,6 @@ fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
.zip(used)
.map(|(limit, used)| (limit - used).max(0.0))
});
let Some(remaining) = remaining else {
return false;
};
let limit = chatgpt_web_image_quota_request_limit_choice(
metadata,
status_snapshot,
@@ -1155,13 +1152,22 @@ fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
snapshot_limit,
remaining,
);
if limit.is_none()
&& chatgpt_web_image_quota_metadata_limit_is_legacy_free_default(
metadata,
status_snapshot,
metadata_limit,
remaining,
)
{
metadata.remove("image_quota_total");
metadata.remove("image_quota_limit_source");
}
let limit_value = limit
.as_ref()
.map(|limit| limit.value)
.unwrap_or(remaining.max(0.0));
let new_remaining = (remaining - 1.0).max(0.0);
.unwrap_or_else(|| remaining.unwrap_or(0.0).max(0.0));
metadata.insert("image_quota_remaining".to_string(), json!(new_remaining));
if limit_value > 0.0 {
metadata.insert("image_quota_total".to_string(), json!(limit_value));
if let Some(source) = limit
@@ -1171,12 +1177,32 @@ fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
{
metadata.insert("image_quota_limit_source".to_string(), json!(source));
}
metadata.insert(
"image_quota_used".to_string(),
json!((limit_value - new_remaining).max(0.0)),
);
} else if let Some(used) = used {
metadata.insert("image_quota_used".to_string(), json!(used + 1.0));
}
match remaining {
Some(remaining) => {
let new_remaining = (remaining - 1.0).max(0.0);
metadata.insert("image_quota_remaining".to_string(), json!(new_remaining));
if limit_value > 0.0 {
metadata.insert(
"image_quota_used".to_string(),
json!((limit_value - new_remaining).max(0.0)),
);
} else if let Some(used) = used {
metadata.insert("image_quota_used".to_string(), json!(used + 1.0));
} else {
metadata.insert("image_quota_used".to_string(), json!(1.0));
}
}
None => {
let new_used = used.unwrap_or(0.0).max(0.0) + 1.0;
metadata.insert("image_quota_used".to_string(), json!(new_used));
if limit_value > 0.0 {
metadata.insert(
"image_quota_remaining".to_string(),
json!((limit_value - new_used).max(0.0)),
);
}
}
}
if !metadata.contains_key("image_quota_reset_at") {
if let Some(reset_at) =
@@ -1227,12 +1253,35 @@ struct ChatGptWebImageQuotaRequestLimit {
source: Option<String>,
}
fn chatgpt_web_image_quota_metadata_limit_is_legacy_free_default(
metadata: &Map<String, Value>,
status_snapshot: Option<&Value>,
metadata_limit: Option<f64>,
remaining: Option<f64>,
) -> bool {
let Some(limit) = metadata_limit else {
return false;
};
let plan_type = chatgpt_web_image_quota_metadata_str(metadata, "plan_type").or_else(|| {
chatgpt_web_image_quota_snapshot(status_snapshot)
.and_then(|quota| chatgpt_web_image_quota_metadata_str(quota, "plan_type"))
});
let metadata_limit_source =
chatgpt_web_image_quota_metadata_str(metadata, "image_quota_limit_source");
chatgpt_web_image_quota_limit_is_legacy_free_default(
limit,
metadata_limit_source,
plan_type,
remaining,
)
}
fn chatgpt_web_image_quota_request_limit_choice(
metadata: &Map<String, Value>,
status_snapshot: Option<&Value>,
metadata_limit: Option<f64>,
snapshot_limit: Option<f64>,
remaining: f64,
remaining: Option<f64>,
) -> Option<ChatGptWebImageQuotaRequestLimit> {
let plan_type = chatgpt_web_image_quota_metadata_str(metadata, "plan_type").or_else(|| {
chatgpt_web_image_quota_snapshot(status_snapshot)
@@ -1251,7 +1300,7 @@ fn chatgpt_web_image_quota_request_limit_choice(
let source = metadata_limit_source.map(ToOwned::to_owned).or_else(|| {
let is_first_remaining = plan_type
.is_some_and(|value| value.eq_ignore_ascii_case("free"))
&& (limit - remaining).abs() <= f64::EPSILON;
&& remaining.is_some_and(|remaining| (limit - remaining).abs() <= f64::EPSILON);
Some(
if is_first_remaining {
"first_remaining"
@@ -1279,11 +1328,9 @@ fn chatgpt_web_image_quota_request_limit_choice(
}
remaining
.is_finite()
.then_some(remaining)
.filter(|value| *value > 0.0)
.map(|value| ChatGptWebImageQuotaRequestLimit {
value,
.filter(|remaining| remaining.is_finite() && *remaining > 0.0)
.map(|remaining| ChatGptWebImageQuotaRequestLimit {
value: remaining,
source: Some("first_remaining".to_string()),
})
}
@@ -1303,7 +1350,7 @@ fn chatgpt_web_image_quota_limit_is_legacy_free_default(
limit: f64,
source: Option<&str>,
plan_type: Option<&str>,
remaining: f64,
remaining: Option<f64>,
) -> bool {
let plan_type_is_free = plan_type
.map(str::trim)
@@ -1314,7 +1361,7 @@ fn chatgpt_web_image_quota_limit_is_legacy_free_default(
if (limit - 25.0).abs() > f64::EPSILON {
return false;
}
remaining < limit
remaining.is_none_or(|remaining| remaining.is_finite() && remaining < limit)
}
async fn refresh_chatgpt_web_image_quota_after_success(
@@ -2842,6 +2889,40 @@ mod tests {
assert_eq!(metadata["image_quota_reset_at"], json!(2_000u64));
}
#[test]
fn chatgpt_web_image_quota_request_delta_records_unknown_quota_use() {
let mut metadata = Map::new();
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata.get("image_quota_remaining"), None);
assert_eq!(metadata.get("image_quota_total"), None);
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(metadata["image_quota_local_request_count"], json!(1u64));
assert_eq!(metadata["updated_at"], json!(1_000u64));
}
#[test]
fn chatgpt_web_image_quota_request_delta_derives_remaining_from_limit_only() {
let mut metadata = Map::from_iter([("image_quota_total".to_string(), json!(10.0))]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata["image_quota_remaining"], json!(9.0));
assert_eq!(metadata["image_quota_total"], json!(10.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
}
#[test]
fn chatgpt_web_image_quota_request_delta_ignores_legacy_free_25_limit() {
let mut metadata = Map::from_iter([
@@ -2867,6 +2948,30 @@ mod tests {
);
}
#[test]
fn chatgpt_web_image_quota_request_delta_ignores_legacy_free_25_without_remaining() {
let mut metadata = Map::from_iter([
("plan_type".to_string(), json!("free")),
("image_quota_total".to_string(), json!(25.0)),
]);
assert!(apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
None,
1_000,
None,
));
assert_eq!(metadata.get("image_quota_remaining"), None);
assert_eq!(metadata.get("image_quota_total"), None);
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(
metadata.get("image_quota_limit_source"),
None,
"legacy free default should not become a first observed limit without remaining"
);
}
#[test]
fn chatgpt_web_image_quota_request_delta_dedupes_same_candidate_start() {
let mut metadata = Map::from_iter([
@@ -482,7 +482,7 @@ fn chatgpt_web_image_quota_limit_is_legacy_free_default(
if (limit - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_some_and(|value| value < limit)
remaining.is_none_or(|value| value < limit)
}
fn model_quota_window_snapshot(
+38 -9
View File
@@ -640,15 +640,13 @@ pub(crate) fn provider_api_key_usage_is_error(
pub(crate) fn provider_api_key_usage_contribution(
usage: &StoredRequestUsageAudit,
) -> Option<ProviderApiKeyUsageContribution> {
if matches!(usage.status.as_str(), "pending" | "streaming") {
return None;
}
let key_id = usage
.provider_api_key_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let is_in_flight = matches!(usage.status.as_str(), "pending" | "streaming");
let is_success = provider_api_key_usage_is_success(
usage.status.as_str(),
usage.status_code,
@@ -665,8 +663,14 @@ pub(crate) fn provider_api_key_usage_contribution(
request_count: 1,
success_count: i64::from(is_success),
error_count: i64::from(is_error),
total_tokens: i64::try_from(usage.total_tokens).unwrap_or(i64::MAX),
total_cost_usd: if usage.total_cost_usd.is_finite() {
total_tokens: if is_in_flight {
0
} else {
i64::try_from(usage.total_tokens).unwrap_or(i64::MAX)
},
total_cost_usd: if is_in_flight {
0.0
} else if usage.total_cost_usd.is_finite() {
usage.total_cost_usd.max(0.0)
} else {
0.0
@@ -1029,7 +1033,7 @@ mod tests {
}
#[test]
fn provider_api_key_usage_contribution_tracks_terminal_requests_only() {
fn provider_api_key_usage_contribution_counts_in_flight_requests_once() {
let usage = StoredRequestUsageAudit::new(
"usage-1".to_string(),
"request-1".to_string(),
@@ -1074,11 +1078,36 @@ mod tests {
let mut streaming = usage.clone();
streaming.status = "streaming".to_string();
assert!(provider_api_key_usage_contribution(&streaming).is_none());
let streaming_contribution =
provider_api_key_usage_contribution(&streaming).expect("streaming should count");
assert_eq!(streaming_contribution.request_count, 1);
assert_eq!(streaming_contribution.success_count, 0);
assert_eq!(streaming_contribution.error_count, 0);
assert_eq!(streaming_contribution.total_tokens, 0);
assert_eq!(streaming_contribution.total_cost_usd, 0.0);
assert_eq!(streaming_contribution.total_response_time_ms, 0);
let mut pending = usage;
let mut pending = usage.clone();
pending.status = "pending".to_string();
assert!(provider_api_key_usage_contribution(&pending).is_none());
let pending_contribution =
provider_api_key_usage_contribution(&pending).expect("pending should count");
assert_eq!(pending_contribution.request_count, 1);
assert_eq!(pending_contribution.success_count, 0);
assert_eq!(pending_contribution.error_count, 0);
assert_eq!(pending_contribution.total_tokens, 0);
assert_eq!(pending_contribution.total_cost_usd, 0.0);
assert_eq!(pending_contribution.total_response_time_ms, 0);
let terminal_contribution =
provider_api_key_usage_contribution(&usage).expect("terminal should count");
let delta =
ProviderApiKeyUsageDelta::between(&pending_contribution, &terminal_contribution);
assert_eq!(delta.request_count, 0);
assert_eq!(delta.success_count, 1);
assert_eq!(delta.error_count, 0);
assert_eq!(delta.total_tokens, 20);
assert_eq!(delta.total_cost_usd, 0.25);
assert_eq!(delta.total_response_time_ms, 120);
}
#[test]
@@ -389,19 +389,28 @@ WHERE provider_api_key_id IS NOT NULL AND provider_api_key_id <> ''
let error_message: Option<String> = row.try_get("error_message").map_sql_err()?;
let entry = stats.entry(key_id).or_default();
entry.request_count += 1;
if provider_api_key_usage_is_success(&status, status_code_u16, error_message.as_deref())
{
let is_success = provider_api_key_usage_is_success(
&status,
status_code_u16,
error_message.as_deref(),
);
let is_in_flight = matches!(status.as_str(), "pending" | "streaming");
if is_success {
entry.success_count += 1;
}
if provider_api_key_usage_is_error(&status, status_code_u16, error_message.as_deref()) {
entry.error_count += 1;
}
entry.total_tokens += row.try_get::<i64, _>("total_tokens").map_sql_err()?;
entry.total_cost_usd += row.try_get::<f64, _>("total_cost_usd").map_sql_err()?;
entry.total_response_time_ms += row
.try_get::<Option<i64>, _>("response_time_ms")
.map_sql_err()?
.unwrap_or_default();
if !is_in_flight {
entry.total_tokens += row.try_get::<i64, _>("total_tokens").map_sql_err()?;
entry.total_cost_usd += row.try_get::<f64, _>("total_cost_usd").map_sql_err()?;
}
if is_success {
entry.total_response_time_ms += row
.try_get::<Option<i64>, _>("response_time_ms")
.map_sql_err()?
.unwrap_or_default();
}
entry.last_used_at = entry.last_used_at.max(
row.try_get::<Option<i64>, _>("updated_at_unix_secs")
.map_sql_err()?,
@@ -24,15 +24,23 @@ WITH aggregated AS (
END
), 0)::BIGINT AS error_count,
COALESCE(SUM(
GREATEST(
COALESCE(
total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
),
0
)::BIGINT
CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE GREATEST(
COALESCE(
total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)
),
0
)::BIGINT
END
), 0)::BIGINT AS total_tokens,
COALESCE(SUM(COALESCE(total_cost_usd, 0)), 0)::NUMERIC(20,8) AS total_cost_usd,
COALESCE(SUM(
CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE COALESCE(total_cost_usd, 0)
END
), 0)::NUMERIC(20,8) AS total_cost_usd,
COALESCE(SUM(
CASE
WHEN status IN ('completed', 'success', 'ok', 'billed', 'settled')
@@ -47,7 +55,6 @@ WITH aggregated AS (
FROM usage_billing_facts AS "usage"
WHERE provider_api_key_id IS NOT NULL
AND BTRIM(provider_api_key_id) <> ''
AND status NOT IN ('pending', 'streaming')
GROUP BY provider_api_key_id
)
UPDATE provider_api_keys
@@ -3409,8 +3409,14 @@ SELECT
COUNT(*) AS request_count,
COALESCE(SUM({success_flag_expr}), 0) AS success_count,
COALESCE(SUM({error_flag_expr}), 0) AS error_count,
COALESCE(SUM(MAX(COALESCE(total_tokens, 0), 0)), 0) AS total_tokens,
COALESCE(SUM(COALESCE(CAST(total_cost_usd AS REAL), 0)), 0) AS total_cost_usd,
COALESCE(SUM(CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE MAX(COALESCE(total_tokens, 0), 0)
END), 0) AS total_tokens,
COALESCE(SUM(CASE
WHEN status IN ('pending', 'streaming') THEN 0
ELSE COALESCE(CAST(total_cost_usd AS REAL), 0)
END), 0) AS total_cost_usd,
COALESCE(SUM(CASE
WHEN {success_flag_expr} = 1 AND response_time_ms IS NOT NULL
THEN MAX(COALESCE(response_time_ms, 0), 0)
@@ -325,7 +325,7 @@ fn is_legacy_chatgpt_web_free_default_limit_value(
if (value - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_some_and(|remaining| remaining < value)
remaining.is_none_or(|remaining| remaining < value)
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {