feat(image): 接入 ChatGPT Web 生图反代

This commit is contained in:
Entropy.Xu
2026-05-06 02:29:17 +08:00
parent beee7a76d2
commit 4baee436ba
47 changed files with 3872 additions and 141 deletions

View File

@@ -94,12 +94,14 @@ pub use crate::request::specialized::{
resolve_sync_spec as resolve_gemini_files_sync_spec, LocalGeminiFilesSpec,
},
image::{
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
is_openai_image_stream_request, normalize_openai_image_request,
openai_image_operation_from_path, resolve_requested_openai_image_model_for_request,
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
default_model_for_openai_image_operation, is_openai_image_stream_request,
normalize_openai_image_request, openai_image_operation_from_path,
resolve_requested_openai_image_model_for_request,
resolve_stream_spec as resolve_local_image_stream_spec,
resolve_sync_spec as resolve_local_image_sync_spec, LocalOpenAiImageSpec,
NormalizedOpenAiImageRequest, OpenAiImageOperation, OpenAiImageResponseFormat,
resolve_sync_spec as resolve_local_image_sync_spec, ChatGptWebImageRequestError,
LocalOpenAiImageSpec, NormalizedOpenAiImageRequest, OpenAiImageOperation,
OpenAiImageResponseFormat,
},
video::{
resolve_sync_spec as resolve_local_video_sync_spec, LocalVideoCreateFamily,

View File

@@ -83,6 +83,171 @@ pub struct NormalizedOpenAiImageRequest {
user: Option<String>,
}
pub const CHATGPT_WEB_IMAGE_MAX_AREA: u64 = 1_500_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatGptWebImageRequestError {
pub status_code: u16,
pub error_type: &'static str,
pub code: &'static str,
pub message: String,
}
impl ChatGptWebImageRequestError {
fn invalid_request(message: impl Into<String>) -> Self {
Self {
status_code: 400,
error_type: "invalid_request_error",
code: "chatgpt_web_image_unsupported",
message: message.into(),
}
}
pub fn to_error_json(&self) -> Value {
json!({
"error": {
"message": self.message,
"type": self.error_type,
"code": self.code,
"param": Value::Null,
}
})
}
}
#[derive(Debug, Clone, Default)]
struct ChatGptWebRawImageFields {
size: Option<String>,
resolution: Option<String>,
size_tier: Option<String>,
ratio: Option<String>,
aspect_ratio: Option<String>,
web_model: Option<String>,
}
#[derive(Debug, Clone)]
struct ChatGptWebResolvedSize {
size: String,
ratio: String,
best_effort: bool,
}
pub fn build_chatgpt_web_image_request_body(
parts: &http::request::Parts,
body_json: &Value,
body_base64: Option<&str>,
) -> Result<Value, ChatGptWebImageRequestError> {
let request = normalize_openai_image_request(parts, body_json, body_base64).ok_or_else(|| {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy only supports OpenAI image requests with prompt, n=1, supported image inputs, and supported output options",
)
})?;
if request.tool.contains_key("input_image_mask") {
return Err(ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support mask inputs",
));
}
let raw_fields = resolve_chatgpt_web_raw_image_fields(parts, body_json, body_base64)?;
let resolved_size = resolve_chatgpt_web_size(&raw_fields)?;
let prompt = request
.prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(match request.operation {
OpenAiImageOperation::Variation => "Create a faithful variation of the provided image.",
OpenAiImageOperation::Generate | OpenAiImageOperation::Edit => {
"Generate a high quality image."
}
});
let prompt = chatgpt_web_prompt_with_ratio(prompt, resolved_size.ratio.as_str());
let mut image_urls = Vec::new();
for image in &request.images {
let Some(object) = image.as_object() else {
continue;
};
if object
.get("file_id")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err(ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support file_id image inputs; use URL, data URL, or multipart file inputs",
));
}
if let Some(image_url) = object
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
image_urls.push(Value::String(image_url.to_string()));
}
}
let requested_model = request
.requested_model
.clone()
.unwrap_or_else(|| default_model_for_openai_image_operation(request.operation).to_string());
let mut body = Map::new();
body.insert(
"operation".to_string(),
Value::String(request.operation.as_str().to_string()),
);
body.insert("model".to_string(), Value::String(requested_model));
body.insert(
"web_model".to_string(),
Value::String(
raw_fields
.web_model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("gpt-5-5-thinking")
.to_string(),
),
);
body.insert("prompt".to_string(), Value::String(prompt));
body.insert("size".to_string(), Value::String(resolved_size.size));
body.insert("ratio".to_string(), Value::String(resolved_size.ratio));
body.insert(
"size_best_effort".to_string(),
Value::Bool(resolved_size.best_effort),
);
body.insert("images".to_string(), Value::Array(image_urls));
body.insert("count".to_string(), Value::Number(Number::from(1)));
if let Some(user) = request.user.as_ref() {
body.insert("user".to_string(), Value::String(user.clone()));
}
if let Some(output_format) = request
.summary_json
.get("output_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert(
"output_format".to_string(),
Value::String(output_format.to_string()),
);
}
if let Some(response_format) = request
.summary_json
.get("response_format")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert(
"response_format".to_string(),
Value::String(response_format.to_string()),
);
}
Ok(Value::Object(body))
}
pub fn is_openai_image_stream_request(
parts: &http::request::Parts,
body_json: &Value,
@@ -117,6 +282,170 @@ pub fn openai_image_operation_from_path(path: &str) -> Option<OpenAiImageOperati
}
}
fn resolve_chatgpt_web_raw_image_fields(
parts: &http::request::Parts,
body_json: &Value,
body_base64: Option<&str>,
) -> Result<ChatGptWebRawImageFields, ChatGptWebImageRequestError> {
if let Some(body_base64) = body_base64 {
let fields = parse_multipart_fields_from_base64(parts, body_base64).ok_or_else(|| {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy could not parse multipart image request",
)
})?;
return Ok(ChatGptWebRawImageFields {
size: find_multipart_text_field(&fields, "size"),
resolution: find_multipart_text_field(&fields, "resolution"),
size_tier: find_multipart_text_field(&fields, "size_tier"),
ratio: find_multipart_text_field(&fields, "ratio"),
aspect_ratio: find_multipart_text_field(&fields, "aspect_ratio"),
web_model: find_multipart_text_field(&fields, "web_model"),
});
}
let Some(object) = body_json.as_object() else {
return Ok(ChatGptWebRawImageFields::default());
};
Ok(ChatGptWebRawImageFields {
size: json_text_field(object, "size"),
resolution: json_text_field(object, "resolution"),
size_tier: json_text_field(object, "size_tier"),
ratio: json_text_field(object, "ratio"),
aspect_ratio: json_text_field(object, "aspect_ratio"),
web_model: json_text_field(object, "web_model"),
})
}
fn json_text_field(object: &Map<String, Value>, key: &str) -> Option<String> {
object
.get(key)
.and_then(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.or_else(|| value.as_u64().map(|number| number.to_string()))
.or_else(|| value.as_i64().map(|number| number.to_string()))
})
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn resolve_chatgpt_web_size(
fields: &ChatGptWebRawImageFields,
) -> Result<ChatGptWebResolvedSize, ChatGptWebImageRequestError> {
let tier = fields
.resolution
.as_deref()
.or(fields.size_tier.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(tier) = tier {
let normalized = tier.to_ascii_uppercase();
if normalized != "1K" && normalized != "1" {
return Err(unsupported_chatgpt_web_resolution_error());
}
}
let fallback_ratio = fields
.ratio
.as_deref()
.or(fields.aspect_ratio.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("1:1");
let explicit_size = fields
.size
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let size = explicit_size
.map(ToOwned::to_owned)
.unwrap_or_else(|| chatgpt_web_1k_size_for_ratio(fallback_ratio).to_string());
let (width, height) = parse_image_size_or_default(size.as_str());
if width.saturating_mul(height) > CHATGPT_WEB_IMAGE_MAX_AREA {
return Err(unsupported_chatgpt_web_resolution_error());
}
let ratio = chatgpt_web_ratio_from_size(size.as_str(), fallback_ratio).to_string();
let best_effort = explicit_size.is_some_and(|_| !chatgpt_web_is_exact_1k_size(size.as_str()));
Ok(ChatGptWebResolvedSize {
size,
ratio,
best_effort,
})
}
fn unsupported_chatgpt_web_resolution_error() -> ChatGptWebImageRequestError {
ChatGptWebImageRequestError::invalid_request(
"ChatGPT-Web image proxy does not support the requested resolution; use resolution=1K, size_tier=1K, or a size area <= 1,500,000 pixels",
)
}
fn chatgpt_web_1k_size_for_ratio(ratio: &str) -> &'static str {
match ratio.trim() {
"3:2" => "1216x832",
"2:3" => "832x1216",
"4:3" => "1152x864",
"3:4" => "864x1152",
"5:4" => "1120x896",
"4:5" => "896x1120",
"16:9" => "1344x768",
"9:16" => "768x1344",
"21:9" => "1536x640",
_ => "1024x1024",
}
}
fn chatgpt_web_ratio_from_size<'a>(size: &str, fallback: &'a str) -> &'a str {
match size.trim() {
"1024x1024" | "1248x1248" | "2480x2480" | "512x512" => "1:1",
"1216x832" | "1536x1024" | "3056x2032" => "3:2",
"832x1216" | "1024x1536" | "2032x3056" => "2:3",
"1152x864" | "1440x1088" | "2880x2160" => "4:3",
"864x1152" | "1088x1440" | "2160x2880" => "3:4",
"1120x896" | "1392x1120" | "2784x2224" => "5:4",
"896x1120" | "1120x1392" | "2224x2784" => "4:5",
"1344x768" | "1664x928" | "3312x1872" => "16:9",
"768x1344" | "928x1664" | "1872x3312" => "9:16",
"1536x640" | "1904x816" | "3808x1632" => "21:9",
_ => fallback.trim(),
}
}
fn chatgpt_web_is_exact_1k_size(size: &str) -> bool {
matches!(
size.trim(),
"1024x1024"
| "1216x832"
| "832x1216"
| "1152x864"
| "864x1152"
| "1120x896"
| "896x1120"
| "1344x768"
| "768x1344"
| "1536x640"
)
}
fn parse_image_size_or_default(size: &str) -> (u64, u64) {
let Some((width, height)) = size.trim().split_once('x') else {
return (1024, 1024);
};
let width = width.trim().parse::<u64>().ok().filter(|value| *value > 0);
let height = height.trim().parse::<u64>().ok().filter(|value| *value > 0);
(width.unwrap_or(1024), height.unwrap_or(1024))
}
fn chatgpt_web_prompt_with_ratio(prompt: &str, ratio: &str) -> String {
let prompt = prompt.trim();
let ratio = ratio.trim();
if ratio.is_empty() || ratio == "1:1" {
return prompt.to_string();
}
format!("{prompt}\n\nSet the image aspect ratio to {ratio}.")
}
pub fn resolve_requested_openai_image_model_for_request(
parts: &http::request::Parts,
body_json: &Value,
@@ -804,9 +1133,9 @@ mod tests {
use serde_json::json;
use super::{
build_openai_image_provider_request_body, is_openai_image_stream_request,
normalize_openai_image_request, resolve_stream_spec, resolve_sync_spec,
OpenAiImageOperation,
build_chatgpt_web_image_request_body, build_openai_image_provider_request_body,
is_openai_image_stream_request, normalize_openai_image_request, resolve_stream_spec,
resolve_sync_spec, OpenAiImageOperation,
};
use crate::request::standard::{
apply_codex_openai_responses_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
@@ -1124,4 +1453,100 @@ mod tests {
Some("image_generation")
);
}
#[test]
fn chatgpt_web_accepts_1k_tier_and_1024_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
let by_tier = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"resolution": "1K",
"aspect_ratio": "16:9"
}),
None,
)
.expect("1K should pass");
assert_eq!(by_tier["size"], "1344x768");
assert_eq!(by_tier["ratio"], "16:9");
assert_eq!(by_tier["size_best_effort"], false);
let by_size = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"size": "1024x1024"
}),
None,
)
.expect("1024x1024 should pass");
assert_eq!(by_size["size"], "1024x1024");
}
#[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
for body in [
json!({"prompt":"draw","resolution":"2K"}),
json!({"prompt":"draw","size_tier":"4K"}),
json!({"prompt":"draw","size":"2048x2048"}),
] {
let err = build_chatgpt_web_image_request_body(&parts, &body, None)
.expect_err("oversized request should fail");
assert_eq!(err.status_code, 400);
assert_eq!(err.error_type, "invalid_request_error");
assert!(err.message.contains("ChatGPT-Web"));
assert!(err.message.contains("resolution"));
}
}
#[test]
fn chatgpt_web_accepts_smaller_size_as_best_effort() {
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": "512x512"
}),
None,
)
.expect("smaller size should pass");
assert_eq!(body["size"], "512x512");
assert_eq!(body["ratio"], "1:1");
assert_eq!(body["size_best_effort"], true);
}
#[test]
fn chatgpt_web_rejects_file_id_and_mask_inputs() {
let edit_parts = request_parts("/v1/images/edits", Some("application/json"));
let file_id_err = build_chatgpt_web_image_request_body(
&edit_parts,
&json!({
"prompt": "edit",
"image": {"file_id": "file_123"}
}),
None,
)
.expect_err("file_id should not be supported");
assert!(file_id_err.message.contains("file_id"));
let mask_err = build_chatgpt_web_image_request_body(
&edit_parts,
&json!({
"prompt": "edit",
"image": "data:image/png;base64,aW1hZ2U=",
"mask": "data:image/png;base64,bWFzaw=="
}),
None,
)
.expect_err("mask should not be supported");
assert!(mask_err.message.contains("mask"));
}
}

