refactor: tighten runtime configuration

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-28 01:30:41 +08:00
co-authored by Codex
parent 60e788e1b0
commit e3971df6f0
39 changed files with 412 additions and 725 deletions
+6 -9
View File
@@ -99,16 +99,15 @@ cargo run
## 配置重点
常用配置都在 `config.yaml` 中:
`config.yaml` 只放启动级配置,模型由客户端请求决定,网关不会兜底默认模型;`auth`、`quota`、模型别名和模型账号路由都不再从 YAML 读取:
```yaml
model:
default: gpt-5.5
server:
host: 0.0.0.0
port: 8080
auth:
max_concurrent_per_account: 3
request_interval_ms: 50
rotation_strategy: least_used
api:
base_url: https://chatgpt.com/backend-api
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
@@ -117,8 +116,6 @@ logging:
directory: .runtime/logs
```
本项目不依赖 `local.yaml`。本地敏感值、数据库路径、日志路径和管理员默认密码都应直接在 `config.yaml` 中配置。
## 开发验证
后端:
-31
View File
@@ -3,35 +3,6 @@ server:
port: 8080
api:
base_url: 'https://chatgpt.com/backend-api'
model:
default: gpt-5.5
default_reasoning_effort: null
default_service_tier: null
aliases: {}
account_routes: {}
auth:
refresh_margin_seconds: 300
refresh_enabled: true
refresh_concurrency: 2
max_concurrent_per_account: 3
request_interval_ms: 50
rotation_strategy: least_used
tier_priority: []
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: 'https://auth.openai.com/oauth/authorize'
oauth_token_endpoint: 'https://auth.openai.com/oauth/token'
quota:
refresh_interval_minutes: 5
warning_thresholds:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
usage_stats:
history_retention_days: null
database:
url: 'sqlite://.runtime/data/codex-proxy-rs.sqlite'
tls:
@@ -93,5 +64,3 @@ logging:
directory: .runtime/logs
retention_days: 14
enabled: true
capacity: 2000
capture_body: false
+3 -8
View File
@@ -84,15 +84,12 @@
- 服务监听地址和端口。
- 数据库 URL。
- API Key pepper 文件路径。
- 上游基础地址。
- OAuth endpoint。
- 日志目录。
- 首次启动管理员默认值。
后台设置页改动的是运行态业务配置,更适合入库,例如:
后台设置页改动的是需要人工决策的运行态配置,更适合入库,例如:
- 默认模型。
- 模型别名。
- 提前刷新秒数。
- 刷新并发数。
@@ -106,7 +103,6 @@
```sql
create table runtime_settings (
id integer primary key check (id = 1),
default_model text not null,
model_aliases_json text not null default '{}',
refresh_margin_seconds integer not null check (refresh_margin_seconds > 0),
refresh_concurrency integer not null check (refresh_concurrency > 0),
@@ -326,16 +322,15 @@ create table usage_time_buckets (
- server。
- database。
- API Key pepper 文件路径。
- API base URL。
- OAuth endpoint。
- TLS。
- 日志目录。
- 首次管理员默认值。
认证刷新、额度刷新和调度节奏不再作为 YAML 输入项;可人工调整的部分进入运行配置表,固定策略使用代码默认值。
运行配置进入数据库:
- default model。
- model aliases。
- model-account routes。
- refresh margin。
+8 -1
View File
@@ -869,7 +869,14 @@ pub(crate) async fn test_account_connection(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| state.config.model.default_model.clone());
.ok_or_else(|| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Model is required",
&request_id,
)
})?;
let stream = state
.services
.admin_accounts
+15 -64
View File
@@ -210,12 +210,6 @@ struct UsageWindow {
min_latency: u64,
}
#[derive(Debug, Clone, Copy)]
struct BillingContext<'a> {
default_model: &'a str,
default_service_tier: Option<&'a str>,
}
impl UsageWindow {
fn tokens(self) -> u64 {
self.input_tokens + self.output_tokens
@@ -288,13 +282,7 @@ pub async fn dashboard_summary(
StatusCode::OK,
AdminEnvelope::ok(
DashboardSummaryData {
cards: dashboard_cards(
&accounts,
&summary,
&time_buckets,
&settings.model.default_model,
settings.model.service_tier.as_deref(),
),
cards: dashboard_cards(&accounts, &summary, &time_buckets),
trend,
health_timeline: dashboard_health_timeline_data(&time_buckets),
account_usage: account_usage_data(
@@ -337,38 +325,22 @@ fn dashboard_cards(
accounts: &[Account],
summary: &AdminUsageSummary,
buckets: &[AdminUsageTimeBucketRecord],
default_model: &str,
default_service_tier: Option<&str>,
) -> DashboardCardsData {
dashboard_cards_at(
accounts,
summary,
buckets,
default_model,
default_service_tier,
Utc::now(),
)
dashboard_cards_at(accounts, summary, buckets, Utc::now())
}
fn dashboard_cards_at(
accounts: &[Account],
summary: &AdminUsageSummary,
buckets: &[AdminUsageTimeBucketRecord],
default_model: &str,
default_service_tier: Option<&str>,
now: DateTime<Utc>,
) -> DashboardCardsData {
let today_start = china_day_start(now);
let yesterday_start = today_start - Duration::days(1);
let billing_context = BillingContext {
default_model,
default_service_tier,
};
let today = usage_window(buckets, today_start, now);
let yesterday = usage_window(buckets, yesterday_start, today_start);
let today_cost = cost_window(buckets, today_start, now, billing_context).unwrap_or(0.0);
let total_cost = total_cost(buckets, summary, billing_context);
let today_cost = cost_window(buckets, today_start, now).unwrap_or(0.0);
let total_cost = total_cost(buckets);
let total_input = nonnegative_i64_to_u64(summary.input_tokens);
let total_cached = nonnegative_i64_to_u64(summary.cached_tokens);
@@ -693,13 +665,12 @@ fn cost_window(
records: &[AdminUsageTimeBucketRecord],
start: DateTime<Utc>,
end: DateTime<Utc>,
context: BillingContext<'_>,
) -> Option<f64> {
let mut total = 0.0;
let mut has_usage = false;
for record in records {
if record.bucket_start >= start && record.bucket_start < end {
if let Some(cost) = bucket_cost(record, context) {
if let Some(cost) = bucket_cost(record) {
total += cost;
has_usage = true;
}
@@ -708,15 +679,11 @@ fn cost_window(
has_usage.then_some(total)
}
fn total_cost(
records: &[AdminUsageTimeBucketRecord],
summary: &AdminUsageSummary,
context: BillingContext<'_>,
) -> f64 {
fn total_cost(records: &[AdminUsageTimeBucketRecord]) -> f64 {
let mut total = 0.0;
let mut has_usage = false;
for record in records {
if let Some(cost) = bucket_cost(record, context) {
if let Some(cost) = bucket_cost(record) {
total += cost;
has_usage = true;
}
@@ -725,19 +692,10 @@ fn total_cost(
return total;
}
let input_tokens = nonnegative_i64_to_u64(summary.input_tokens);
let output_tokens = nonnegative_i64_to_u64(summary.output_tokens);
let cached_tokens = nonnegative_i64_to_u64(summary.cached_tokens);
billing::calculate_cost(
input_tokens,
output_tokens,
cached_tokens,
context.default_model,
context.default_service_tier,
)
0.0
}
fn bucket_cost(record: &AdminUsageTimeBucketRecord, context: BillingContext<'_>) -> Option<f64> {
fn bucket_cost(record: &AdminUsageTimeBucketRecord) -> Option<f64> {
let input_tokens = nonnegative_i64_to_u64(record.input_tokens);
let output_tokens = nonnegative_i64_to_u64(record.output_tokens);
let cached_tokens = nonnegative_i64_to_u64(record.cached_tokens);
@@ -745,18 +703,11 @@ fn bucket_cost(record: &AdminUsageTimeBucketRecord, context: BillingContext<'_>)
return None;
}
let model = {
let value = record.model.trim();
if value.is_empty() {
context.default_model
} else {
value
}
};
let service_tier = record
.service_tier
.as_deref()
.or(context.default_service_tier);
let model = record.model.trim();
if model.is_empty() {
return None;
}
let service_tier = record.service_tier.as_deref();
Some(billing::calculate_cost(
input_tokens,
@@ -1026,7 +977,7 @@ mod tests {
usage_bucket_at("2026-06-24T15:30:00Z", 20),
];
let cards = dashboard_cards_at(&[], &empty_usage_summary(), &buckets, "gpt-5.5", None, now);
let cards = dashboard_cards_at(&[], &empty_usage_summary(), &buckets, now);
assert_eq!(cards.traffic.today_requests, 1);
assert_eq!(cards.traffic.yesterday_requests, 1);
+7
View File
@@ -12,6 +12,13 @@ use crate::admin::monitoring::events::{
use crate::infra::json::{decode_cursor, encode_cursor, page_offset, NumberedPage, Page};
use crate::infra::time::china_quarter_hour_start;
/// 默认启用事件日志,便于管理端诊断请求链路。
pub const DEFAULT_EVENT_LOG_ENABLED: bool = true;
/// 默认保留最新事件日志数量。
pub const DEFAULT_EVENT_LOG_CAPACITY: u32 = 2_000;
/// 默认不保存请求/响应体,避免把用户内容写入诊断日志。
pub const DEFAULT_EVENT_LOG_CAPTURE_BODY: bool = false;
/// SQLite 事件日志错误。
#[derive(Debug, Error)]
pub enum SqliteEventLogStoreError {
+3 -6
View File
@@ -22,8 +22,7 @@ use crate::{
runtime::state::AppState,
};
const ALLOWED_SETTINGS_KEYS: [&str; 8] = [
"defaultModel",
const ALLOWED_SETTINGS_KEYS: [&str; 7] = [
"modelAliases",
"modelAccountRoutes",
"refreshMarginSeconds",
@@ -37,7 +36,6 @@ const ALLOWED_SETTINGS_KEYS: [&str; 8] = [
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsData {
pub default_model: String,
pub model_aliases: BTreeMap<String, String>,
pub model_account_routes: BTreeMap<String, Vec<String>>,
pub refresh_margin_seconds: u64,
@@ -50,9 +48,8 @@ pub struct AdminSettingsData {
impl AdminSettingsData {
fn from_config(config: &AppConfig) -> Self {
Self {
default_model: config.model.default_model.clone(),
model_aliases: config.model.aliases.clone(),
model_account_routes: config.model.account_routes.clone(),
model_aliases: config.model_aliases.clone(),
model_account_routes: config.model_account_routes.clone(),
refresh_margin_seconds: config.auth.refresh_margin_seconds,
refresh_concurrency: config.auth.refresh_concurrency,
max_concurrent_per_account: config.auth.max_concurrent_per_account,
+8 -93
View File
@@ -21,18 +21,10 @@ const RUNTIME_SETTINGS_ID: i64 = 1;
/// 管理端可变设置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdminSettings {
/// 默认模型 ID。
pub default_model: String,
/// 默认 reasoning effort。
pub default_reasoning_effort: Option<String>,
/// 默认服务层级。
pub service_tier: Option<String>,
/// 模型别名映射。
pub model_aliases: BTreeMap<String, String>,
/// 模型到账号 ID 的显式路由。
pub model_account_routes: BTreeMap<String, Vec<String>>,
/// 是否启用访问令牌刷新。
pub refresh_enabled: bool,
/// 访问令牌过期前多少秒开始刷新。
pub refresh_margin_seconds: u64,
/// 访问令牌刷新并发数。
@@ -43,65 +35,18 @@ pub struct AdminSettings {
pub request_interval_ms: u64,
/// 账号轮换策略。
pub rotation_strategy: String,
/// 计划类型优先级。
pub tier_priority: Vec<String>,
/// 配额刷新间隔分钟数。
pub quota_refresh_interval_minutes: u64,
/// 配额预警阈值。
pub quota_warning_thresholds: AdminQuotaWarningThresholds,
/// 配额耗尽账号是否跳过调度。
pub quota_skip_exhausted: bool,
/// 是否启用事件日志。
pub logs_enabled: bool,
/// 事件日志容量。
pub logs_capacity: u32,
/// 事件日志是否捕获请求/响应体。
pub logs_capture_body: bool,
/// 用量历史保留天数。
pub usage_history_retention_days: Option<u64>,
}
impl Default for AdminSettings {
fn default() -> Self {
Self {
default_model: "gpt-4o".to_string(),
default_reasoning_effort: None,
service_tier: None,
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
refresh_enabled: true,
refresh_margin_seconds: 300,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
quota_refresh_interval_minutes: 5,
quota_warning_thresholds: AdminQuotaWarningThresholds::default(),
quota_skip_exhausted: true,
logs_enabled: false,
logs_capacity: 2_000,
logs_capture_body: false,
usage_history_retention_days: None,
}
}
}
/// 配额预警阈值集合。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminQuotaWarningThresholds {
/// primary 配额预警百分比。
pub primary: Vec<u8>,
/// secondary 配额预警百分比。
pub secondary: Vec<u8>,
}
impl Default for AdminQuotaWarningThresholds {
fn default() -> Self {
Self {
primary: vec![80, 90],
secondary: vec![80, 90],
}
}
}
@@ -110,8 +55,6 @@ impl Default for AdminQuotaWarningThresholds {
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsPatch {
/// 默认模型 ID。
pub default_model: Option<String>,
/// 模型别名映射。
pub model_aliases: Option<BTreeMap<String, String>>,
/// 模型到账号 ID 的显式路由。
@@ -138,9 +81,6 @@ impl SettingsService {
current: &mut AdminSettings,
patch: AdminSettingsPatch,
) -> Result<(), SettingsServiceError> {
if let Some(default_model) = patch.default_model {
current.default_model = non_empty_string("defaultModel", default_model)?;
}
if let Some(model_aliases) = patch.model_aliases {
current.model_aliases = validate_model_aliases(model_aliases)?;
}
@@ -395,37 +335,20 @@ pub enum RuntimeSettingsError {
}
fn admin_settings_from_config(config: &AppConfig) -> AdminSettings {
let defaults = AdminSettings::default();
AdminSettings {
default_model: config.model.default_model.clone(),
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
service_tier: config.model.service_tier.clone(),
model_aliases: config.model.aliases.clone(),
model_account_routes: config.model.account_routes.clone(),
refresh_enabled: defaults.refresh_enabled,
model_aliases: config.model_aliases.clone(),
model_account_routes: config.model_account_routes.clone(),
refresh_margin_seconds: config.auth.refresh_margin_seconds,
refresh_concurrency: config.auth.refresh_concurrency,
max_concurrent_per_account: config.auth.max_concurrent_per_account,
request_interval_ms: config.auth.request_interval_ms,
rotation_strategy: config.auth.rotation_strategy.clone(),
tier_priority: config.auth.tier_priority.clone(),
quota_refresh_interval_minutes: config.quota.refresh_interval_minutes,
quota_warning_thresholds: AdminQuotaWarningThresholds {
primary: config.quota.warning_thresholds.primary.clone(),
secondary: config.quota.warning_thresholds.secondary.clone(),
},
quota_skip_exhausted: defaults.quota_skip_exhausted,
logs_enabled: defaults.logs_enabled,
logs_capacity: defaults.logs_capacity,
logs_capture_body: config.logging.capture_body,
usage_history_retention_days: config.usage_stats.history_retention_days,
}
}
fn apply_admin_settings_to_config(config: &mut AppConfig, settings: AdminSettings) {
config.model.default_model = settings.default_model;
config.model.aliases = settings.model_aliases;
config.model.account_routes = settings.model_account_routes;
config.model_aliases = settings.model_aliases;
config.model_account_routes = settings.model_account_routes;
config.auth.refresh_margin_seconds = settings.refresh_margin_seconds;
config.auth.refresh_concurrency = settings.refresh_concurrency;
config.auth.max_concurrent_per_account = settings.max_concurrent_per_account;
@@ -443,7 +366,6 @@ async fn insert_runtime_settings_if_missing(
r"
insert or ignore into runtime_settings (
id,
default_model,
model_aliases_json,
refresh_margin_seconds,
refresh_concurrency,
@@ -451,10 +373,9 @@ insert or ignore into runtime_settings (
request_interval_ms,
rotation_strategy,
updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
) values (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(RUNTIME_SETTINGS_ID)
.bind(&settings.default_model)
.bind(model_aliases_json)
.bind(
i64::try_from(settings.refresh_margin_seconds)
@@ -490,7 +411,6 @@ async fn save_runtime_settings(
r"
insert into runtime_settings (
id,
default_model,
model_aliases_json,
refresh_margin_seconds,
refresh_concurrency,
@@ -498,9 +418,8 @@ insert into runtime_settings (
request_interval_ms,
rotation_strategy,
updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
) values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(id) do update set
default_model = excluded.default_model,
model_aliases_json = excluded.model_aliases_json,
refresh_margin_seconds = excluded.refresh_margin_seconds,
refresh_concurrency = excluded.refresh_concurrency,
@@ -510,7 +429,6 @@ on conflict(id) do update set
updated_at = excluded.updated_at",
)
.bind(RUNTIME_SETTINGS_ID)
.bind(&settings.default_model)
.bind(model_aliases_json)
.bind(
i64::try_from(settings.refresh_margin_seconds)
@@ -538,7 +456,6 @@ async fn load_runtime_settings(pool: &SqlitePool) -> Result<AdminSettings, Runti
let row = sqlx::query(
r"
select
default_model,
model_aliases_json,
refresh_margin_seconds,
refresh_concurrency,
@@ -559,7 +476,6 @@ where id = ?",
fn runtime_settings_from_row(row: &SqliteRow) -> Result<AdminSettings, RuntimeSettingsError> {
let model_aliases_json: String = row.get("model_aliases_json");
Ok(AdminSettings {
default_model: row.get("default_model"),
model_aliases: serde_json::from_str(&model_aliases_json)?,
model_account_routes: BTreeMap::new(),
refresh_margin_seconds: positive_i64_to_u64(
@@ -579,7 +495,6 @@ fn runtime_settings_from_row(row: &SqliteRow) -> Result<AdminSettings, RuntimeSe
row.get("request_interval_ms"),
)?,
rotation_strategy: validate_rotation_strategy(row.get("rotation_strategy"))?,
..AdminSettings::default()
})
}
@@ -643,9 +558,9 @@ pub fn account_pool_options_from_config(config: &AppConfig) -> AccountPoolOption
AccountPoolOptions {
max_concurrent_per_account: config.auth.max_concurrent_per_account,
rotation_strategy: rotation_strategy_from_config(&config.auth.rotation_strategy),
skip_quota_limited: config.quota.skip_exhausted,
skip_quota_limited: true,
tier_priority: config.auth.tier_priority.clone(),
model_account_routes: config.model.account_routes.clone(),
model_account_routes: config.model_account_routes.clone(),
..AccountPoolOptions::default()
}
}
+70 -37
View File
@@ -4,30 +4,40 @@ use serde::{Deserialize, Serialize};
/// 应用运行总配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
/// HTTP 服务配置。
pub server: ServerConfig,
/// API 地址配置。
pub api: ApiConfig,
/// 模型默认值配置。
pub model: ModelConfig,
/// 认证与刷新配置。
/// 运行时模型别名,由数据库设置加载,配置文件不承载该字段。
#[serde(skip)]
pub model_aliases: BTreeMap<String, String>,
/// 运行时模型账号路由,由 `model_account_routes` 表加载,配置文件不承载该字段。
#[serde(skip)]
pub model_account_routes: BTreeMap<String, Vec<String>>,
/// 认证与刷新配置,由固定默认值和数据库运行时设置承载,配置文件不承载该字段。
#[serde(skip)]
pub auth: AuthConfig,
/// 配额配置。
/// 配额配置,由固定默认值承载,配置文件不承载该字段。
#[serde(skip)]
pub quota: QuotaConfig,
/// 用量统计配置。
pub usage_stats: UsageStatsConfig,
/// 数据库配置。
pub database: DatabaseConfig,
/// TLS 偏好配置。
#[serde(default)]
pub tls: TlsConfig,
/// WebSocket 连接池配置。
#[serde(default)]
pub ws_pool: WebSocketPoolConfig,
/// 上游请求指纹默认配置。
#[serde(default)]
pub fingerprint: FingerprintConfig,
/// 管理员初始化配置。
#[serde(default)]
pub admin: AdminConfig,
/// 日志配置。
#[serde(default)]
pub logging: LoggingConfig,
}
@@ -47,23 +57,6 @@ pub struct ApiConfig {
pub base_url: String,
}
/// 模型默认值与别名配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ModelConfig {
/// 默认模型名。
#[serde(rename = "default")]
pub default_model: String,
/// 默认推理强度。
pub default_reasoning_effort: Option<String>,
/// 默认服务层级。
#[serde(rename = "default_service_tier")]
pub service_tier: Option<String>,
/// 模型别名映射。
pub aliases: BTreeMap<String, String>,
/// 模型到账号 ID 的显式路由。
pub account_routes: BTreeMap<String, Vec<String>>,
}
/// 认证、轮换与 token 续期配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct AuthConfig {
@@ -83,12 +76,26 @@ pub struct AuthConfig {
pub tier_priority: Vec<String>,
/// OAuth 客户端 ID。
pub oauth_client_id: String,
/// OAuth 授权端点。
pub oauth_auth_endpoint: String,
/// OAuth token 端点。
pub oauth_token_endpoint: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
refresh_margin_seconds: 300,
refresh_enabled: true,
refresh_concurrency: 2,
max_concurrent_per_account: 3,
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
}
}
}
/// 配额刷新与跳过配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct QuotaConfig {
@@ -96,8 +103,15 @@ pub struct QuotaConfig {
pub refresh_interval_minutes: u64,
/// 预警阈值。
pub warning_thresholds: QuotaWarningThresholds,
/// 是否跳过耗尽账号。
pub skip_exhausted: bool,
}
impl Default for QuotaConfig {
fn default() -> Self {
Self {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds::default(),
}
}
}
/// 配额预警阈值集合。
@@ -109,11 +123,13 @@ pub struct QuotaWarningThresholds {
pub secondary: Vec<u8>,
}
/// 用量历史保留配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct UsageStatsConfig {
/// 历史保留天数。
pub history_retention_days: Option<u64>,
impl Default for QuotaWarningThresholds {
fn default() -> Self {
Self {
primary: vec![80, 90],
secondary: vec![80, 90],
}
}
}
/// 数据库连接配置。
@@ -124,7 +140,7 @@ pub struct DatabaseConfig {
}
/// TLS/HTTP 协议偏好配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct TlsConfig {
/// 是否强制 HTTP/1.1。
pub force_http11: bool,
@@ -256,6 +272,17 @@ pub struct AdminConfig {
pub default_password: String,
}
impl Default for AdminConfig {
fn default() -> Self {
Self {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: default_session_cleanup_interval(),
default_username: default_admin_username(),
default_password: default_admin_password(),
}
}
}
fn default_session_cleanup_interval() -> u64 {
3600 // 默认每小时清理一次过期会话
}
@@ -278,8 +305,14 @@ pub struct LoggingConfig {
pub retention_days: usize,
/// 是否启用日志。
pub enabled: bool,
/// 内存容量。
pub capacity: u32,
/// 是否捕获请求体。
pub capture_body: bool,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
directory: ".runtime/logs".to_string(),
retention_days: 14,
enabled: true,
}
}
}
-1
View File
@@ -44,7 +44,6 @@ create index if not exists idx_client_api_keys_created_id on client_api_keys(cre
-- ============================================
create table if not exists runtime_settings (
id integer primary key check (id = 1),
default_model text not null,
model_aliases_json text not null default '{}',
refresh_margin_seconds integer not null check (refresh_margin_seconds > 0),
refresh_concurrency integer not null check (refresh_concurrency > 0),
+1 -1
View File
@@ -85,7 +85,7 @@ impl ChatDispatchService {
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
let display_model = ModelCatalog::build_display_model_name(&parsed_model);
apply_response_model_options(&mut request, &parsed_model, self.models.config());
apply_response_model_options(&mut request, &parsed_model);
let include_reasoning = request
.reasoning
.as_ref()
+2 -2
View File
@@ -385,7 +385,7 @@ impl ResponseDispatchService {
let started_at = Instant::now();
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
apply_response_model_options(&mut request, &parsed_model, self.models.config());
apply_response_model_options(&mut request, &parsed_model);
let tuple_schema = request.tuple_schema.clone();
let image_generation_requested = request.expects_image_generation();
let now = Utc::now();
@@ -976,7 +976,7 @@ impl ResponseDispatchService {
let started_at = Instant::now();
let catalog = self.models.catalog().await;
let parsed_model = catalog.parse_model_name(requested_model);
apply_response_model_options(&mut request, &parsed_model, self.models.config());
apply_response_model_options(&mut request, &parsed_model);
request.stream = true;
let tuple_schema = request.tuple_schema.clone();
let now = Utc::now();
+1 -5
View File
@@ -919,11 +919,7 @@ pub async fn chat_completions(
return invalid_chat_completion_request_response().into_response();
}
};
apply_response_model_options(
&mut codex_request,
&parsed_model,
state.services.models.config(),
);
apply_response_model_options(&mut codex_request, &parsed_model);
let include_reasoning = codex_request
.reasoning
.as_ref()
+9 -12
View File
@@ -4,7 +4,10 @@ use std::sync::Arc as StdArc;
use crate::{
admin::monitoring::{
event_store::{AdminLogService, SqliteEventLogStore},
event_store::{
AdminLogService, SqliteEventLogStore, DEFAULT_EVENT_LOG_CAPACITY,
DEFAULT_EVENT_LOG_CAPTURE_BODY, DEFAULT_EVENT_LOG_ENABLED,
},
service::AdminUsageService,
usage_store::SqliteUsageStore,
},
@@ -168,9 +171,9 @@ impl Services {
));
let logs = StdArc::new(AdminLogService::new(
stores.event_logs.clone(),
config.logging.enabled,
config.logging.capacity,
config.logging.capture_body,
DEFAULT_EVENT_LOG_ENABLED,
DEFAULT_EVENT_LOG_CAPACITY,
DEFAULT_EVENT_LOG_CAPTURE_BODY,
));
let usage_store = SqliteUsageStore::new(stores.accounts.pool().clone());
let usage = StdArc::new(AdminUsageService::new(usage_store));
@@ -184,10 +187,7 @@ impl Services {
);
let models = StdArc::new(ModelService::new(
crate::upstream::models::ModelConfig {
default_model: config.model.default_model.clone(),
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
service_tier: config.model.service_tier.clone(),
aliases: config.model.aliases.clone(),
model_aliases: config.model_aliases.clone(),
},
Some(snapshot_store.clone()),
Some(upstream_client.clone()),
@@ -196,10 +196,7 @@ impl Services {
let admin_models = StdArc::new(AdminModelService::new(
StdArc::new(ModelService::new(
crate::upstream::models::ModelConfig {
default_model: config.model.default_model.clone(),
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
service_tier: config.model.service_tier.clone(),
aliases: config.model.aliases.clone(),
model_aliases: config.model_aliases.clone(),
},
Some(snapshot_store),
Some(upstream_client),
-2
View File
@@ -13,7 +13,6 @@ pub struct AppState {
/// 运行时配置镜像(从 AppConfig 衍生)。
#[derive(Debug, Clone)]
pub struct RuntimeConfig {
pub model: crate::config::types::ModelConfig,
pub admin: crate::config::types::AdminConfig,
pub auth: crate::config::types::AuthConfig,
pub quota: crate::config::types::QuotaConfig,
@@ -22,7 +21,6 @@ pub struct RuntimeConfig {
impl From<AppConfig> for RuntimeConfig {
fn from(config: AppConfig) -> Self {
Self {
model: config.model.clone(),
admin: config.admin.clone(),
auth: config.auth.clone(),
quota: config.quota,
+13 -60
View File
@@ -16,17 +16,11 @@ use crate::upstream::accounts::{
// 数据类型
// ---------------------------------------------------------------------------
/// 模型目录默认值与别名配置。
/// 模型目录别名配置。
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ModelConfig {
/// 默认模型名。
pub default_model: String,
/// 默认推理强度。
pub default_reasoning_effort: Option<String>,
/// 默认服务层级。
pub service_tier: Option<String>,
/// 模型别名映射。
pub aliases: BTreeMap<String, String>,
pub model_aliases: BTreeMap<String, String>,
}
/// 单个 reasoning effort 的标准化展示信息。
@@ -349,14 +343,12 @@ use std::collections::BTreeSet;
const SERVICE_TIER_SUFFIXES: [&str; 2] = ["fast", "flex"];
const REASONING_EFFORT_SUFFIXES: [&str; 6] = ["none", "minimal", "low", "medium", "high", "xhigh"];
const STATIC_REASONING_EFFORTS: [&str; 4] = ["low", "medium", "high", "xhigh"];
/// 模型目录聚合。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCatalog {
models: Vec<CodexModelInfo>,
aliases: BTreeMap<String, String>,
default_model: String,
model_aliases: BTreeMap<String, String>,
model_plan_index: BTreeMap<String, Vec<String>>,
}
@@ -376,12 +368,10 @@ impl ModelPlanSnapshot {
impl ModelCatalog {
/// 从静态配置构造模型目录。
pub fn from_config(config: &ModelConfig) -> Self {
let default_model = config.default_model.trim().to_string();
let aliases = normalize_aliases(&config.aliases);
let model_aliases = normalize_aliases(&config.model_aliases);
Self {
models: vec![default_model_info(config)],
aliases,
default_model,
models: Vec::new(),
model_aliases,
model_plan_index: BTreeMap::new(),
}
}
@@ -395,8 +385,7 @@ impl ModelCatalog {
return Self::from_config(config);
}
let aliases = normalize_aliases(&config.aliases);
let default_model = config.default_model.trim().to_string();
let model_aliases = normalize_aliases(&config.model_aliases);
let mut models_by_id = BTreeMap::new();
let mut model_plan_index: BTreeMap<String, Vec<String>> = BTreeMap::new();
@@ -417,8 +406,7 @@ impl ModelCatalog {
Self {
models,
aliases,
default_model,
model_aliases,
model_plan_index,
}
}
@@ -447,7 +435,7 @@ impl ModelCatalog {
if trimmed.is_empty() {
return false;
}
if self.aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() {
if self.model_aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() {
return true;
}
@@ -457,14 +445,14 @@ impl ModelCatalog {
{
return false;
}
self.aliases.contains_key(&stripped.model_name)
self.model_aliases.contains_key(&stripped.model_name)
|| self.model_info(&stripped.model_name).is_some()
}
/// 解析外部传入的模型名,提取别名、推理强度和服务层级后缀。
pub fn parse_model_name(&self, input: &str) -> ParsedModelName {
let trimmed = input.trim();
if self.aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() {
if self.model_aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() {
return ParsedModelName {
model_id: self.resolve_model_id(trimmed),
reasoning_effort: None,
@@ -495,11 +483,7 @@ impl ModelCatalog {
}
fn resolve_model_id(&self, input: &str) -> String {
let resolved = self.resolve_alias_chain(input);
if self.model_info(&resolved).is_some() {
return resolved;
}
self.default_model.clone()
self.resolve_alias_chain(input)
}
fn resolve_alias_chain(&self, input: &str) -> String {
@@ -507,7 +491,7 @@ impl ModelCatalog {
let mut current = original.to_string();
let mut seen = BTreeSet::new();
for _ in 0..20 {
let Some(target) = self.aliases.get(&current).map(|value| value.trim()) else {
let Some(target) = self.model_aliases.get(&current).map(|value| value.trim()) else {
return current;
};
if !seen.insert(current.clone()) || seen.contains(target) {
@@ -558,37 +542,6 @@ fn normalize_aliases(input: &BTreeMap<String, String>) -> BTreeMap<String, Strin
.collect()
}
fn default_model_info(config: &ModelConfig) -> CodexModelInfo {
let id = config.default_model.trim().to_string();
let default_reasoning_effort = config
.default_reasoning_effort
.clone()
.unwrap_or_else(|| "medium".to_string());
CodexModelInfo {
id: id.clone(),
display_name: id,
description: "Codex default model".to_string(),
is_default: true,
supported_reasoning_efforts: STATIC_REASONING_EFFORTS
.into_iter()
.map(|effort| ReasoningEffortInfo {
reasoning_effort: effort.to_string(),
description: effort.to_string(),
})
.collect(),
default_reasoning_effort,
input_modalities: vec!["text".to_string(), "image".to_string()],
output_modalities: vec!["text".to_string()],
supports_personality: false,
upgrade: None,
source: "static".to_string(),
context_window: None,
max_context_window: None,
max_output_tokens: None,
truncation_policy_limit: None,
}
}
fn normalize_backend_model(raw: BackendModelEntry) -> CodexModelInfo {
let id = first_non_empty([raw.slug.as_deref(), raw.id.as_deref(), raw.name.as_deref()])
.unwrap_or("unknown")
+6 -17
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::upstream::{
models::{ModelConfig, ParsedModelName},
models::ParsedModelName,
protocol::{
schema::reconvert_tuple_values,
sse::{parse_sse_events, SseError},
@@ -467,18 +467,13 @@ fn failure_code(value: &Value) -> Option<String> {
pub fn apply_response_model_options(
request: &mut CodexResponsesRequest,
parsed_model: &ParsedModelName,
config: &ModelConfig,
) {
request.model = parsed_model.model_id.clone();
apply_reasoning_options(request, parsed_model, config);
apply_service_tier_options(request, parsed_model, config);
apply_reasoning_options(request, parsed_model);
apply_service_tier_options(request, parsed_model);
}
fn apply_reasoning_options(
request: &mut CodexResponsesRequest,
parsed_model: &ParsedModelName,
config: &ModelConfig,
) {
fn apply_reasoning_options(request: &mut CodexResponsesRequest, parsed_model: &ParsedModelName) {
let existing_reasoning = request.reasoning.take();
let existing_object = match existing_reasoning {
Some(Value::Object(object)) => object,
@@ -488,8 +483,7 @@ fn apply_reasoning_options(
.get("effort")
.and_then(Value::as_str)
.map(ToString::to_string)
.or_else(|| non_empty_string(parsed_model.reasoning_effort.as_deref()))
.or_else(|| non_empty_string(config.default_reasoning_effort.as_deref()));
.or_else(|| non_empty_string(parsed_model.reasoning_effort.as_deref()));
if effort.is_none() && existing_object.is_empty() {
request.reasoning = None;
return;
@@ -523,17 +517,12 @@ pub(crate) fn ensure_reasoning_include(request: &mut CodexResponsesRequest) {
request.include = Some(vec!["reasoning.encrypted_content".to_string()]);
}
fn apply_service_tier_options(
request: &mut CodexResponsesRequest,
parsed_model: &ParsedModelName,
config: &ModelConfig,
) {
fn apply_service_tier_options(request: &mut CodexResponsesRequest, parsed_model: &ParsedModelName) {
request.service_tier = request
.service_tier
.take()
.and_then(|value| non_empty_string(Some(&value)))
.or_else(|| non_empty_string(parsed_model.service_tier.as_deref()))
.or_else(|| non_empty_string(config.service_tier.as_deref()))
.map(normalize_service_tier_for_upstream);
}
+4 -17
View File
@@ -11,9 +11,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -241,13 +240,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -257,7 +251,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -266,10 +259,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -287,8 +276,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+4 -17
View File
@@ -11,9 +11,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::{database::connect_sqlite, identity::hash_admin_password},
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -168,13 +167,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("high".to_string()),
service_tier: Some("flex".to_string()),
aliases,
account_routes: BTreeMap::new(),
},
model_aliases: aliases,
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 240,
refresh_enabled: true,
@@ -184,7 +178,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: vec!["team".to_string(), "plus".to_string()],
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -193,10 +186,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![70, 95],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: Some(30),
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -214,8 +203,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+4 -17
View File
@@ -10,9 +10,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -118,13 +117,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -134,7 +128,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -143,10 +136,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -164,8 +153,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+4 -17
View File
@@ -11,9 +11,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -207,13 +206,8 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
port: 0,
},
api: ApiConfig { base_url },
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -223,7 +217,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -232,10 +225,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -253,8 +242,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+2 -2
View File
@@ -213,7 +213,7 @@ async fn dashboard_summary_should_normalize_codex_model_names_for_cost() {
}
#[tokio::test]
async fn dashboard_summary_should_fallback_total_cost_to_usage_summary_when_logs_have_no_usage() {
async fn dashboard_summary_should_not_price_usage_summary_without_model_dimension() {
let (app, _store, pool, _dir) = dashboard_test_app(
"dashboard-cost-summary-fallback.sqlite",
crate::support::fingerprint::test_fingerprint(),
@@ -227,7 +227,7 @@ async fn dashboard_summary_should_fallback_total_cost_to_usage_summary_when_logs
body["data"]["cards"]["tokens"]["totalCostUsd"]
.as_f64()
.unwrap(),
3.5,
0.0,
);
}
+4 -17
View File
@@ -14,9 +14,8 @@ use codex_proxy_rs::{
},
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -318,13 +317,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -334,7 +328,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -343,10 +336,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -364,8 +353,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
-5
View File
@@ -5,8 +5,6 @@ use codex_proxy_rs::config::settings::{AdminSettings, AdminSettingsPatch, Settin
#[test]
fn settings_service_should_apply_retained_settings_patch() {
let mut current = AdminSettings {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("high".to_string()),
max_concurrent_per_account: 4,
request_interval_ms: 50,
rotation_strategy: "least_used".to_string(),
@@ -16,7 +14,6 @@ fn settings_service_should_apply_retained_settings_patch() {
SettingsService::apply_patch(
&mut current,
AdminSettingsPatch {
default_model: Some(" gpt-6 ".to_string()),
model_aliases: Some(
[
(" claude-sonnet ".to_string(), " gpt-5.5 ".to_string()),
@@ -40,7 +37,6 @@ fn settings_service_should_apply_retained_settings_patch() {
)
.expect("settings patch should be valid");
assert_eq!(current.default_model, "gpt-6");
assert_eq!(current.model_aliases["claude-sonnet"], "gpt-5.5");
assert_eq!(current.model_aliases["openai-fast"], "openai:gpt-4o");
assert_eq!(
@@ -52,7 +48,6 @@ fn settings_service_should_apply_retained_settings_patch() {
assert_eq!(current.rotation_strategy, "round_robin");
assert_eq!(current.max_concurrent_per_account, 5);
assert_eq!(current.request_interval_ms, 125);
assert_eq!(current.default_reasoning_effort.as_deref(), Some("high"));
}
#[test]
+18 -39
View File
@@ -9,9 +9,8 @@ use codex_proxy_rs::{
admin::keys::service::SqliteClientKeyStore,
admin::monitoring::event_store::SqliteEventLogStore,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
config::{settings::RuntimeSettingsService, types::AppConfig},
infra::database::connect_sqlite,
@@ -60,7 +59,6 @@ async fn admin_settings_should_return_runtime_fields() {
.await
.unwrap();
let body = response_json(response).await;
assert_eq!(body["data"]["defaultModel"], "gpt-5.5");
assert_eq!(body["data"]["modelAliases"]["codex-fast"], "gpt-5.5");
assert_eq!(body["data"]["modelAccountRoutes"], json!({}));
assert_eq!(body["data"]["refreshMarginSeconds"], 240);
@@ -78,7 +76,7 @@ async fn admin_settings_update_should_require_admin_session_cookie() {
.uri("/api/admin/settings")
.header("content-type", "application/json")
.header("x-request-id", "req_settings_update_auth")
.body(Body::from(r#"{"defaultModel":"gpt-5.5"}"#))
.body(Body::from(r#"{"refreshMarginSeconds":300}"#))
.unwrap(),
)
.await
@@ -126,7 +124,7 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
.header("x-request-id", "req_settings_update")
.body(Body::from(
json!({
"defaultModel": "gpt-6", "rotationStrategy": "round_robin",
"rotationStrategy": "round_robin",
"modelAliases": {
"gpt-5.2": "gpt-5.5",
"claude-sonnet": "gpt-5.5"
@@ -147,7 +145,6 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_json(response).await;
assert_eq!(body["data"]["defaultModel"], "gpt-6");
assert_eq!(body["data"]["modelAliases"]["gpt-5.2"], "gpt-5.5");
assert_eq!(
body["data"]["modelAccountRoutes"]["gpt-5.5"][0],
@@ -168,25 +165,24 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
.await
.unwrap();
assert_eq!(
response_json(get_response).await["data"]["defaultModel"],
"gpt-6"
response_json(get_response).await["data"]["rotationStrategy"],
"round_robin"
);
let row: (String, String, i64, i64, i64, i64, String) = sqlx::query_as(
"select default_model, model_aliases_json, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy from runtime_settings where id = 1",
let row: (String, i64, i64, i64, i64, String) = sqlx::query_as(
"select model_aliases_json, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy from runtime_settings where id = 1",
)
.fetch_one(&pool)
.await
.unwrap();
let aliases: BTreeMap<String, String> = serde_json::from_str(&row.1).unwrap();
assert_eq!(row.0, "gpt-6");
let aliases: BTreeMap<String, String> = serde_json::from_str(&row.0).unwrap();
assert_eq!(aliases["gpt-5.2"], "gpt-5.5");
assert_eq!(aliases["claude-sonnet"], "gpt-5.5");
assert_eq!(row.2, 900);
assert_eq!(row.3, 4);
assert_eq!(row.4, 7);
assert_eq!(row.5, 80);
assert_eq!(row.6, "round_robin");
assert_eq!(row.1, 900);
assert_eq!(row.2, 4);
assert_eq!(row.3, 7);
assert_eq!(row.4, 80);
assert_eq!(row.5, "round_robin");
let route_rows: Vec<(String, String, i64)> = sqlx::query_as(
"select model, account_id, priority from model_account_routes order by model, priority",
)
@@ -205,10 +201,9 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
let restarted_config = RuntimeSettingsService::load_or_initialize_config(config.clone(), &pool)
.await
.unwrap();
assert_eq!(restarted_config.model.default_model, "gpt-6");
assert_eq!(restarted_config.model.aliases["gpt-5.2"], "gpt-5.5");
assert_eq!(restarted_config.model_aliases["gpt-5.2"], "gpt-5.5");
assert_eq!(
restarted_config.model.account_routes["gpt-5.5"],
restarted_config.model_account_routes["gpt-5.5"],
vec!["acct_route_a".to_string(), "acct_route_b".to_string()]
);
assert_eq!(restarted_config.auth.refresh_margin_seconds, 900);
@@ -216,10 +211,6 @@ async fn admin_settings_update_should_persist_runtime_settings_to_database() {
assert_eq!(restarted_config.auth.rotation_strategy, "round_robin");
assert_eq!(restarted_config.auth.max_concurrent_per_account, 7);
assert_eq!(restarted_config.auth.request_interval_ms, 80);
assert_eq!(
restarted_config.model.default_reasoning_effort,
config.model.default_reasoning_effort
);
assert_eq!(restarted_config.database.url, config.database.url);
}
@@ -355,13 +346,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("high".to_string()),
service_tier: Some("flex".to_string()),
aliases,
account_routes: BTreeMap::new(),
},
model_aliases: aliases,
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 240,
refresh_enabled: true,
@@ -371,7 +357,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: vec!["team".to_string(), "plus".to_string()],
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -380,10 +365,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![70, 95],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: Some(30),
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -401,8 +382,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: true,
capacity: 2_000,
capture_body: false,
},
}
}
+50 -68
View File
@@ -8,35 +8,6 @@ server:
port: 8080
api:
base_url: https://chatgpt.com/backend-api
model:
default: gpt-5.5
default_reasoning_effort: null
default_service_tier: null
aliases: {}
account_routes: {}
auth:
refresh_margin_seconds: 300
refresh_enabled: true
refresh_concurrency: 2
max_concurrent_per_account: 3
request_interval_ms: 50
rotation_strategy: least_used
tier_priority: []
oauth_client_id: app_EMoamEEZ73f0CkXaXp7hrann
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.openai.com/oauth/token
quota:
refresh_interval_minutes: 5
warning_thresholds:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
usage_stats:
history_retention_days: null
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
tls:
@@ -97,8 +68,6 @@ logging:
directory: .runtime/logs
retention_days: 14
enabled: true
capacity: 2000
capture_body: false
"#;
#[test]
@@ -106,7 +75,7 @@ fn default_config_keeps_only_codex_backend() {
let cfg: AppConfig = serde_yml::from_str(DEFAULT_CONFIG_YAML).unwrap();
assert_eq!(cfg.server.host, "0.0.0.0");
assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api");
assert_eq!(cfg.model.default_model, "gpt-5.5");
assert!(cfg.model_aliases.is_empty());
assert_eq!(cfg.auth.refresh_margin_seconds, 300);
assert_eq!(cfg.auth.rotation_strategy, "least_used");
assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann");
@@ -133,6 +102,52 @@ fn default_config_keeps_runtime_artifacts_under_runtime_directory() {
assert_eq!(cfg.logging.directory, ".runtime/logs");
}
#[test]
fn config_should_reject_runtime_model_section() {
let err = serde_yml::from_str::<AppConfig>(
r#"
server:
host: 127.0.0.1
port: 8080
api:
base_url: https://chatgpt.com/backend-api
model: {}
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
"#,
)
.unwrap_err();
assert!(
err.to_string().contains("model"),
"expected model section to be rejected, got {err}"
);
}
#[test]
fn config_should_reject_runtime_auth_and_quota_sections() {
for section in ["auth", "quota"] {
let yaml = format!(
r#"
server:
host: 127.0.0.1
port: 8080
api:
base_url: https://chatgpt.com/backend-api
{section}: {{}}
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
"#
);
let err = serde_yml::from_str::<AppConfig>(&yaml).unwrap_err();
assert!(
err.to_string().contains(section),
"expected {section} section to be rejected, got {err}"
);
}
}
#[test]
fn config_loader_should_read_only_config_yaml() {
let dir = tempfile::tempdir().unwrap();
@@ -144,35 +159,6 @@ server:
port: 8080
api:
base_url: https://chatgpt.com/backend-api
model:
default: gpt-5.5
default_reasoning_effort: null
default_service_tier: null
aliases: {}
account_routes: {}
auth:
refresh_margin_seconds: 300
refresh_enabled: true
refresh_concurrency: 2
max_concurrent_per_account: 3
request_interval_ms: 50
rotation_strategy: least_used
tier_priority: []
oauth_client_id: app_test_client
oauth_auth_endpoint: https://auth.openai.com/oauth/authorize
oauth_token_endpoint: https://auth.example.test/oauth/token
quota:
refresh_interval_minutes: 5
warning_thresholds:
primary:
- 80
- 90
secondary:
- 80
- 90
skip_exhausted: true
usage_stats:
history_retention_days: null
database:
url: sqlite://.runtime/data/codex-proxy-rs.sqlite
tls:
@@ -231,8 +217,6 @@ logging:
directory: .runtime/logs
retention_days: 14
enabled: false
capacity: 2000
capture_body: false
"#,
)
.unwrap();
@@ -259,12 +243,12 @@ ws_pool:
cfg.database.url,
"sqlite://.runtime/data/codex-proxy-rs.sqlite"
);
assert_eq!(cfg.model.default_model, "gpt-5.5");
assert!(cfg.model_aliases.is_empty());
assert_eq!(cfg.auth.max_concurrent_per_account, 3);
assert_eq!(cfg.auth.oauth_client_id, "app_test_client");
assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann");
assert_eq!(
cfg.auth.oauth_token_endpoint,
"https://auth.example.test/oauth/token"
"https://auth.openai.com/oauth/token"
);
assert!(cfg.ws_pool.enabled);
assert_eq!(cfg.ws_pool.max_age_ms, 3_300_000);
@@ -281,8 +265,6 @@ directory: .runtime/logs
max_file_bytes: 10485760
retention_days: 14
enabled: false
capacity: 2000
capture_body: false
"#,
)
.unwrap_err();
+6 -17
View File
@@ -1,12 +1,13 @@
use std::collections::BTreeMap;
use axum::{
body::{to_bytes, Body},
http::{header, Request, StatusCode},
};
use codex_proxy_rs::{
config::types::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
};
@@ -134,13 +135,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
account_routes: Default::default(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 240,
refresh_enabled: true,
@@ -150,7 +146,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_id".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.invalid/token".to_string(),
},
quota: QuotaConfig {
@@ -159,10 +154,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: Some(30),
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -180,8 +171,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+4 -5
View File
@@ -32,7 +32,7 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() {
let pool = connect_sqlite(&url).await.unwrap();
let rows: Vec<(String,)> = sqlx::query_as(
"select name from pragma_table_info('runtime_settings') where name in ('id', 'default_model', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'updated_at') order by name",
"select name from pragma_table_info('runtime_settings') where name in ('id', 'model_aliases_json', 'refresh_margin_seconds', 'refresh_concurrency', 'max_concurrent_per_account', 'request_interval_ms', 'rotation_strategy', 'updated_at') order by name",
)
.fetch_all(&pool)
.await
@@ -41,7 +41,6 @@ async fn sqlite_schema_should_persist_runtime_settings_columns() {
assert_eq!(
rows.into_iter().map(|row| row.0).collect::<Vec<_>>(),
[
"default_model",
"id",
"max_concurrent_per_account",
"model_aliases_json",
@@ -518,17 +517,17 @@ async fn sqlite_schema_should_reject_invalid_runtime_settings() {
let pool = connect_sqlite(&url).await.unwrap();
let invalid_id_result = sqlx::query(
"insert into runtime_settings (id, default_model, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (2, 'gpt-5.5', 300, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')",
"insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (2, 300, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')",
)
.execute(&pool)
.await;
let invalid_refresh_result = sqlx::query(
"insert into runtime_settings (id, default_model, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 'gpt-5.5', 0, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')",
"insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 0, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')",
)
.execute(&pool)
.await;
let invalid_strategy_result = sqlx::query(
"insert into runtime_settings (id, default_model, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 'gpt-5.5', 300, 2, 3, 50, 'random', '2026-06-14T00:00:00Z')",
"insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 300, 2, 3, 50, 'random', '2026-06-14T00:00:00Z')",
)
.execute(&pool)
.await;
+101 -23
View File
@@ -16,14 +16,13 @@ use codex_proxy_rs::{
admin::auth::service::SqliteAdminSessionStore,
admin::keys::service::SqliteClientKeyStore,
admin::monitoring::{
event_store::{EventLogFilter, SqliteEventLogStore},
event_store::{AdminLogStateUpdate, EventLogFilter, SqliteEventLogStore},
events::EventLevel,
},
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
http::router,
infra::database::connect_sqlite,
@@ -300,6 +299,7 @@ async fn test_app_without_accounts(base_url: String) -> (axum::Router, String, t
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let api_key = insert_client_api_key(&pool).await;
insert_model_snapshot(&pool).await;
let state = test_app_state_with_pool_and_installation_id(
test_config(url, base_url),
pool,
@@ -323,14 +323,70 @@ async fn test_app_with_account_pool_and_logging(
.await
}
async fn test_app_with_account_pool_and_disabled_logging(
base_url: String,
) -> (axum::Router, String, SqlitePool, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("openai-record-affinity.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let api_key = insert_client_api_key(&pool).await;
insert_account(&pool).await;
let state = test_app_state_with_pool_and_installation_id(
test_config(url, base_url),
pool.clone(),
TEST_INSTALLATION_ID.to_string(),
);
state
.services
.account_pool
.restore_from_repository()
.await
.expect("account pool should restore");
state
.services
.logs
.update_state(AdminLogStateUpdate {
enabled: Some(false),
..AdminLogStateUpdate::default()
})
.await
.expect("event log should be disabled");
(router::router().with_state(state), api_key, pool, dir)
}
async fn test_app_with_account_pool_and_logging_capture_body(
base_url: String,
) -> (axum::Router, String, SqlitePool, tempfile::TempDir) {
test_app_with_account_pool_config(base_url, |config| {
config.logging.enabled = true;
config.logging.capture_body = true;
})
.await
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("openai-record-affinity.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let api_key = insert_client_api_key(&pool).await;
insert_account(&pool).await;
let mut config = test_config(url, base_url);
config.logging.enabled = true;
let state = test_app_state_with_pool_and_installation_id(
config,
pool.clone(),
TEST_INSTALLATION_ID.to_string(),
);
state
.services
.account_pool
.restore_from_repository()
.await
.expect("account pool should restore");
state
.services
.logs
.update_state(AdminLogStateUpdate {
capture_body: Some(true),
..AdminLogStateUpdate::default()
})
.await
.expect("log capture body should be enabled");
(router::router().with_state(state), api_key, pool, dir)
}
async fn test_app_with_account_pool_config(
@@ -579,6 +635,7 @@ async fn update_admin_account_status(app: &axum::Router, account_id: &str, statu
}
async fn insert_account(pool: &SqlitePool) {
insert_model_snapshot(pool).await;
sqlx::query(
"insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
@@ -596,12 +653,45 @@ async fn insert_account(pool: &SqlitePool) {
.unwrap();
}
async fn insert_model_snapshot(pool: &SqlitePool) {
let models_json = json!([
{
"id": "gpt-5.5",
"displayName": "GPT 5.5",
"description": "Test model",
"isDefault": false,
"supportedReasoningEfforts": [
{"reasoningEffort": "low", "description": "low"},
{"reasoningEffort": "medium", "description": "medium"},
{"reasoningEffort": "high", "description": "high"}
],
"defaultReasoningEffort": "medium",
"inputModalities": ["text", "image"],
"outputModalities": ["text"],
"supportsPersonality": false,
"upgrade": null,
"source": "test"
}
])
.to_string();
sqlx::query(
"insert or replace into model_plan_snapshots (plan_type, models_json, fetched_at) values (?, ?, ?)",
)
.bind("plus")
.bind(models_json)
.bind("2026-06-18T00:00:00Z")
.execute(pool)
.await
.unwrap();
}
async fn insert_named_account(
pool: &SqlitePool,
id: &str,
access_token: &str,
chatgpt_account_id: &str,
) {
insert_model_snapshot(pool).await;
sqlx::query(
"insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
@@ -1566,13 +1656,8 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
port: 0,
},
api: ApiConfig { base_url },
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -1582,7 +1667,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -1591,10 +1675,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -1612,8 +1692,6 @@ fn test_config(database_url: String, base_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
@@ -99,7 +99,8 @@ async fn responses_should_skip_event_log_when_logging_disabled() {
.mount(&server)
.await;
let (app, api_key, pool, _dir) = test_app_with_account_and_pool(server.uri()).await;
let (app, api_key, pool, _dir) =
test_app_with_account_pool_and_disabled_logging(server.uri()).await;
let response = app
.oneshot(
Request::builder()
+4 -17
View File
@@ -10,9 +10,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -121,13 +120,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -137,7 +131,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -146,10 +139,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -167,8 +156,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+19 -17
View File
@@ -10,9 +10,8 @@ use codex_proxy_rs::{
admin::monitoring::event_store::SqliteEventLogStore,
config::types::AppConfig,
config::types::{
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
},
infra::database::connect_sqlite,
proxy::dispatch::session_affinity::SqliteSessionAffinityStore,
@@ -140,6 +139,7 @@ async fn test_app_with_client_api_key() -> (axum::Router, String, tempfile::Temp
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
let plaintext = insert_client_api_key(&pool).await;
insert_model_snapshot(&pool).await;
let config = test_config(url);
let stores = BackgroundTaskStores {
accounts: SqliteAccountStore::new(pool.clone()),
@@ -164,6 +164,20 @@ async fn test_app_with_client_api_key() -> (axum::Router, String, tempfile::Temp
)
}
async fn insert_model_snapshot(pool: &SqlitePool) {
sqlx::query(
r#"insert or replace into model_plan_snapshots (plan_type, models_json, fetched_at) values (?, ?, ?)"#,
)
.bind("plus")
.bind(
r#"[{"id":"gpt-5.5","displayName":"GPT 5.5","description":"Test model","isDefault":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium","description":"medium"}],"defaultReasoningEffort":"medium","inputModalities":["text"],"outputModalities":["text"],"supportsPersonality":false,"upgrade":null,"source":"test"}]"#,
)
.bind("2026-06-18T00:00:00Z")
.execute(pool)
.await
.unwrap();
}
async fn insert_client_api_key(pool: &SqlitePool) -> String {
SqliteClientKeyStore::new(pool.clone())
.create("test")
@@ -186,13 +200,8 @@ fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -202,7 +211,6 @@ fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -211,10 +219,6 @@ fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -232,8 +236,6 @@ fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+1 -4
View File
@@ -4,10 +4,7 @@ use super::*;
async fn model_refresh_task_should_start_and_shutdown() {
let model_service = Arc::new(ModelService::new(
ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
model_aliases: Default::default(),
},
None,
None,
+4 -17
View File
@@ -1,9 +1,8 @@
use std::collections::BTreeMap;
use codex_proxy_rs::config::types::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, ServerConfig, TlsConfig, UsageStatsConfig,
WebSocketPoolConfig,
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, QuotaConfig,
QuotaWarningThresholds, ServerConfig, TlsConfig, WebSocketPoolConfig,
};
pub fn test_config(database_url: String) -> AppConfig {
@@ -15,13 +14,8 @@ pub fn test_config(database_url: String) -> AppConfig {
api: ApiConfig {
base_url: "https://chatgpt.com/backend-api".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
account_routes: BTreeMap::new(),
},
model_aliases: BTreeMap::new(),
model_account_routes: BTreeMap::new(),
auth: AuthConfig {
refresh_margin_seconds: 300,
refresh_enabled: true,
@@ -31,7 +25,6 @@ pub fn test_config(database_url: String) -> AppConfig {
rotation_strategy: "least_used".to_string(),
tier_priority: Vec::new(),
oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(),
oauth_auth_endpoint: "https://auth.openai.com/oauth/authorize".to_string(),
oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(),
},
quota: QuotaConfig {
@@ -40,10 +33,6 @@ pub fn test_config(database_url: String) -> AppConfig {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
tls: TlsConfig {
@@ -61,8 +50,6 @@ pub fn test_config(database_url: String) -> AppConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
+11 -29
View File
@@ -17,14 +17,9 @@ use codex_proxy_rs::upstream::transport::{
#[test]
fn model_catalog_should_parse_alias_reasoning_and_service_tier_suffixes() {
let mut aliases = BTreeMap::new();
aliases.insert("codex-fast".to_string(), "gpt-5.5".to_string());
let catalog = ModelCatalog::from_config(&ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases,
});
let mut model_aliases = BTreeMap::new();
model_aliases.insert("codex-fast".to_string(), "gpt-5.5".to_string());
let catalog = ModelCatalog::from_config(&ModelConfig { model_aliases });
let parsed = catalog.parse_model_name("codex-fast-high-flex");
@@ -39,27 +34,21 @@ fn model_catalog_should_parse_alias_reasoning_and_service_tier_suffixes() {
}
#[test]
fn model_catalog_should_fall_back_to_default_for_unknown_model_names() {
fn model_catalog_should_keep_unknown_model_names_without_fallback() {
let catalog = ModelCatalog::from_config(&ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
model_aliases: BTreeMap::new(),
});
let parsed = catalog.parse_model_name("unknown-medium");
assert_eq!(parsed.model_id, "gpt-5.5");
assert_eq!(parsed.model_id, "unknown");
assert_eq!(parsed.reasoning_effort, Some("medium".to_string()));
}
#[test]
fn model_catalog_should_parse_none_and_minimal_reasoning_suffixes() {
let catalog = ModelCatalog::from_config(&ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
model_aliases: BTreeMap::new(),
});
let none = catalog.parse_model_name("gpt-5.5-none");
@@ -98,10 +87,7 @@ fn model_catalog_should_merge_backend_snapshots_and_build_plan_allowlist() {
);
let catalog = ModelCatalog::from_config_and_snapshots(
&ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
model_aliases: BTreeMap::new(),
},
&[snapshot],
);
@@ -122,13 +108,12 @@ fn model_catalog_should_merge_backend_snapshots_and_build_plan_allowlist() {
}
#[tokio::test]
async fn model_service_should_fall_back_to_static_catalog_when_snapshot_store_is_missing() {
async fn model_service_should_return_empty_catalog_when_snapshot_store_is_missing() {
let service = ModelService::new(test_model_config(), None, None, None);
let catalog = service.catalog().await;
assert_eq!(catalog.models().len(), 1);
assert_eq!(catalog.models()[0].id, "gpt-5.5");
assert!(catalog.models().is_empty());
}
#[tokio::test]
@@ -205,10 +190,7 @@ async fn model_service_should_return_no_accounts_when_no_active_plans_exist() {
fn test_model_config() -> ModelConfig {
ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
model_aliases: BTreeMap::new(),
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ use codex_proxy_rs::proxy::openai::responses::{
response_failed_sse_event, translate_response_to_codex, translate_response_to_compact,
OpenAiResponsesRequest,
};
use codex_proxy_rs::upstream::models::{ModelConfig, ParsedModelName};
use codex_proxy_rs::upstream::models::ParsedModelName;
use codex_proxy_rs::upstream::protocol::events::{
cooldown_with_jitter, extract_sse_usage, extract_usage, parse_rate_limit_headers,
parse_rate_limits_event, rate_limit_quota, retry_after_seconds_from_body, RateLimitWindow,
+2 -16
View File
@@ -319,14 +319,7 @@ fn codex_responses_model_options_should_apply_suffix_defaults_and_include_reason
reasoning_effort: Some("high".to_string()),
service_tier: Some("fast".to_string()),
};
let config = ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("medium".to_string()),
service_tier: Some("flex".to_string()),
aliases: Default::default(),
};
apply_response_model_options(&mut request, &parsed, &config);
apply_response_model_options(&mut request, &parsed);
assert_eq!(request.model, "gpt-5.5");
assert_eq!(
@@ -351,14 +344,7 @@ fn codex_responses_model_options_should_preserve_client_include_and_normalize_bo
reasoning_effort: Some("low".to_string()),
service_tier: Some("flex".to_string()),
};
let config = ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: Some("medium".to_string()),
service_tier: Some("flex".to_string()),
aliases: Default::default(),
};
apply_response_model_options(&mut request, &parsed, &config);
apply_response_model_options(&mut request, &parsed);
assert_eq!(
request.reasoning,
@@ -135,7 +135,7 @@ export function useAccountConnectionTest() {
function handleConnectionTestEvent(event: any) {
if (event.type === 'test_start') {
connectionTestModel.value = event.model || connectionTestModel.value
appendConnectionTestLog(`开始测试 ${connectionTestModel.value || '默认模型'}`, 'info')
appendConnectionTestLog(`开始测试 ${connectionTestModel.value || '未选择模型'}`, 'info')
return
}
if (event.type === 'request') {
+14 -29
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Cpu, Gauge, GitBranch, Plus, RefreshCw, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { Gauge, GitBranch, Plus, RefreshCw, Save, Timer, Trash2, Zap } from '@lucide/vue'
import { getSettings, updateSettings } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -17,7 +17,6 @@ interface AliasRow {
}
interface SettingsForm {
defaultModel: string
modelAliases: Record<string, string>
refreshMarginSeconds: number
refreshConcurrency: number
@@ -32,7 +31,6 @@ const saving = ref(false)
const aliasError = ref('')
const form = reactive<SettingsForm>({
defaultModel: '',
modelAliases: {},
refreshMarginSeconds: 300,
refreshConcurrency: 2,
@@ -86,42 +84,41 @@ const refreshConcurrencyValue = numericModel('refreshConcurrency')
const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
const requestIntervalMsValue = numericModel('requestIntervalMs')
function aliasesToRows(aliases: Record<string, string> = {}) {
return Object.entries(aliases)
function modelAliasesToRows(modelAliases: Record<string, string> = {}) {
return Object.entries(modelAliases)
.sort(([left], [right]) => left.localeCompare(right))
.map(([alias, target]) => ({ alias, target }))
}
function rowsToAliases(rows: AliasRow[]) {
const aliases: Record<string, string> = {}
const modelAliases: Record<string, string> = {}
for (const row of rows) {
const alias = row.alias.trim()
const target = row.target.trim()
if (!alias && !target) continue
if (!alias || !target) {
return { aliases: {}, error: '别名和目标模型需要同时填写' }
return { modelAliases: {}, error: '别名和目标模型需要同时填写' }
}
if (alias === target) {
return { aliases: {}, error: '别名不能指向自身' }
return { modelAliases: {}, error: '别名不能指向自身' }
}
if (aliases[alias] !== undefined) {
return { aliases: {}, error: `别名重复:${alias}` }
if (modelAliases[alias] !== undefined) {
return { modelAliases: {}, error: `别名重复:${alias}` }
}
aliases[alias] = target
modelAliases[alias] = target
}
return { aliases, error: '' }
return { modelAliases, error: '' }
}
function applySettings(data: any) {
form.defaultModel = data.defaultModel || ''
form.modelAliases = data.modelAliases || {}
form.refreshMarginSeconds = Number(data.refreshMarginSeconds ?? 300)
form.refreshConcurrency = Number(data.refreshConcurrency ?? 2)
form.maxConcurrentPerAccount = Number(data.maxConcurrentPerAccount ?? 3)
form.requestIntervalMs = Number(data.requestIntervalMs ?? 50)
form.rotationStrategy = (data.rotationStrategy || 'least_used') as RotationStrategy
aliasRows.value = aliasesToRows(form.modelAliases)
aliasRows.value = modelAliasesToRows(form.modelAliases)
aliasError.value = ''
}
@@ -175,8 +172,7 @@ async function handleSave() {
try {
saving.value = true
const data = await updateSettings({
defaultModel: form.defaultModel,
modelAliases: aliasResult.aliases,
modelAliases: aliasResult.modelAliases,
refreshMarginSeconds: form.refreshMarginSeconds,
refreshConcurrency: form.refreshConcurrency,
maxConcurrentPerAccount: form.maxConcurrentPerAccount,
@@ -233,22 +229,11 @@ onMounted(loadSettings)
v-loading="loading"
:padded="false"
title="运行参数"
description="默认模型、请求节奏和 Token 刷新。"
description="请求节奏、账号并发和 Token 刷新。"
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid gap-4 lg:grid-cols-2">
<div class="grid gap-2">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
默认模型
</span>
<BaseInput v-model="form.defaultModel">
<template #prefix>
<Cpu class="size-4" />
</template>
</BaseInput>
</div>
<div class="grid gap-2">
<span class="text-xs leading-[1.15] font-bold text-(--cp-text-secondary)">
单账号最大并发
@@ -408,7 +393,7 @@ onMounted(loadSettings)
<span class="text-[14px] leading-[1.15] font-[760]">{{ option.label }}</span>
</span>
<span
class="mt-2 block text-[13px] leading-[1.5] font-[650] text-(--cp-text-secondary)"
class="mt-2 block text-[13px] leading-normal font-[650] text-(--cp-text-secondary)"
>
{{ option.description }}
</span>