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

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