Merge remote-tracking branch 'origin/pr/558'

This commit is contained in:
fawney19
2026-05-25 01:22:03 +08:00
18 changed files with 2099 additions and 117 deletions
@@ -1073,22 +1073,42 @@ fn build_chatgpt_web_image_provider_body_from_openai_responses_body(
.unwrap_or("gpt-5-5-thinking");
let image_urls = openai_image_inputs_as_urls(&images);
let body = json!({
let mut body = json!({
"operation": operation,
"model": if model.is_empty() { "gpt-image-2" } else { model },
"web_model": web_model,
"prompt": prompt,
"size": size,
"ratio": chatgpt_web_ratio_for_size(size),
"quality": quality,
"output_format": output_format,
"images": image_urls,
});
let summary = json!({
if let Some(partial_images) = tool
.as_ref()
.and_then(|tool| tool.get("partial_images"))
.or_else(|| object.get("partial_images"))
.cloned()
{
body.as_object_mut()?
.insert("partial_images".to_string(), partial_images);
}
let mut summary = json!({
"operation": operation,
"output_format": output_format,
"size": size,
"quality": quality,
});
if let Some(partial_images) = tool
.as_ref()
.and_then(|tool| tool.get("partial_images"))
.or_else(|| object.get("partial_images"))
.cloned()
{
summary
.as_object_mut()?
.insert("partial_images".to_string(), partial_images);
}
Some((body, summary))
}
@@ -1426,4 +1446,36 @@ mod tests {
assert_eq!(summary["operation"], "generate");
assert_eq!(summary["output_format"], "png");
}
#[test]
fn chatgpt_web_responses_image_body_preserves_usage_options() {
let body_json = json!({
"model": "gpt-image-2",
"input": "Draw a glass city",
"tools": [
{
"type": "image_generation",
"size": "1024x1024",
"quality": "high",
"output_format": "png",
"partial_images": 2
}
],
"tool_choice": {
"type": "image_generation"
}
});
let (provider_body, summary) =
build_chatgpt_web_image_provider_body_from_openai_responses_body(
&body_json,
"gpt-image-2",
)
.expect("responses image body should convert");
assert_eq!(provider_body["quality"], "high");
assert_eq!(provider_body["partial_images"], 2);
assert_eq!(summary["quality"], "high");
assert_eq!(summary["partial_images"], 2);
}
}
File diff suppressed because it is too large Load Diff
@@ -61,7 +61,10 @@ use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
use crate::constants::{CONTROL_CANDIDATE_ID_HEADER, CONTROL_REQUEST_ID_HEADER};
use crate::control::GatewayControlDecision;
use crate::execution_runtime::build_direct_execution_frame_stream;
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_stream;
use crate::execution_runtime::chatgpt_web_image::{
maybe_apply_chatgpt_web_image_quota_request_delta_at_candidate_start,
maybe_execute_chatgpt_web_image_stream,
};
use crate::execution_runtime::grok::maybe_execute_grok_stream;
use crate::execution_runtime::kiro_cache::{
billed_input_tokens as kiro_billed_input_tokens, build_kiro_prompt_cache_profile,
@@ -858,6 +861,12 @@ pub(crate) async fn execute_execution_runtime_stream(
.await;
});
}
maybe_apply_chatgpt_web_image_quota_request_delta_at_candidate_start(
state,
&plan,
report_context.as_ref(),
)
.await;
let plan_request_id_for_log = short_request_id(plan.request_id.as_str());
let provider_name = plan
.provider_name
@@ -39,7 +39,10 @@ use crate::api::response::{
};
use crate::clock::current_unix_ms as current_request_candidate_unix_ms;
use crate::control::GatewayControlDecision;
use crate::execution_runtime::chatgpt_web_image::maybe_execute_chatgpt_web_image_sync;
use crate::execution_runtime::chatgpt_web_image::{
maybe_apply_chatgpt_web_image_quota_request_delta_at_candidate_start,
maybe_execute_chatgpt_web_image_sync,
};
use crate::execution_runtime::grok::maybe_execute_grok_sync;
use crate::execution_runtime::oauth_retry::refresh_oauth_plan_auth_for_retry;
#[cfg(test)]
@@ -1335,6 +1338,12 @@ async fn execute_execution_runtime_sync_impl(
},
)
.await;
maybe_apply_chatgpt_web_image_quota_request_delta_at_candidate_start(
state,
&plan,
report_context.as_ref(),
)
.await;
let mut terminal_guard = SyncAttemptTerminalGuard::new(
state,
&plan,
@@ -5,12 +5,15 @@ use super::shared::{
quota_key_auto_removed, quota_refresh_success_invalid_state, ProviderQuotaExecutionOutcome,
};
use crate::handlers::admin::provider::shared::payloads::{
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX,
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_EXPIRED_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
};
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_admin::provider::quota::parse_chatgpt_web_conversation_init_response;
use aether_contracts::ProxySnapshot;
use aether_contracts::{
ExecutionResult, ProxySnapshot, ResolvedTransportProfile, TRANSPORT_BACKEND_BROWSER_WREQ,
TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
@@ -18,10 +21,12 @@ use aether_provider_pool::{
build_chatgpt_web_pool_quota_request, enrich_chatgpt_web_quota_metadata,
normalize_chatgpt_web_image_quota_limit,
};
use base64::Engine as _;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
const CHATGPT_WEB_BROWSER_PROFILE: &str = "chrome143";
fn chatgpt_web_auth_config(
transport: &AdminGatewayProviderTransportSnapshot,
@@ -74,19 +79,99 @@ async fn execute_chatgpt_web_quota_plan(
)));
let spec =
build_chatgpt_web_pool_quota_request(&transport.key.id, &endpoint.base_url, authorization);
let resolved_transport_profile = state.resolve_transport_profile(transport);
let plan = super::shared::build_provider_quota_execution_plan(
transport,
spec,
proxy,
state.resolve_transport_profile(transport),
chatgpt_web_quota_transport_profile(resolved_transport_profile.as_ref()),
timeouts,
);
execute_provider_quota_plan(state, transport, plan, "chatgpt_web").await
}
fn chatgpt_web_quota_transport_profile(
transport_profile: Option<&ResolvedTransportProfile>,
) -> Option<ResolvedTransportProfile> {
match transport_profile {
Some(profile)
if profile
.backend
.trim()
.eq_ignore_ascii_case(TRANSPORT_BACKEND_BROWSER_WREQ) =>
{
Some(profile.clone())
}
_ => Some(default_chatgpt_web_quota_transport_profile()),
}
}
fn default_chatgpt_web_quota_transport_profile() -> ResolvedTransportProfile {
ResolvedTransportProfile {
profile_id: CHATGPT_WEB_BROWSER_PROFILE.to_string(),
backend: TRANSPORT_BACKEND_BROWSER_WREQ.to_string(),
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
header_fingerprint: None,
extra: Some(json!({
"browser_profile": CHATGPT_WEB_BROWSER_PROFILE,
"source": "chatgpt_web_quota_default",
})),
}
}
fn chatgpt_web_quota_error_detail(result: &ExecutionResult) -> Option<String> {
extract_execution_error_message(result).or_else(|| {
let body = result.body.as_ref()?.body_bytes_b64.as_deref()?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(body)
.ok()?;
let text = String::from_utf8_lossy(&decoded).trim().to_string();
(!text.is_empty()).then_some(text)
})
}
fn chatgpt_web_is_structured_account_block(message: &str) -> bool {
let lowered = message.to_ascii_lowercase();
[
"account has been disabled",
"account disabled",
"account has been deactivated",
"account_deactivated",
"account deactivated",
"organization has been disabled",
"organization_disabled",
"deactivated_workspace",
"account suspended",
"account banned",
"account_block",
"account blocked",
"访问被禁止",
"账户访问被禁止",
"账户已封禁",
"封禁",
"封号",
"被封",
]
.iter()
.any(|keyword| lowered.contains(keyword))
}
fn chatgpt_web_quota_403_refresh_failed_reason(message: Option<&str>) -> String {
let detail = message
.map(str::trim)
.filter(|value| !value.is_empty())
.filter(|value| !value.contains('<'))
.unwrap_or("ChatGPT Web 访问验证失败,请检查浏览器指纹、Cloudflare 验证或代理/地区限制");
format!("{OAUTH_REFRESH_FAILED_PREFIX}{detail}")
}
fn chatgpt_web_quota_invalid_reason(status_code: u16, upstream_message: Option<&str>) -> String {
let message = upstream_message.unwrap_or_default().trim();
if status_code == 403 && !chatgpt_web_is_structured_account_block(message) {
return chatgpt_web_quota_403_refresh_failed_reason(upstream_message);
}
let detail = if message.is_empty() {
match status_code {
401 => "ChatGPT Web Token 无效或已过期",
@@ -103,6 +188,19 @@ fn chatgpt_web_quota_invalid_reason(status_code: u16, upstream_message: Option<&
}
}
fn chatgpt_web_quota_result_message(reason: &str) -> String {
for prefix in [
OAUTH_REFRESH_FAILED_PREFIX,
OAUTH_EXPIRED_PREFIX,
OAUTH_ACCOUNT_BLOCK_PREFIX,
] {
if let Some(message) = reason.strip_prefix(prefix) {
return message.trim().to_string();
}
}
reason.trim().to_string()
}
pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
@@ -216,8 +314,20 @@ pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
message = Some("响应中未包含 ChatGPT Web 生图限额信息".to_string());
}
} else {
let err_msg = extract_execution_error_message(&result);
message = Some(match err_msg.as_deref() {
let err_msg = chatgpt_web_quota_error_detail(&result);
let invalid_reason = if matches!(result.status_code, 401 | 403) {
Some(chatgpt_web_quota_invalid_reason(
result.status_code,
err_msg.as_deref(),
))
} else {
None
};
let display_detail = invalid_reason
.as_deref()
.map(chatgpt_web_quota_result_message)
.or_else(|| err_msg.clone());
message = Some(match display_detail.as_deref() {
Some(detail) if !detail.is_empty() => {
format!(
"conversation/init 返回状态码 {}: {}",
@@ -229,12 +339,14 @@ pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
if matches!(result.status_code, 401 | 403) {
oauth_invalid_at_unix_secs = Some(now_unix_secs);
oauth_invalid_reason = Some(chatgpt_web_quota_invalid_reason(
result.status_code,
err_msg.as_deref(),
));
oauth_invalid_reason = invalid_reason;
status = if result.status_code == 401 {
"auth_invalid".to_string()
} else if oauth_invalid_reason
.as_deref()
.is_some_and(|reason| reason.starts_with(OAUTH_REFRESH_FAILED_PREFIX))
{
"refresh_failed".to_string()
} else {
"forbidden".to_string()
};
@@ -303,3 +415,81 @@ pub(crate) async fn refresh_chatgpt_web_provider_quota_locally(
"auto_removed": auto_removed_count,
})))
}
#[cfg(test)]
mod tests {
use super::*;
use aether_contracts::{ResponseBody, TRANSPORT_BACKEND_REQWEST_RUSTLS};
use base64::Engine as _;
use std::collections::BTreeMap;
#[test]
fn quota_refresh_defaults_to_browser_wreq_transport() {
let profile = chatgpt_web_quota_transport_profile(None).expect("transport profile");
assert_eq!(profile.backend, TRANSPORT_BACKEND_BROWSER_WREQ);
assert_eq!(profile.profile_id, CHATGPT_WEB_BROWSER_PROFILE);
assert_eq!(profile.http_mode, TRANSPORT_HTTP_MODE_AUTO);
assert_eq!(profile.pool_scope, TRANSPORT_POOL_SCOPE_KEY);
assert_eq!(
profile
.extra
.as_ref()
.and_then(|value| value.get("browser_profile"))
.and_then(serde_json::Value::as_str),
Some(CHATGPT_WEB_BROWSER_PROFILE)
);
}
#[test]
fn quota_refresh_overrides_non_browser_transport() {
let reqwest_profile = ResolvedTransportProfile {
profile_id: "chrome_136".to_string(),
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.to_string(),
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
header_fingerprint: None,
extra: None,
};
let profile =
chatgpt_web_quota_transport_profile(Some(&reqwest_profile)).expect("transport profile");
assert_eq!(profile.backend, TRANSPORT_BACKEND_BROWSER_WREQ);
assert_eq!(profile.profile_id, CHATGPT_WEB_BROWSER_PROFILE);
}
#[test]
fn browser_challenge_403_is_not_account_block() {
let body = "<!DOCTYPE html><html><head><title>Just a moment...</title></head><body>Cloudflare</body></html>";
let result = ExecutionResult {
request_id: "chatgpt-web-quota:test".to_string(),
candidate_id: None,
status_code: 403,
headers: BTreeMap::new(),
body: Some(ResponseBody {
json_body: None,
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
}),
telemetry: None,
error: None,
};
let detail = chatgpt_web_quota_error_detail(&result).expect("html body should decode");
let reason = chatgpt_web_quota_invalid_reason(result.status_code, Some(&detail));
assert!(reason.starts_with(OAUTH_REFRESH_FAILED_PREFIX));
assert!(!reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX));
assert_eq!(
chatgpt_web_quota_result_message(&reason),
"ChatGPT Web 访问验证失败,请检查浏览器指纹、Cloudflare 验证或代理/地区限制"
);
}
#[test]
fn explicit_account_block_403_remains_account_block() {
let reason = chatgpt_web_quota_invalid_reason(403, Some("account has been deactivated"));
assert!(reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX));
}
}
@@ -439,27 +439,53 @@ fn chatgpt_web_image_quota_limit(
metadata: &Map<String, Value>,
remaining: Option<f64>,
) -> Option<f64> {
let explicit_limit = metadata
.get("image_quota_total")
.and_then(admin_provider_quota_pure::coerce_json_f64)
.filter(|value| *value > 0.0);
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)
.filter(|value| *value > 0.0);
let limit_source = metadata
.get("image_quota_limit_source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(limit) = explicit_limit {
return Some(limit);
if !chatgpt_web_image_quota_limit_is_legacy_free_default(
limit,
limit_source,
plan_type.as_deref(),
remaining,
) {
return Some(limit);
}
}
remaining.filter(|value| *value > 0.0)
}
fn chatgpt_web_image_quota_limit_is_legacy_free_default(
limit: f64,
limit_source: Option<&str>,
plan_type: Option<&str>,
remaining: Option<f64>,
) -> bool {
let plan_type_is_free = plan_type
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("free"));
if !plan_type_is_free || limit_source.is_some() {
return false;
}
if (limit - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_none_or(|value| value < limit)
}
fn model_quota_window_snapshot(
model_name: &str,
item: &Map<String, Value>,
@@ -2559,12 +2585,39 @@ 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]
fn provider_key_status_snapshot_payload_ignores_chatgpt_web_legacy_free_25_limit() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"chatgpt_web": {
"updated_at": 1_778_067_246u64,
"plan_type": "free",
"image_quota_remaining": 19.0,
"image_quota_total": 25.0
}
}));
let payload = provider_key_status_snapshot_payload(&key, "chatgpt_web");
let window = payload
.get("quota")
.and_then(Value::as_object)
.and_then(|quota| quota.get("windows"))
.and_then(Value::as_array)
.and_then(|windows| windows.first())
.and_then(Value::as_object)
.expect("image quota window should exist");
assert_eq!(window.get("remaining_value"), Some(&json!(19.0)));
assert_eq!(window.get("limit_value"), Some(&json!(19.0)));
assert_eq!(window.get("used_value"), Some(&json!(0.0)));
}
#[test]
@@ -206,6 +206,21 @@ pub fn build_chatgpt_web_image_request_body(
if let Some(user) = request.user.as_ref() {
body.insert("user".to_string(), Value::String(user.clone()));
}
if let Some(quality) = request
.tool
.get("quality")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("quality".to_string(), Value::String(quality.to_string()));
}
if let Some(partial_images) = request.tool.get("partial_images").and_then(Value::as_u64) {
body.insert(
"partial_images".to_string(),
Value::Number(Number::from(partial_images)),
);
}
if let Some(output_format) = request
.summary_json
.get("output_format")
@@ -1593,6 +1608,28 @@ mod tests {
assert_eq!(by_size["size"], "1024x1024");
}
#[test]
fn chatgpt_web_preserves_quality_and_partial_images() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
let body = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"size": "1024x1024",
"quality": "high",
"partial_images": 2,
"output_format": "png"
}),
None,
)
.expect("request should pass");
assert_eq!(body["quality"], "high");
assert_eq!(body["partial_images"], 2);
assert_eq!(body["output_format"], "png");
}
#[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
+38 -9
View File
@@ -636,15 +636,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,
@@ -661,8 +659,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
@@ -1025,7 +1029,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(),
@@ -1070,11 +1074,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]
@@ -854,19 +854,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
@@ -3749,8 +3749,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)
+69 -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]
@@ -252,6 +252,72 @@ mod tests {
assert_eq!(metadata["image_quota_used"], json!(33.0));
}
#[test]
fn chatgpt_web_quota_metadata_does_not_preserve_legacy_free_25_limit() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 19,
});
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
Some(&json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_total": 25
}
})),
);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn chatgpt_web_quota_metadata_ignores_upstream_free_25_default() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 19,
"image_quota_total": 25,
});
normalize_chatgpt_web_image_quota_limit(&mut metadata, None);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(0.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn chatgpt_web_quota_metadata_preserves_marked_free_first_limit() {
let mut metadata = json!({
"plan_type": "free",
"image_quota_remaining": 18,
});
normalize_chatgpt_web_image_quota_limit(
&mut metadata,
Some(&json!({
"chatgpt_web": {
"plan_type": "free",
"image_quota_total": 19,
"image_quota_limit_source": "first_remaining"
}
})),
);
assert_eq!(metadata["image_quota_total"], json!(19.0));
assert_eq!(metadata["image_quota_used"], json!(1.0));
assert_eq!(
metadata["image_quota_limit_source"],
json!("first_remaining")
);
}
#[test]
fn windsurf_quota_request_uses_user_status_connect_rpc() {
let spec = build_windsurf_pool_quota_request("key-ws", "session-token-123");
@@ -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;
@@ -183,28 +181,47 @@ pub fn normalize_chatgpt_web_image_quota_limit(
};
let remaining = provider_pool_json_f64(object.get("image_quota_remaining"));
let explicit_limit =
let plan_type = chatgpt_web_image_quota_plan_type(object)
.map(ToOwned::to_owned)
.or_else(|| {
existing_limit
.as_ref()
.and_then(|existing| existing.plan_type.clone())
});
let raw_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 explicit_limit_is_free_default = raw_explicit_limit.is_some_and(|limit| {
is_legacy_chatgpt_web_free_default_limit_value(limit, None, plan_type.as_deref(), remaining)
});
if explicit_limit_is_free_default {
object.remove("image_quota_total");
object.remove("image_quota_limit_source");
}
let explicit_limit = raw_explicit_limit.filter(|_| !explicit_limit_is_free_default);
let limit = explicit_limit
.map(|limit| ChatGptWebImageQuotaLimit {
value: limit,
source: Some("upstream_total".to_string()),
plan_type: plan_type.clone(),
})
.or_else(|| {
infer_chatgpt_web_image_quota_limit(remaining, existing_limit, plan_type.as_deref())
});
if let Some(limit) = limit {
object.insert("image_quota_total".to_string(), json!(limit));
object.insert("image_quota_total".to_string(), json!(limit.value));
if let Some(source) = limit.source.as_deref().filter(|value| !value.is_empty()) {
object.insert("image_quota_limit_source".to_string(), json!(source));
}
if !object.contains_key("image_quota_used") {
if let Some(remaining) = remaining {
object.insert(
"image_quota_used".to_string(),
json!((limit - remaining).max(0.0)),
json!((limit.value - remaining).max(0.0)),
);
} else if object.get("image_quota_blocked").and_then(Value::as_bool) == Some(true) {
object.insert("image_quota_used".to_string(), json!(limit));
object.insert("image_quota_used".to_string(), json!(limit.value));
}
}
}
@@ -222,37 +239,93 @@ fn chatgpt_web_auth_config_string(auth_config: Option<&Value>, fields: &[&str])
})
}
fn chatgpt_web_json_string(value: Option<&Value>) -> Option<&str> {
value
#[derive(Debug, Clone)]
struct ChatGptWebImageQuotaLimit {
value: f64,
source: Option<String>,
plan_type: Option<String>,
}
fn chatgpt_web_image_quota_plan_type(object: &Map<String, Value>) -> Option<&str> {
object
.get("plan_type")
.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
fn existing_chatgpt_web_image_quota_limit(
upstream_metadata: Option<&Value>,
) -> Option<ChatGptWebImageQuotaLimit> {
let bucket = upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)
.and_then(|bucket| provider_pool_json_f64(bucket.get("image_quota_total")))
.filter(|value| *value > 0.0)
.and_then(Value::as_object)?;
let value =
provider_pool_json_f64(bucket.get("image_quota_total")).filter(|value| *value > 0.0)?;
let source = bucket
.get("image_quota_limit_source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let plan_type = chatgpt_web_image_quota_plan_type(bucket).map(ToOwned::to_owned);
Some(ChatGptWebImageQuotaLimit {
value,
source,
plan_type,
})
}
fn infer_chatgpt_web_image_quota_limit(
remaining: Option<f64>,
existing_limit: Option<ChatGptWebImageQuotaLimit>,
plan_type: Option<&str>,
) -> Option<ChatGptWebImageQuotaLimit> {
if let Some(existing_limit) = existing_limit {
if !is_legacy_chatgpt_web_free_default_limit(&existing_limit, plan_type, remaining) {
return Some(existing_limit);
}
}
remaining
.filter(|value| *value > 0.0)
.map(|value| ChatGptWebImageQuotaLimit {
value,
source: Some("first_remaining".to_string()),
plan_type: plan_type.map(ToOwned::to_owned),
})
}
fn is_legacy_chatgpt_web_free_default_limit(
existing_limit: &ChatGptWebImageQuotaLimit,
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);
}
) -> bool {
is_legacy_chatgpt_web_free_default_limit_value(
existing_limit.value,
existing_limit.source.as_deref(),
plan_type,
remaining,
)
}
if let Some(existing_limit) = existing_limit.filter(|value| *value > 0.0) {
return Some(existing_limit);
fn is_legacy_chatgpt_web_free_default_limit_value(
value: f64,
source: Option<&str>,
plan_type: Option<&str>,
remaining: Option<f64>,
) -> bool {
let plan_type_is_free = plan_type
.map(str::trim)
.is_some_and(|value| value.eq_ignore_ascii_case("free"));
if !plan_type_is_free || source.is_some() {
return false;
}
remaining.filter(|value| *value > 0.0)
if (value - 25.0).abs() > f64::EPSILON {
return false;
}
remaining.is_none_or(|remaining| remaining < value)
}
pub(crate) fn quota_exhausted_from_bucket(bucket: &Map<String, Value>) -> bool {
@@ -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)}`
}
+5 -5
View File
@@ -3700,7 +3700,7 @@ function getQuotaProgressLabel(label: string): string {
}
function getQuotaProgressCountdown(item: QuotaProgressItem) {
if (!['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3'].includes(item.label)) return null
if (!['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图'].includes(item.label)) return null
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
return getCodexResetCountdown(
item.resetAtSeconds,
@@ -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
@@ -643,6 +643,47 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.querySelectorAll('button[title="查看评分计算结果"]').length).toBeGreaterThan(0)
})
it('shows ChatGPT Web image quota reset countdown above the quota bar', async () => {
const chatgptWebKey = createPoolKey('chatgpt_web', {
api_formats: ['openai:image'],
status_snapshot: {
oauth: { code: 'valid' },
account: { code: 'ok', blocked: false },
quota: {
code: 'ok',
exhausted: false,
provider_type: 'chatgpt_web',
updated_at: 1_700_000_000,
windows: [
{
code: 'image_gen',
label: '生图',
scope: 'account',
remaining_ratio: 0.96,
remaining_value: 24,
limit_value: 25,
reset_seconds: 3600,
},
],
},
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('chatgpt_web')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(chatgptWebKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('chatgpt_web', {
api_formats: ['openai:image'],
}))
const root = mountPoolManagement()
await settle()
const resetTexts = Array.from(root.querySelectorAll('[data-testid="pool-quota-reset-text"]'))
.map((element) => element.textContent?.trim())
.filter(Boolean)
expect(resetTexts).toContain('1h')
expect(root.textContent).toContain('生图')
})
it('opens only one score popover across desktop and mobile layouts', async () => {
const scoredKey = createPoolKey('codex', {
pool_score: {