修复 ChatGPT Web 生图额度递减显示

This commit is contained in:
Codex
2026-05-24 18:50:14 +08:00
parent 2b2754b779
commit 218ca8e6eb
8 changed files with 274 additions and 59 deletions
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet};
use std::io::Error as IoError;
use std::time::Instant;
use std::time::{Duration, Instant};
use aether_admin::provider::quota::{
parse_chatgpt_web_conversation_init_response, quota_refresh_success_invalid_state,
@@ -964,6 +964,19 @@ fn spawn_chatgpt_web_image_quota_refresh_after_success(
let base_url = base_url.to_string();
let token = token.to_string();
tokio::spawn(async move {
if let Err(err) = apply_chatgpt_web_image_quota_success_delta(&state, &plan).await {
warn!(
event_name = "chatgpt_web_image_quota_success_delta_failed",
log_type = "ops",
request_id = %plan.request_id,
candidate_id = ?plan.candidate_id,
provider_id = %plan.provider_id,
key_id = %plan.key_id,
error = %err,
"gateway failed to persist ChatGPT-Web image quota success delta"
);
}
tokio::time::sleep(Duration::from_secs(5)).await;
if let Err(err) =
refresh_chatgpt_web_image_quota_after_success(&state, &plan, &base_url, &token).await
{
@@ -981,6 +994,91 @@ fn spawn_chatgpt_web_image_quota_refresh_after_success(
});
}
async fn apply_chatgpt_web_image_quota_success_delta(
state: &AppState,
plan: &ExecutionPlan,
) -> Result<bool, String> {
let key_id = plan.key_id.trim();
let provider_id = plan.provider_id.trim();
let Some(mut latest_key) = state
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await
.map_err(|err| err.into_message())?
.into_iter()
.find(|key| key.id == key_id && key.provider_id == provider_id)
else {
return Ok(false);
};
let Some(mut metadata) = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)
.cloned()
else {
return Ok(false);
};
let now_unix_secs = current_unix_secs();
if chatgpt_web_image_quota_u64(metadata.get("image_quota_reset_at"))
.is_some_and(|reset_at| reset_at <= now_unix_secs)
{
return Ok(false);
}
let Some(remaining) = chatgpt_web_image_quota_f64(metadata.get("image_quota_remaining"))
.filter(|value| *value > 0.0)
else {
return Ok(false);
};
let limit = chatgpt_web_image_quota_f64(metadata.get("image_quota_total"))
.filter(|value| *value > 0.0)
.unwrap_or(remaining);
let new_remaining = (remaining - 1.0).max(0.0);
metadata.insert("image_quota_remaining".to_string(), json!(new_remaining));
metadata.insert("image_quota_total".to_string(), json!(limit));
metadata.insert(
"image_quota_used".to_string(),
json!((limit - new_remaining).max(0.0)),
);
metadata.insert("updated_at".to_string(), json!(now_unix_secs));
metadata.insert(
"image_quota_last_local_success_at".to_string(),
json!(now_unix_secs),
);
let local_success_count =
chatgpt_web_image_quota_u64(metadata.get("image_quota_local_success_count")).unwrap_or(0);
metadata.insert(
"image_quota_local_success_count".to_string(),
json!(local_success_count.saturating_add(1)),
);
let updated_upstream_metadata = merge_provider_metadata_object(
latest_key.upstream_metadata.as_ref(),
"chatgpt_web",
Value::Object(metadata),
);
latest_key.upstream_metadata = updated_upstream_metadata;
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
latest_key.status_snapshot.as_ref(),
"chatgpt_web",
latest_key.upstream_metadata.as_ref(),
"image_success_local",
);
latest_key.status_snapshot =
sync_provider_key_oauth_status_snapshot(latest_key.status_snapshot.as_ref(), &latest_key);
latest_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key_runtime_state(&latest_key)
.await
.map_err(|err| err.into_message())?
.is_some())
}
async fn refresh_chatgpt_web_image_quota_after_success(
state: &AppState,
plan: &ExecutionPlan,
@@ -1039,8 +1137,9 @@ async fn refresh_chatgpt_web_image_quota_after_success(
}
let body_json = execution_result_json(&result).map_err(|err| err.to_string())?;
let now_unix_secs = current_unix_secs();
let Some(mut metadata) =
parse_chatgpt_web_conversation_init_response(&body_json, current_unix_secs())
parse_chatgpt_web_conversation_init_response(&body_json, now_unix_secs)
else {
return Ok(false);
};
@@ -1055,6 +1154,11 @@ async fn refresh_chatgpt_web_image_quota_after_success(
return Ok(false);
};
normalize_chatgpt_web_image_quota_limit(&mut metadata, latest_key.upstream_metadata.as_ref());
preserve_chatgpt_web_local_success_delta(
&mut metadata,
latest_key.upstream_metadata.as_ref(),
now_unix_secs,
);
let mut updated_key = latest_key;
let updated_upstream_metadata = merge_provider_metadata_object(
@@ -1075,7 +1179,7 @@ async fn refresh_chatgpt_web_image_quota_after_success(
);
updated_key.status_snapshot =
sync_provider_key_oauth_status_snapshot(updated_key.status_snapshot.as_ref(), &updated_key);
updated_key.updated_at_unix_secs = Some(current_unix_secs());
updated_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key_runtime_state(&updated_key)
@@ -1084,6 +1188,83 @@ async fn refresh_chatgpt_web_image_quota_after_success(
.is_some())
}
fn preserve_chatgpt_web_local_success_delta(
metadata: &mut Value,
existing_upstream_metadata: Option<&Value>,
now_unix_secs: u64,
) {
let Some(incoming) = metadata.as_object_mut() else {
return;
};
let Some(existing) = existing_upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)
else {
return;
};
let Some(local_success_at) =
chatgpt_web_image_quota_u64(existing.get("image_quota_last_local_success_at"))
else {
return;
};
if now_unix_secs.saturating_sub(local_success_at) > 180 {
return;
}
let incoming_reset_at = chatgpt_web_image_quota_u64(incoming.get("image_quota_reset_at"));
let existing_reset_at = chatgpt_web_image_quota_u64(existing.get("image_quota_reset_at"));
if incoming_reset_at.is_some_and(|reset_at| reset_at <= now_unix_secs) {
return;
}
match (incoming_reset_at, existing_reset_at) {
(Some(incoming_reset_at), Some(existing_reset_at))
if incoming_reset_at != existing_reset_at =>
{
return;
}
(Some(_), Some(_)) | (None, None) => {}
_ => return,
}
let Some(existing_remaining) =
chatgpt_web_image_quota_f64(existing.get("image_quota_remaining"))
else {
return;
};
let Some(incoming_remaining) =
chatgpt_web_image_quota_f64(incoming.get("image_quota_remaining"))
else {
return;
};
if incoming_remaining <= existing_remaining {
return;
}
let limit = chatgpt_web_image_quota_f64(incoming.get("image_quota_total"))
.filter(|value| *value > 0.0)
.or_else(|| {
chatgpt_web_image_quota_f64(existing.get("image_quota_total"))
.filter(|value| *value > 0.0)
})
.unwrap_or_else(|| incoming_remaining.max(existing_remaining));
incoming.insert(
"image_quota_remaining".to_string(),
json!(existing_remaining),
);
incoming.insert("image_quota_total".to_string(), json!(limit));
incoming.insert(
"image_quota_used".to_string(),
json!((limit - existing_remaining).max(0.0)),
);
if let Some(value) = existing.get("image_quota_last_local_success_at").cloned() {
incoming.insert("image_quota_last_local_success_at".to_string(), value);
}
if let Some(value) = existing.get("image_quota_local_success_count").cloned() {
incoming.insert("image_quota_local_success_count".to_string(), value);
}
}
fn build_chatgpt_web_image_quota_refresh_plan(
plan: &ExecutionPlan,
spec: ProviderPoolQuotaRequestSpec,
@@ -1169,6 +1350,26 @@ fn merge_provider_metadata_object(
Some(Value::Object(merged))
}
fn chatgpt_web_image_quota_f64(value: Option<&Value>) -> Option<f64> {
match value {
Some(Value::Number(number)) => number.as_f64(),
Some(Value::String(value)) => value.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|value| value.is_finite())
}
fn chatgpt_web_image_quota_u64(value: Option<&Value>) -> Option<u64> {
let mut parsed = chatgpt_web_image_quota_f64(value)?;
if parsed <= 0.0 {
return None;
}
if parsed > 1_000_000_000_000.0 {
parsed /= 1000.0;
}
Some(parsed.floor() as u64)
}
fn chatgpt_web_image_transport_profile(plan: &ExecutionPlan) -> Option<ResolvedTransportProfile> {
match plan.transport_profile.as_ref() {
Some(profile)
@@ -2386,6 +2587,35 @@ mod tests {
);
}
#[test]
fn chatgpt_web_image_quota_refresh_preserves_recent_local_success_delta() {
let mut metadata = json!({
"updated_at": 1_000u64,
"image_quota_remaining": 25.0,
"image_quota_total": 25.0,
"image_quota_used": 0.0,
"image_quota_reset_at": 2_000u64
});
let existing = json!({
"chatgpt_web": {
"updated_at": 995u64,
"image_quota_remaining": 24.0,
"image_quota_total": 25.0,
"image_quota_used": 1.0,
"image_quota_reset_at": 2_000u64,
"image_quota_last_local_success_at": 998u64,
"image_quota_local_success_count": 1u64
}
});
preserve_chatgpt_web_local_success_delta(&mut metadata, Some(&existing), 1_000);
assert_eq!(metadata["image_quota_remaining"], json!(24.0));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(metadata["image_quota_last_local_success_at"], json!(998u64));
}
async fn start_mock_chatgpt_web() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new().fallback(any(|request: Request| async move {
let path = request.uri().path().to_string();
@@ -438,16 +438,6 @@ fn chatgpt_web_image_quota_limit(
metadata: &Map<String, Value>,
remaining: Option<f64>,
) -> Option<f64> {
let plan_type = metadata
.get("plan_type")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase());
if plan_type.as_deref() == Some("free") {
return Some(25.0);
}
let explicit_limit = metadata
.get("image_quota_total")
.and_then(admin_provider_quota_pure::coerce_json_f64)
@@ -2535,12 +2525,12 @@ mod tests {
assert_eq!(quota.get("code"), Some(&json!("ok")));
assert_eq!(quota.get("plan_type"), Some(&json!("free")));
assert_eq!(quota.get("reset_at"), Some(&json!(1_778_157_172u64)));
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.04)));
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.0)));
assert_eq!(window.get("code"), Some(&json!("image_gen")));
assert_eq!(window.get("remaining_value"), Some(&json!(24.0)));
assert_eq!(window.get("limit_value"), Some(&json!(25.0)));
assert_eq!(window.get("used_value"), Some(&json!(1.0)));
assert_eq!(window.get("remaining_ratio"), Some(&json!(0.96)));
assert_eq!(window.get("limit_value"), Some(&json!(24.0)));
assert_eq!(window.get("used_value"), Some(&json!(0.0)));
assert_eq!(window.get("remaining_ratio"), Some(&json!(1.0)));
}
#[test]
+3 -3
View File
@@ -212,7 +212,7 @@ mod tests {
}
#[test]
fn chatgpt_web_quota_metadata_enriches_auth_and_normalizes_free_limit() {
fn chatgpt_web_quota_metadata_enriches_auth_and_uses_first_remaining_as_limit() {
let mut metadata = json!({
"image_quota_remaining": 12,
});
@@ -229,8 +229,8 @@ mod tests {
assert_eq!(metadata["plan_type"], json!("free"));
assert_eq!(metadata["email"], json!("user@example.com"));
assert_eq!(metadata["account_id"], json!("acct-1"));
assert_eq!(metadata["image_quota_total"], json!(25.0));
assert_eq!(metadata["image_quota_used"], json!(13.0));
assert_eq!(metadata["image_quota_total"], json!(12.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
}
#[test]
@@ -24,8 +24,6 @@ const CHATGPT_WEB_CLIENT_VERSION: &str = "prod-be885abbfcfe7b1f511e88b3003d9ee44
const CHATGPT_WEB_BUILD_NUMBER: &str = "5955942";
const CHATGPT_WEB_SEC_CH_UA: &str =
r#""Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24""#;
const CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT: f64 = 25.0;
#[derive(Debug, Clone, Default)]
pub struct ChatGptWebProviderPoolAdapter;
@@ -185,14 +183,8 @@ pub fn normalize_chatgpt_web_image_quota_limit(
let remaining = provider_pool_json_f64(object.get("image_quota_remaining"));
let explicit_limit =
provider_pool_json_f64(object.get("image_quota_total")).filter(|value| *value > 0.0);
let plan_type = chatgpt_web_json_string(object.get("plan_type"));
let is_free_plan = plan_type.is_some_and(|value| value.trim().eq_ignore_ascii_case("free"));
let limit = if is_free_plan {
Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT)
} else {
explicit_limit
.or_else(|| infer_chatgpt_web_image_quota_limit(plan_type, remaining, existing_limit))
};
let limit =
explicit_limit.or_else(|| infer_chatgpt_web_image_quota_limit(remaining, existing_limit));
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit));
@@ -222,13 +214,6 @@ fn chatgpt_web_auth_config_string(auth_config: Option<&Value>, fields: &[&str])
})
}
fn chatgpt_web_json_string(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn existing_chatgpt_web_image_quota_limit(upstream_metadata: Option<&Value>) -> Option<f64> {
upstream_metadata
.and_then(Value::as_object)
@@ -239,15 +224,9 @@ fn existing_chatgpt_web_image_quota_limit(upstream_metadata: Option<&Value>) ->
}
fn infer_chatgpt_web_image_quota_limit(
plan_type: Option<&str>,
remaining: Option<f64>,
existing_limit: Option<f64>,
) -> Option<f64> {
let normalized_plan = plan_type.unwrap_or_default().trim().to_ascii_lowercase();
if normalized_plan == "free" {
return Some(CHATGPT_WEB_FREE_IMAGE_QUOTA_LIMIT);
}
if let Some(existing_limit) = existing_limit.filter(|value| *value > 0.0) {
return Some(existing_limit);
}
@@ -1056,7 +1056,7 @@
</div>
<div>
<div class="flex items-center justify-between text-[10px] mb-0.5">
<span class="text-muted-foreground">使用额度</span>
<span class="text-muted-foreground">剩余额度</span>
<span :class="getQuotaRemainingClass(getChatGPTWebQuotaUsedPercent(key))">
{{ getChatGPTWebQuotaRemainingPercent(key).toFixed(1) }}%
</span>
@@ -1070,7 +1070,7 @@
</div>
<div class="flex items-center justify-between text-[9px] text-muted-foreground/70 mt-0.5">
<span>
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_used) }} /
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_remaining) }} /
{{ formatChatGPTWebUsage(getChatGPTWebQuotaDisplay(key)?.image_quota_total) }}
</span>
<span v-if="getChatGPTWebQuotaDisplay(key)?.image_quota_reset_at">
@@ -105,6 +105,28 @@ describe('providerKeyQuota', () => {
}, 'grok')).toBe('Auto剩余 40.0% (60/150) | Heavy剩余 0.0% (0/20)')
})
it('formats ChatGPT Web image quota as remaining count', () => {
expect(getQuotaDisplayText({
status_snapshot: {
quota: {
provider_type: 'chatgpt_web',
code: 'ok',
exhausted: false,
windows: [
{
code: 'image_gen',
scope: 'account',
remaining_ratio: 0.96,
used_value: 1,
remaining_value: 24,
limit_value: 25,
},
],
},
},
}, 'chatgpt_web')).toBe('生图剩余 24/25')
})
it('surfaces Windsurf hard account states', () => {
expect(getQuotaDisplayText({
status_snapshot: {
+4 -10
View File
@@ -343,19 +343,13 @@ function getChatGPTWebQuotaText(quota: QuotaStatusSnapshot): string | null {
if (!window) return normalizeText(quota.label)
const remainingPercent = getQuotaWindowRemainingPercent(window)
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0 && window.remaining_value <= 0) {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (remainingPercent != null) {
if (typeof window.used_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
return `生图剩余 ${formatPercent(remainingPercent)} (${formatQuotaValue(window.used_value)}/${formatQuotaValue(window.limit_value)})`
}
return `生图剩余 ${formatPercent(remainingPercent)}`
}
if (typeof window.remaining_value === 'number' && typeof window.limit_value === 'number' && window.limit_value > 0) {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}/${formatQuotaValue(window.limit_value)}`
}
if (remainingPercent != null) {
return `生图剩余 ${formatPercent(remainingPercent)}`
}
if (typeof window.remaining_value === 'number') {
return `生图剩余 ${formatQuotaValue(window.remaining_value)}`
}
+4 -4
View File
@@ -4109,10 +4109,10 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
const remainingValue = typeof window?.remaining_value === 'number' ? window.remaining_value : null
const limitValue = typeof window?.limit_value === 'number' ? window.limit_value : null
const usedValue = typeof window?.used_value === 'number' ? window.used_value : null
const detail = usedValue != null && limitValue != null
? `${formatQuotaValue(usedValue)}/${formatQuotaValue(limitValue)}`
: remainingValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - remainingValue, 0))}/${formatQuotaValue(limitValue)}`
const detail = remainingValue != null && limitValue != null
? `${formatQuotaValue(remainingValue)}/${formatQuotaValue(limitValue)}`
: usedValue != null && limitValue != null
? `${formatQuotaValue(Math.max(limitValue - usedValue, 0))}/${formatQuotaValue(limitValue)}`
: remainingValue != null
? `剩余 ${formatQuotaValue(remainingValue)}`
: undefined