View File

@@ -9,9 +9,10 @@ pub use error::{ExecutionError, ExecutionErrorKind, ExecutionPhase};
pub use frame::{StreamFrame, StreamFramePayload, StreamFrameType};
pub use plan::{
ExecutionPlan, ExecutionTimeouts, ProxySnapshot, RequestBody, ResolvedTransportProfile,
EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER, EXECUTION_REQUEST_HTTP1_ONLY_HEADER,
TRANSPORT_BACKEND_HYPER_RUSTLS, TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO,
TRANSPORT_HTTP_MODE_HTTP1_ONLY, TRANSPORT_POOL_SCOPE_KEY,
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
EXECUTION_REQUEST_HTTP1_ONLY_HEADER, TRANSPORT_BACKEND_HYPER_RUSTLS,
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
TRANSPORT_POOL_SCOPE_KEY,
};
pub use result::{ExecutionResult, ExecutionTelemetry, ResponseBody};
pub use usage::{ExecutionStreamTerminalSummary, StandardizedUsage};

View File

@@ -5,6 +5,8 @@ use serde_json::Value;
pub const EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER: &str = "x-aether-execution-follow-redirects";
pub const EXECUTION_REQUEST_HTTP1_ONLY_HEADER: &str = "x-aether-execution-http1-only";
pub const EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER: &str =
"x-aether-execution-accept-invalid-certs";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]

