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

@@ -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 {