mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(image): 接入 ChatGPT Web 生图反代
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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` (
|
||||
|
||||
@@ -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 < ?
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
);
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -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 <> ''
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user