View File

@@ -504,7 +504,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
bind_dn TEXT NOT NULL,
bind_password_encrypted TEXT,
base_dn TEXT NOT NULL,
user_search_filter TEXT NOT NULL DEFAULT '(uid={username})',
user_search_filter VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
username_attr VARCHAR(50) NOT NULL DEFAULT 'uid',
email_attr VARCHAR(50) NOT NULL DEFAULT 'mail',
display_name_attr VARCHAR(50) NOT NULL DEFAULT 'cn',

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS public.auth_modules (
id character varying(36) PRIMARY KEY,
module_type character varying(128) NOT NULL UNIQUE,
enabled boolean DEFAULT true NOT NULL,
config json DEFAULT '{}'::json NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
bind_dn TEXT NOT NULL,
bind_password_encrypted TEXT,
base_dn TEXT NOT NULL,
user_search_filter TEXT NOT NULL DEFAULT '(uid={username})',
user_search_filter VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
username_attr VARCHAR(50) NOT NULL DEFAULT 'uid',
email_attr VARCHAR(50) NOT NULL DEFAULT 'mail',
display_name_attr VARCHAR(50) NOT NULL DEFAULT 'cn',

View File

@@ -48,7 +48,7 @@ CREATE TABLE IF NOT EXISTS ldap_configs (
`bind_dn` LONGTEXT NOT NULL,
`bind_password_encrypted` LONGTEXT,
`base_dn` LONGTEXT NOT NULL,
`user_search_filter` LONGTEXT NOT NULL DEFAULT '(uid={username})',
`user_search_filter` VARCHAR(512) NOT NULL DEFAULT '(uid={username})',
`username_attr` VARCHAR(50) NOT NULL DEFAULT 'uid',
`email_attr` VARCHAR(50) NOT NULL DEFAULT 'mail',
`display_name_attr` VARCHAR(50) NOT NULL DEFAULT 'cn',

View File

@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS public.ldap_configs (
bind_dn text NOT NULL,
bind_password_encrypted text,
base_dn text NOT NULL,
user_search_filter text DEFAULT '(uid={username})' NOT NULL,
user_search_filter character varying(512) DEFAULT '(uid={username})' NOT NULL,
username_attr character varying(50) DEFAULT 'uid' NOT NULL,
email_attr character varying(50) DEFAULT 'mail' NOT NULL,
display_name_attr character varying(50) DEFAULT 'cn' NOT NULL,

View File

@@ -180,7 +180,8 @@ type = "long_text"
[[table.ldap_configs.columns]]
name = "user_search_filter"
type = "long_text"
type = "text"
length = 512
default = "(uid={username})"
[[table.ldap_configs.columns]]

View File

@@ -284,6 +284,18 @@ mod tests {
let request_zero = format!("request-daily-zero-{suffix}");
let request_outside = format!("request-daily-outside-{suffix}");
let stale_ledger_id = format!("stale-ledger-{suffix}");
let unique_offset = chrono::Utc::now()
.timestamp_nanos_opt()
.unwrap_or_default()
.rem_euclid(10_000_000);
let window_start = 4_100_000_000_i64 + unique_offset * 1_000;
let window_end = window_start + 200;
let first_finalized_at = window_start;
let last_finalized_at = window_start + 100;
let zero_finalized_at = window_start + 150;
let outside_finalized_at = window_end;
let seed_created_at = window_start - 100;
let aggregated_at = window_end + 100;
sqlx::query(
r#"
@@ -309,19 +321,31 @@ INSERT INTO `usage` (
cache_read_input_tokens, finalized_at, created_at_unix_ms, updated_at_unix_secs
) VALUES
(?, 'wrong-wallet', 'provider', 'model', 'completed', 'pending',
1.25, 10, 20, 3, 4, 4099999900, 4099999900000, 4099999900),
1.25, 10, 20, 3, 4, ?, ?, ?),
(?, NULL, 'provider', 'model', 'completed', 'pending',
2.00, 5, 7, 1, 2, 4099999901, 4099999901000, 4099999901),
2.00, 5, 7, 1, 2, ?, ?, ?),
(?, NULL, 'provider', 'model', 'completed', 'pending',
0.00, 100, 100, 0, 0, 4099999902, 4099999902000, 4099999902),
0.00, 100, 100, 0, 0, ?, ?, ?),
(?, NULL, 'provider', 'model', 'completed', 'pending',
9.00, 50, 50, 0, 0, 4099999903, 4099999903000, 4099999903)
9.00, 50, 50, 0, 0, ?, ?, ?)
"#,
)
.bind(&request_one)
.bind(seed_created_at)
.bind(seed_created_at * 1000)
.bind(seed_created_at)
.bind(&request_two)
.bind(seed_created_at + 1)
.bind((seed_created_at + 1) * 1000)
.bind(seed_created_at + 1)
.bind(&request_zero)
.bind(seed_created_at + 2)
.bind((seed_created_at + 2) * 1000)
.bind(seed_created_at + 2)
.bind(&request_outside)
.bind(seed_created_at + 3)
.bind((seed_created_at + 3) * 1000)
.bind(seed_created_at + 3)
.execute(backend.pool())
.await
.expect("usage should seed");
@@ -331,20 +355,32 @@ INSERT INTO `usage` (
INSERT INTO usage_settlement_snapshots (
request_id, billing_status, wallet_id, finalized_at, created_at, updated_at
) VALUES
(?, 'settled', ?, 4100000000, 4100000000, 4100000000),
(?, 'settled', ?, 4100000100, 4100000100, 4100000100),
(?, 'settled', ?, 4100000150, 4100000150, 4100000150),
(?, 'settled', ?, 4100000200, 4100000200, 4100000200)
(?, 'settled', ?, ?, ?, ?),
(?, 'settled', ?, ?, ?, ?),
(?, 'settled', ?, ?, ?, ?),
(?, 'settled', ?, ?, ?, ?)
"#,
)
.bind(&request_one)
.bind(&wallet_id)
.bind(first_finalized_at)
.bind(first_finalized_at)
.bind(first_finalized_at)
.bind(&request_two)
.bind(&wallet_id)
.bind(last_finalized_at)
.bind(last_finalized_at)
.bind(last_finalized_at)
.bind(&request_zero)
.bind(&wallet_id)
.bind(zero_finalized_at)
.bind(zero_finalized_at)
.bind(zero_finalized_at)
.bind(&request_outside)
.bind(&wallet_id)
.bind(outside_finalized_at)
.bind(outside_finalized_at)
.bind(outside_finalized_at)
.execute(backend.pool())
.await
.expect("settlement snapshots should seed");
@@ -355,12 +391,15 @@ INSERT INTO wallet_daily_usage_ledgers (
id, wallet_id, billing_date, billing_timezone, total_cost_usd,
total_requests, input_tokens, output_tokens, cache_creation_tokens,
cache_read_tokens, aggregated_at, created_at, updated_at
) VALUES (?, ?, '2026-05-03', ?, 7.0, 3, 1, 1, 0, 0, 4099999999, 4099999999, 4099999999)
) VALUES (?, ?, '2026-05-03', ?, 7.0, 3, 1, 1, 0, 0, ?, ?, ?)
"#,
)
.bind(&stale_ledger_id)
.bind(&stale_wallet_id)
.bind(&timezone)
.bind(seed_created_at)
.bind(seed_created_at)
.bind(seed_created_at)
.execute(backend.pool())
.await
.expect("stale ledger should seed");
@@ -369,9 +408,9 @@ INSERT INTO wallet_daily_usage_ledgers (
.aggregate_wallet_daily_usage(&WalletDailyUsageAggregationInput {
billing_date: "2026-05-03".to_string(),
billing_timezone: timezone.clone(),
window_start_unix_secs: 4_100_000_000,
window_end_unix_secs: 4_100_000_200,
aggregated_at_unix_secs: 4_100_000_300,
window_start_unix_secs: window_start as u64,
window_end_unix_secs: window_end as u64,
aggregated_at_unix_secs: aggregated_at as u64,
})
.await
.expect("wallet daily usage aggregation should run");
@@ -425,9 +464,9 @@ WHERE wallet_id = ?
assert_eq!(ledger.4, 27);
assert_eq!(ledger.5, 4);
assert_eq!(ledger.6, 6);
assert_eq!(ledger.7, Some(4_100_000_000));
assert_eq!(ledger.8, Some(4_100_000_100));
assert_eq!(ledger.9, 4_100_000_300);
assert_eq!(ledger.7, Some(first_finalized_at));
assert_eq!(ledger.8, Some(last_finalized_at));
assert_eq!(ledger.9, aggregated_at);
let stale_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_daily_usage_ledgers WHERE id = ?")
@@ -463,6 +502,18 @@ WHERE wallet_id = ?
.await
.expect("mysql migrations should run");
for sql in [
"DELETE FROM stats_daily WHERE `date` = 0",
"DELETE FROM stats_hourly WHERE hour_utc = 3600",
"DELETE FROM usage_settlement_snapshots WHERE request_id LIKE 'request-daily-%' OR request_id LIKE 'stats-%'",
"DELETE FROM `usage` WHERE request_id LIKE 'request-%' OR request_id LIKE 'export-request-%' OR request_id LIKE 'stats-%'",
] {
sqlx::query(sql)
.execute(backend.pool())
.await
.expect("stats smoke cleanup should run");
}
sqlx::query(
r#"
INSERT INTO `usage` (

View File

@@ -56,7 +56,7 @@ async fn next_mysql_stats_hourly_bucket(
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 3600000) * 3600)
SELECT CAST(MIN(FLOOR(created_at_unix_ms / 3600000) * 3600) AS SIGNED)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
@@ -88,7 +88,7 @@ async fn next_mysql_stats_daily_bucket(
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 86400000) * 86400)
SELECT CAST(MIN(FLOOR(created_at_unix_ms / 86400000) * 86400) AS SIGNED)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
@@ -106,19 +106,19 @@ WHERE created_at_unix_ms >= ?
const MYSQL_STATS_AGGREGATE_SQL: &str = r#"
SELECT
COUNT(*) AS total_requests,
COALESCE(SUM(CASE
CAST(COUNT(*) AS SIGNED) AS total_requests,
CAST(COALESCE(SUM(CASE
WHEN status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
THEN 1 ELSE 0 END), 0) AS error_requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
THEN 1 ELSE 0 END), 0) AS SIGNED) AS error_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS SIGNED) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS SIGNED) AS output_tokens,
CAST(COALESCE(SUM(cache_creation_input_tokens), 0) AS SIGNED) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS SIGNED) AS cache_read_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0.0) AS DOUBLE) AS total_cost,
CAST(COALESCE(SUM(actual_total_cost_usd), 0.0) AS DOUBLE) AS actual_total_cost,
CAST(COALESCE(AVG(response_time_ms), 0.0) AS DOUBLE) AS avg_response_time_ms
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?

View File

@@ -95,12 +95,12 @@ WHERE ledgers.billing_date = $1
const MYSQL_SELECT_WALLET_DAILY_USAGE_AGGREGATES_SQL: &str = r#"
SELECT
usage_settlement_snapshots.wallet_id AS wallet_id,
COUNT(*) AS total_requests,
COALESCE(SUM(`usage`.total_cost_usd), 0) AS total_cost_usd,
COALESCE(SUM(`usage`.input_tokens), 0) AS input_tokens,
COALESCE(SUM(`usage`.output_tokens), 0) AS output_tokens,
COALESCE(SUM(`usage`.cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(`usage`.cache_read_input_tokens), 0) AS cache_read_tokens,
CAST(COUNT(*) AS SIGNED) AS total_requests,
CAST(COALESCE(SUM(`usage`.total_cost_usd), 0) AS DOUBLE) AS total_cost_usd,
CAST(COALESCE(SUM(`usage`.input_tokens), 0) AS SIGNED) AS input_tokens,
CAST(COALESCE(SUM(`usage`.output_tokens), 0) AS SIGNED) AS output_tokens,
CAST(COALESCE(SUM(`usage`.cache_creation_input_tokens), 0) AS SIGNED) AS cache_creation_tokens,
CAST(COALESCE(SUM(`usage`.cache_read_input_tokens), 0) AS SIGNED) AS cache_read_tokens,
MIN(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS first_finalized_at,
MAX(COALESCE(usage_settlement_snapshots.finalized_at, `usage`.finalized_at)) AS last_finalized_at
FROM `usage`

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260505130000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260506000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT

View File

@@ -1826,6 +1826,7 @@ fn mysql_value_to_json(row: &sqlx::mysql::MySqlRow, index: usize) -> Result<Valu
}
match raw.type_info().name().to_ascii_uppercase().as_str() {
"BOOL" | "BOOLEAN" => Ok(Value::Bool(row.try_get::<bool, _>(index).map_sql_err()?)),
"TINYINT" | "TINY" | "SMALLINT" | "SHORT" | "MEDIUMINT" | "INT24" | "INT" | "INTEGER"
| "LONG" | "BIGINT" | "LONGLONG" | "YEAR" => {
Ok(Value::from(row.try_get::<i64, _>(index).map_sql_err()?))
@@ -1869,8 +1870,8 @@ mod tests {
export_postgres_core_jsonl, export_sqlite_core_jsonl, import_mysql_jsonl,
import_postgres_jsonl, import_sqlite_jsonl, mysql_core_export_domains,
normalize_postgres_import_payload, postgres_core_export_domains,
sqlite_core_export_domains, DataExportManifest, DataExportRecord, ExportDomain, ExportRow,
PostgresImportColumn,
sqlite_core_export_domains, DataExportManifest, DataExportRecord, DataImportPlan,
ExportDomain, ExportRow, PostgresImportColumn,
};
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
use crate::lifecycle::migrate::{
@@ -2266,7 +2267,10 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let endpoint_id = format!("export-endpoint-{suffix}");
let global_model_id = format!("export-global-model-{suffix}");
let model_id = format!("export-model-{suffix}");
let billing_rule_id = format!("export-billing-rule-{suffix}");
let collector_id = format!("export-collector-{suffix}");
let config_id = format!("export-config-{suffix}");
let config_key = format!("export.config.{suffix}");
let wallet_id = format!("export-wallet-{suffix}");
let request_id = format!("export-request-{suffix}");
@@ -2332,22 +2336,24 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
sqlx::query(
"INSERT INTO billing_rules (id, global_model_id, name, task_type, expression, variables, dimension_mappings, is_enabled, created_at, updated_at) VALUES ($1, $2, 'Rule One', 'chat', 'input_tokens * 0.01', '{}', '{\"input\":\"input_tokens\"}', TRUE, to_timestamp(1), to_timestamp(2))",
)
.bind("billing-rule-1")
.bind(&billing_rule_id)
.bind(&global_model_id)
.execute(&pool)
.await
.expect("billing rule should seed");
sqlx::query(
"INSERT INTO dimension_collectors (id, api_format, task_type, dimension_name, source_type, value_type, transform_expression, priority, is_enabled, created_at, updated_at) VALUES ($1, 'openai', 'chat', 'input_tokens', 'computed', 'float', 'usage.input_tokens', 10, TRUE, to_timestamp(1), to_timestamp(2))",
"INSERT INTO dimension_collectors (id, api_format, task_type, dimension_name, source_type, value_type, transform_expression, priority, is_enabled, created_at, updated_at) VALUES ($1, 'openai', 'chat', $2, 'computed', 'float', 'usage.input_tokens', 10, TRUE, to_timestamp(1), to_timestamp(2))",
)
.bind("collector-1")
.bind(&collector_id)
.bind(format!("input_tokens_{suffix}"))
.execute(&pool)
.await
.expect("dimension collector should seed");
sqlx::query(
"INSERT INTO system_configs (id, key, value, created_at, updated_at) VALUES ($1, 'billing.enabled', 'true', to_timestamp(1), to_timestamp(2))",
"INSERT INTO system_configs (id, key, value, created_at, updated_at) VALUES ($1, $2, 'true', to_timestamp(1), to_timestamp(2))",
)
.bind(&config_id)
.bind(&config_key)
.execute(&pool)
.await
.expect("system config should seed");
@@ -2417,7 +2423,7 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
let imported = import_sqlite_jsonl(&target_pool, &encoded)
.await
.expect("sqlite import should load postgres exported rows");
assert_eq!(imported, 12);
assert_eq!(imported, import_plan_row_count(&import_plan));
let imported_api_key =
sqlx::query_as::<_, (String,)>("SELECT key_encrypted FROM api_keys WHERE id = $1")
@@ -2602,10 +2608,21 @@ VALUES ('request-1', 'request-1', 'user-1', 'Provider One', 'gpt-test', 'complet
}
fn unique_suffix() -> String {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{:013x}", nanos & 0x1fff_ffff_fffff)
.as_nanos() as u64;
let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("{:016x}", nanos ^ counter.rotate_left(17))
}
fn import_plan_row_count(plan: &DataImportPlan) -> usize {
plan.manifest
.domains
.iter()
.map(|domain| plan.rows(*domain).len())
.sum()
}
}

View File

@@ -291,6 +291,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260502000000,
20260505000000,
20260505130000,
20260506000000,
]
);
}
@@ -1010,6 +1011,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260502000000,
20260505000000,
20260505130000,
20260506000000,
]
);
}

View File

@@ -184,6 +184,9 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
"openai:responses" | "openai:responses:compact" | "openai:image"
)
}
"chatgpt_web" => {
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
}
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
"kiro" => {
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "claude:messages"
@@ -324,6 +327,38 @@ mod tests {
);
}
#[tokio::test]
async fn allows_chatgpt_web_oauth_and_bearer_for_openai_image_only() {
let mut oauth = sample_row("chatgpt-web-oauth", "openai:image", "gpt-image-2", 10);
oauth.provider_type = "chatgpt_web".to_string();
oauth.key_auth_type = "oauth".to_string();
let mut bearer = sample_row("chatgpt-web-bearer", "openai:image", "gpt-image-2", 20);
bearer.provider_type = "chatgpt_web".to_string();
bearer.key_auth_type = "bearer".to_string();
let mut api_key = sample_row("chatgpt-web-api-key", "openai:image", "gpt-image-2", 30);
api_key.provider_type = "chatgpt_web".to_string();
api_key.key_auth_type = "api_key".to_string();
let mut responses = sample_row("chatgpt-web-responses", "openai:responses", "gpt-5", 40);
responses.provider_type = "chatgpt_web".to_string();
responses.key_auth_type = "oauth".to_string();
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
oauth, bearer, api_key, responses,
]);
let rows = repository
.list_for_exact_api_format_and_requested_model("openai:image", "gpt-image-2")
.await
.expect("list should succeed");
assert_eq!(
rows.iter()
.map(|row| row.provider_id.as_str())
.collect::<Vec<_>>(),
vec!["chatgpt-web-oauth", "chatgpt-web-bearer"]
);
}
#[tokio::test]
async fn filters_by_exact_api_format_only() {
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![

View File

@@ -289,6 +289,9 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
"openai:responses" | "openai:responses:compact" | "openai:image"
)
}
"chatgpt_web" => {
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
}
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
"kiro" => {
api_format == "claude:messages"

View File

@@ -80,6 +80,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
)
OR (
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
AND LOWER($3) = 'openai:image'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'claude_code'
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
@@ -117,6 +122,7 @@ WHERE p.is_active = TRUE
)
OR (
LOWER(BTRIM(p.provider_type)) NOT IN (
'chatgpt_web',
'claude_code',
'codex',
'gemini_cli',
@@ -257,6 +263,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
)
OR (
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
AND LOWER($4) = 'openai:image'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'claude_code'
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
@@ -294,6 +305,7 @@ WHERE p.is_active = TRUE
)
OR (
LOWER(BTRIM(p.provider_type)) NOT IN (
'chatgpt_web',
'claude_code',
'codex',
'gemini_cli',
@@ -433,6 +445,11 @@ WHERE p.is_active = TRUE
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
AND LOWER($6) IN ('openai:responses', 'openai:responses:compact', 'openai:image')
)
OR (
LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')
AND LOWER($6) = 'openai:image'
)
OR (
LOWER(BTRIM(p.provider_type)) = 'claude_code'
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
@@ -470,6 +487,7 @@ WHERE p.is_active = TRUE
)
OR (
LOWER(BTRIM(p.provider_type)) NOT IN (
'chatgpt_web',
'claude_code',
'codex',
'gemini_cli',
@@ -1007,6 +1025,8 @@ mod tests {
use super::{
parse_provider_model_mappings, parse_string_list, requested_model_selection_page_sql,
requested_model_selection_sql, SqlxMinimalCandidateSelectionReadRepository,
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL, LIST_FOR_EXACT_API_FORMAT_SQL,
LIST_POOL_KEYS_FOR_GROUP_SQL,
};
use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory};
use crate::repository::candidate_selection::StoredProviderModelMapping;
@@ -1042,6 +1062,21 @@ mod tests {
assert!(!sql.contains("AND gm.name = $2\n AND"));
}
#[test]
fn candidate_selection_sql_allows_chatgpt_web_image_auth() {
let requested_model_sql = requested_model_selection_sql();
for sql in [
LIST_FOR_EXACT_API_FORMAT_SQL,
LIST_FOR_EXACT_API_FORMAT_AND_GLOBAL_MODEL_SQL,
LIST_POOL_KEYS_FOR_GROUP_SQL,
requested_model_sql.as_str(),
] {
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'chatgpt_web'"));
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer')"));
assert!(sql.contains("'chatgpt_web',"));
}
}
#[test]
fn requested_model_selection_page_sql_adds_limit_and_offset() {
let sql = requested_model_selection_page_sql();

View File

@@ -289,6 +289,9 @@ fn key_auth_channel_matches(row: &CandidateSelectionRow, api_format: &str) -> bo
"openai:responses" | "openai:responses:compact" | "openai:image"
)
}
"chatgpt_web" => {
matches!(auth_type.as_str(), "oauth" | "bearer") && api_format == "openai:image"
}
"claude_code" => auth_type == "oauth" && api_format == "claude:messages",
"kiro" => {
api_format == "claude:messages"
@@ -666,6 +669,25 @@ mod tests {
.expect("pool keys should load");
assert_eq!(pool_keys.len(), 1);
assert_eq!(pool_keys[0].key_id, "key-2");
let image_rows = repository
.list_for_exact_api_format_and_requested_model_page(
&StoredRequestedModelCandidateRowsQuery {
api_format: "openai:image".to_string(),
requested_model_name: "gpt-image-2".to_string(),
offset: 0,
limit: 10,
},
)
.await
.expect("chatgpt web image rows should load");
assert_eq!(
image_rows
.iter()
.map(|row| row.key_id.as_str())
.collect::<Vec<_>>(),
vec!["key-chatgpt-web-oauth", "key-chatgpt-web-bearer"]
);
}
async fn seed_candidate_selection(pool: &sqlx::SqlitePool) {
@@ -688,10 +710,33 @@ VALUES
('key-1', 'provider-1', 'Key One', 'api_key', '["openai:chat"]', 10, 1, 1, 1),
('key-2', 'provider-1', 'Key Two', 'api_key', '["openai:chat"]', 20, 1, 1, 1);
INSERT INTO providers (
id, name, provider_type, provider_priority, is_active, created_at, updated_at
)
VALUES ('provider-chatgpt-web', 'ChatGPT Web', 'chatgpt_web', 20, 1, 1, 1);
INSERT INTO provider_endpoints (
id, provider_id, name, base_url, api_format, is_active, created_at, updated_at
)
VALUES (
'endpoint-chatgpt-web', 'provider-chatgpt-web', 'ChatGPT Web Image',
'https://chatgpt.com', 'openai:image', 1, 1, 1
);
INSERT INTO provider_api_keys (
id, provider_id, name, auth_type, api_formats, internal_priority, is_active, created_at, updated_at
)
VALUES
('key-chatgpt-web-oauth', 'provider-chatgpt-web', 'OAuth', 'oauth', '["openai:image"]', 10, 1, 1, 1),
('key-chatgpt-web-bearer', 'provider-chatgpt-web', 'Bearer', 'bearer', '["openai:image"]', 20, 1, 1, 1),
('key-chatgpt-web-api-key', 'provider-chatgpt-web', 'API Key', 'api_key', '["openai:image"]', 30, 1, 1, 1);
INSERT INTO global_models (
id, name, config, is_active, created_at, updated_at
)
VALUES ('global-1', 'gpt-5', '{"model_mappings":["alias-global"],"streaming":true}', 1, 1, 1);
VALUES
('global-1', 'gpt-5', '{"model_mappings":["alias-global"],"streaming":true}', 1, 1, 1),
('global-image-1', 'gpt-image-2', NULL, 1, 1, 1);
INSERT INTO models (
id, provider_id, global_model_id, provider_model_name, provider_model_mappings,
@@ -701,6 +746,10 @@ VALUES (
'model-1', 'provider-1', 'global-1', 'provider-model',
'[{"name":"alias-provider","api_formats":["openai:chat"],"priority":1}]',
1, 1, 1, 1, 1
),
(
'model-chatgpt-web-image', 'provider-chatgpt-web', 'global-image-1', 'gpt-image-2',
NULL, 1, 1, 1, 1, 1
);
"#,
)

View File

@@ -304,8 +304,8 @@ SET total_requests = 0,
SELECT
api_key_id,
COUNT(*) AS total_requests,
COALESCE(SUM(total_tokens), 0) AS total_tokens,
COALESCE(SUM(total_cost_usd), 0) AS total_cost_usd,
CAST(COALESCE(SUM(total_tokens), 0) AS SIGNED) AS total_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE) AS total_cost_usd,
MAX(updated_at_unix_secs) AS last_used_at
FROM `usage`
WHERE api_key_id IS NOT NULL AND api_key_id <> ''

View File

@@ -3483,8 +3483,13 @@ mod tests {
})
.await
.expect("admin wallets should list");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].total_adjusted, 3.0);
let wallet_item = page
.items
.iter()
.find(|item| item.id == "wallet-1")
.expect("seeded wallet should be listed");
assert!(page.total >= 1);
assert_eq!(wallet_item.total_adjusted, 3.0);
let orders = repository
.list_admin_payment_orders(&AdminPaymentOrderListQuery {

View File

@@ -51,6 +51,18 @@ pub const GENERIC_PROVIDER_OAUTH_TEMPLATES: &[GenericProviderOAuthTemplate] = &[
use_pkce: true,
uses_json_payload: false,
},
GenericProviderOAuthTemplate {
provider_type: "chatgpt_web",
display_name: "ChatGPT Web",
authorize_url: "https://auth.openai.com/oauth/authorize",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
scopes: &["openid", "email", "profile", "offline_access"],
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
uses_json_payload: false,
},
GenericProviderOAuthTemplate {
provider_type: "gemini_cli",
display_name: "GeminiCli",

View File

@@ -26,7 +26,7 @@ impl ProviderOAuthService {
.with_adapter(Arc::new(KiroProviderOAuthAdapter::default()))
.with_adapter(Arc::new(CodexProviderOAuthAdapter::default()))
.with_adapter(Arc::new(AntigravityProviderOAuthAdapter::default()));
for provider_type in ["claude_code", "gemini_cli"] {
for provider_type in ["claude_code", "chatgpt_web", "gemini_cli"] {
if let Some(adapter) = GenericProviderOAuthAdapter::for_provider_type(provider_type) {
service = service.with_adapter(Arc::new(adapter));
}
@@ -121,7 +121,14 @@ mod tests {
fn builtin_provider_service_registers_supported_provider_types() {
let service = ProviderOAuthService::with_builtin_adapters();
for provider_type in ["claude_code", "codex", "gemini_cli", "antigravity", "kiro"] {
for provider_type in [
"claude_code",
"codex",
"chatgpt_web",
"gemini_cli",
"antigravity",
"kiro",
] {
assert!(
service.adapter(provider_type).is_ok(),
"{provider_type} adapter should be registered"

View File

@@ -22,7 +22,17 @@ pub fn openai_image_transport_unsupported_reason(
transport: &GatewayProviderTransportSnapshot,
api_format: &str,
) -> Option<&'static str> {
local_standard_transport_unsupported_reason_with_network(transport, api_format)
let reason = local_standard_transport_unsupported_reason_with_network(transport, api_format);
if reason == Some("transport_provider_type_unsupported")
&& transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("chatgpt_web")
{
return None;
}
reason
}
pub fn resolve_openai_image_auth(
@@ -68,7 +78,7 @@ mod tests {
use super::{
build_openai_image_headers, build_openai_image_upstream_url,
ProviderOpenAiImageHeadersInput,
openai_image_transport_unsupported_reason, ProviderOpenAiImageHeadersInput,
};
use crate::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
@@ -137,6 +147,17 @@ mod tests {
assert_eq!(url, "https://api.openai.com/v1/responses?trace=1");
}
#[test]
fn chatgpt_web_is_supported_by_dedicated_openai_image_transport_policy() {
let mut transport = sample_transport();
transport.provider.provider_type = "chatgpt_web".to_string();
assert_eq!(
openai_image_transport_unsupported_reason(&transport, "openai:image"),
None
);
}
#[test]
fn builds_json_eventstream_headers_and_applies_rules() {
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {

View File

@@ -95,6 +95,18 @@ const CODEX_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTempla
],
};
const CHATGPT_WEB_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "chatgpt_web",
version: 1,
base_url: "https://chatgpt.com",
endpoints: &[FixedProviderEndpointTemplate {
item_key: "openai:image",
api_format: "openai:image",
custom_path: None,
config_defaults: FORCE_STREAM_ENDPOINT_CONFIG_DEFAULTS,
}],
};
const KIRO_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate {
provider_type: "kiro",
version: 1,
@@ -154,7 +166,13 @@ const ANTIGRAVITY_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProvider
pub fn provider_type_is_fixed(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "kiro" | "codex" | "gemini_cli" | "antigravity" | "vertex_ai"
"claude_code"
| "kiro"
| "codex"
| "chatgpt_web"
| "gemini_cli"
| "antigravity"
| "vertex_ai"
)
}
@@ -167,6 +185,7 @@ pub fn fixed_provider_key_inherits_api_formats(
let auth_type = auth_type.trim().to_ascii_lowercase();
provider_type_is_fixed(&provider_type)
&& (auth_type == "oauth"
|| provider_type == "chatgpt_web" && auth_type == "bearer"
|| provider_type == "kiro"
&& auth_type == "bearer"
&& decrypted_auth_config
@@ -177,7 +196,7 @@ pub fn fixed_provider_key_inherits_api_formats(
pub fn provider_type_enables_format_conversion_by_default(provider_type: &str) -> bool {
matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"claude_code" | "kiro" | "codex" | "antigravity" | "vertex_ai"
"claude_code" | "kiro" | "codex" | "chatgpt_web" | "antigravity" | "vertex_ai"
)
}
@@ -185,6 +204,7 @@ pub fn fixed_provider_template(provider_type: &str) -> Option<&'static FixedProv
match provider_type.trim().to_ascii_lowercase().as_str() {
"claude_code" => Some(&CLAUDE_CODE_FIXED_PROVIDER_TEMPLATE),
"codex" => Some(&CODEX_FIXED_PROVIDER_TEMPLATE),
"chatgpt_web" => Some(&CHATGPT_WEB_FIXED_PROVIDER_TEMPLATE),
"kiro" => Some(&KIRO_FIXED_PROVIDER_TEMPLATE),
"gemini_cli" => Some(&GEMINI_CLI_FIXED_PROVIDER_TEMPLATE),
"vertex_ai" => Some(&VERTEX_AI_FIXED_PROVIDER_TEMPLATE),
@@ -207,21 +227,27 @@ pub fn fixed_provider_endpoint_template_by_api_format(
pub fn provider_type_supports_model_fetch(provider_type: &str) -> bool {
!matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"vertex_ai" | "antigravity" | "codex" | "kiro" | "claude_code"
"vertex_ai" | "antigravity" | "codex" | "chatgpt_web" | "kiro" | "claude_code"
)
}
pub fn provider_type_supports_local_openai_chat_transport(provider_type: &str) -> bool {
!matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"antigravity" | "claude_code" | "codex" | "gemini_cli" | "kiro" | "vertex_ai"
"antigravity"
| "claude_code"
| "codex"
| "chatgpt_web"
| "gemini_cli"
| "kiro"
| "vertex_ai"
)
}
pub fn provider_type_supports_local_same_format_transport(provider_type: &str) -> bool {
!matches!(
provider_type.trim().to_ascii_lowercase().as_str(),
"antigravity" | "claude_code" | "kiro" | "vertex_ai"
"antigravity" | "chatgpt_web" | "claude_code" | "kiro" | "vertex_ai"
)
}
@@ -276,6 +302,17 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
}),
"chatgpt_web" => Some(ProviderOAuthTemplate {
provider_type: "chatgpt_web",
display_name: "ChatGPT Web",
authorize_url: "https://auth.openai.com/oauth/authorize",
token_url: "https://auth.openai.com/oauth/token",
client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
client_secret: "",
scopes: &["openid", "email", "profile", "offline_access"],
redirect_uri: "http://localhost:1455/auth/callback",
use_pkce: true,
}),
"gemini_cli" => Some(ProviderOAuthTemplate {
provider_type: "gemini_cli",
display_name: "GeminiCli",
@@ -312,15 +349,20 @@ pub fn provider_type_admin_oauth_template(provider_type: &str) -> Option<Provide
}
}
pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] =
&["claude_code", "codex", "gemini_cli", "antigravity"];
pub const ADMIN_PROVIDER_OAUTH_TEMPLATE_TYPES: &[&str] = &[
"claude_code",
"codex",
"chatgpt_web",
"gemini_cli",
"antigravity",
];
#[cfg(test)]
mod tests {
use super::{
fixed_provider_endpoint_template_by_api_format, fixed_provider_key_inherits_api_formats,
fixed_provider_template, provider_type_supports_local_embedding_transport,
FixedProviderEndpointConfigValue,
provider_type_supports_local_same_format_transport, FixedProviderEndpointConfigValue,
};
#[test]
@@ -357,11 +399,47 @@ mod tests {
);
}
#[test]
fn chatgpt_web_fixed_provider_template_only_exposes_openai_image() {
let template =
fixed_provider_template("chatgpt_web").expect("chatgpt_web template should exist");
assert_eq!(template.base_url, "https://chatgpt.com");
assert_eq!(template.version, 1);
assert_eq!(
template
.endpoints
.iter()
.map(|item| item.api_format)
.collect::<Vec<_>>(),
vec!["openai:image"]
);
let image_template =
fixed_provider_endpoint_template_by_api_format("chatgpt_web", "openai:image")
.expect("chatgpt_web image endpoint should exist");
assert_eq!(
image_template
.config_defaults
.iter()
.map(|item| (item.key, item.value))
.collect::<Vec<_>>(),
vec![(
"upstream_stream_policy",
FixedProviderEndpointConfigValue::String("force_stream")
)]
);
}
#[test]
fn fixed_provider_key_inheritance_keeps_oauth_and_kiro_configured_bearer_keys_open() {
assert!(fixed_provider_key_inherits_api_formats(
"codex", "oauth", None
));
assert!(fixed_provider_key_inherits_api_formats(
"chatgpt_web",
"oauth",
None
));
assert!(fixed_provider_key_inherits_api_formats(
"kiro",
"bearer",
@@ -375,6 +453,13 @@ mod tests {
));
}
#[test]
fn chatgpt_web_does_not_use_generic_same_format_transport() {
assert!(!provider_type_supports_local_same_format_transport(
"chatgpt_web"
));
}
#[test]
fn provider_type_supports_only_matching_embedding_formats() {
for (provider_type, api_format) in [