refactor: migrate workspace to monolith layout

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-22 23:45:33 +08:00
co-authored by Codex
parent a28ecbe4aa
commit 552ec2424c
359 changed files with 20380 additions and 21716 deletions
Generated
+16 -121
View File
@@ -365,16 +365,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "codex-proxy-adapters"
name = "codex-proxy-rs"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"async-trait",
"axum",
"base64",
"bytes",
"chrono",
"codex-proxy-core",
"codex-proxy-platform",
"config",
"dirs",
"futures",
"hex",
"hmac",
"indexmap",
"rand 0.10.1",
"reqwest",
"rustls",
"rustls-native-certs",
@@ -382,132 +389,24 @@ dependencies = [
"secrecy",
"serde",
"serde_json",
"serde_yml",
"sha2 0.11.0",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tracing",
"tungstenite",
"uuid",
"wiremock",
]
[[package]]
name = "codex-proxy-assets"
version = "0.1.0"
dependencies = [
"axum",
"tempfile",
"tokio",
"tower",
]
[[package]]
name = "codex-proxy-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"chrono",
"hex",
"indexmap",
"rand 0.10.1",
"serde",
"serde_json",
"sha2 0.11.0",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "codex-proxy-platform"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"chrono",
"config",
"dirs",
"hex",
"hmac",
"rand 0.10.1",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"secrecy",
"serde",
"serde_json",
"serde_yml",
"sha2 0.11.0",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"zeroize",
]
[[package]]
name = "codex-proxy-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"bytes",
"chrono",
"codex-proxy-adapters",
"codex-proxy-core",
"codex-proxy-platform",
"futures",
"reqwest",
"secrecy",
"serde_json",
"sqlx",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite",
"tracing",
"uuid",
"wiremock",
]
[[package]]
name = "codex-proxy-server"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64",
"chrono",
"codex-proxy-adapters",
"codex-proxy-assets",
"codex-proxy-core",
"codex-proxy-platform",
"codex-proxy-runtime",
"futures",
"secrecy",
"serde",
"serde_json",
"serde_yml",
"sqlx",
"tempfile",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tower",
"tower-http",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tungstenite",
"uuid",
"wiremock",
"zeroize",
]
[[package]]
@@ -3863,10 +3762,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xtask"
version = "0.1.0"
[[package]]
name = "yaml-rust2"
version = "0.11.0"
+12 -10
View File
@@ -1,17 +1,14 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
[package]
name = "codex-proxy-rs"
version = "0.1.0"
edition = "2021"
license = "MIT"
publish = false
rust-version = "1.95"
description = "AI protocol gateway backed by ChatGPT/Codex accounts."
[workspace.dependencies]
[dependencies]
aes-gcm = "0.10.3"
anyhow = "1.0.102"
argon2 = "0.5.3"
async-trait = "0.1.89"
axum = "0.8.9"
@@ -38,8 +35,8 @@ sqlx = { version = "0.9.0", features = ["runtime-tokio", "tls-rustls", "sqlite",
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net", "test-util"] }
tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring", "tls12"] }
tokio-stream = "0.1.17"
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout"] }
tracing = "0.1.44"
tracing-appender = "0.2.5"
@@ -48,16 +45,21 @@ tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
zeroize = "1.8.2"
[dev-dependencies]
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
wiremock = "0.6.5"
[patch.crates-io]
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
[workspace.lints.rust]
[lints.rust]
future_incompatible = { level = "warn", priority = -1 }
nonstandard_style = { level = "deny", priority = -1 }
unsafe_code = "forbid"
[workspace.lints.clippy]
[lints.clippy]
all = { level = "deny", priority = 10 }
redundant_clone = { level = "deny", priority = 9 }
large_enum_variant = { level = "warn", priority = 8 }
+2 -3
View File
@@ -1,9 +1,9 @@
FROM rust:1.95-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY Cargo.toml Cargo.lock rust-toolchain.toml ./
COPY src ./src
COPY migrations ./migrations
COPY web/dist ./web/dist
RUN cargo build --release --locked
FROM debian:bookworm-slim
@@ -15,7 +15,6 @@ RUN apt-get update \
WORKDIR /app
COPY --from=builder /app/target/release/codex-proxy-rs /usr/local/bin/codex-proxy-rs
COPY config.yaml ./config.yaml
COPY migrations ./migrations
EXPOSE 8080
-38
View File
@@ -1,38 +0,0 @@
[package]
name = "codex-proxy-adapters"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
async-trait.workspace = true
bytes.workspace = true
codex-proxy-core = { path = "../core" }
codex-proxy-platform = { path = "../platform" }
chrono.workspace = true
futures.workspace = true
indexmap.workspace = true
reqwest.workspace = true
rustls.workspace = true
rustls-native-certs.workspace = true
rustls-pki-types.workspace = true
secrecy.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-rustls.workspace = true
tokio-tungstenite.workspace = true
tracing.workspace = true
tungstenite.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile = "3.27.0"
wiremock = "0.6.5"
[lints]
workspace = true
-9
View File
@@ -1,9 +0,0 @@
//! Codex 上游适配器。
pub mod client;
pub mod fingerprint;
pub mod websocket;
pub use client::{
endpoint_request_path, endpoint_url, primary_usage_request_path, usage_endpoint_urls,
};
@@ -1,5 +0,0 @@
//! Codex WebSocket 适配器。
pub mod connect;
pub mod opening;
pub mod pool;
-7
View File
@@ -1,7 +0,0 @@
#![deny(missing_docs)]
//! 适配器层,承载具体存储、HTTP、OAuth 和 WebSocket 实现。
pub mod codex;
pub mod oauth;
pub mod sqlite;
-4
View File
@@ -1,4 +0,0 @@
//! OAuth 上游适配器。
/// OpenAI OAuth 的 Reqwest 实现。
pub mod openai;
@@ -1,10 +0,0 @@
//! SQLite 账号 token 加解密辅助。
use secrecy::SecretString;
use codex_proxy_platform::crypto::{CryptoResult, SecretBox};
/// 加密账号 token。
pub fn encrypt_token(secret_box: &SecretBox, token: &SecretString) -> CryptoResult<String> {
secret_box.encrypt(token)
}
-328
View File
@@ -1,328 +0,0 @@
//! SQLite Cookie 存储。
use chrono::{DateTime, Duration, Utc};
use secrecy::{ExposeSecret, SecretString};
use sqlx::{Row, SqlitePool};
use thiserror::Error;
use uuid::Uuid;
use codex_proxy_platform::crypto::{CryptoError, SecretBox};
const DEFAULT_COOKIE_DOMAIN: &str = "chatgpt.com";
const CAPTURABLE_COOKIES: &[&str] = &["cf_clearance"];
/// SQLite Cookie 存储错误。
#[derive(Debug, Error)]
pub enum SqliteCookieStoreError {
/// 数据库错误。
#[error("sqlite cookie store database error: {0}")]
Database(#[from] sqlx::Error),
/// 加解密错误。
#[error("sqlite cookie store crypto error: {0}")]
Crypto(#[from] CryptoError),
}
/// SQLite Cookie 存储结果。
pub type SqliteCookieStoreResult<T> = Result<T, SqliteCookieStoreError>;
/// SQLite Cookie 存储。
#[derive(Clone)]
pub struct SqliteCookieStore {
pool: SqlitePool,
secret_box: SecretBox,
}
impl SqliteCookieStore {
/// 构造存储。
pub fn new(pool: SqlitePool, secret_box: SecretBox) -> Self {
Self { pool, secret_box }
}
/// 返回底层连接池。
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// 检查账号是否存在。
pub async fn account_exists(&self, account_id: &str) -> SqliteCookieStoreResult<bool> {
let row = sqlx::query("select 1 from accounts where id = ?")
.bind(account_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
/// 捕获上游 `Set-Cookie` 响应头中允许持久化的 Cookie。
pub async fn capture_set_cookie(
&self,
account_id: &str,
raw: &str,
) -> SqliteCookieStoreResult<()> {
let Some(parsed) = parse_set_cookie(raw) else {
return Ok(());
};
if !CAPTURABLE_COOKIES.contains(&parsed.name.as_str()) {
return Ok(());
}
self.upsert_cookie(account_id, parsed).await
}
/// 将 Cookie 请求头写入账号 Cookie 存储。
pub async fn set_cookie_header(
&self,
account_id: &str,
raw: &str,
) -> SqliteCookieStoreResult<usize> {
let parsed = parse_cookie_header(raw);
let count = parsed.len();
for cookie in parsed {
self.upsert_cookie(account_id, cookie).await?;
}
Ok(count)
}
/// 为请求域名读取账号 Cookie 请求头。
pub async fn cookie_header(
&self,
account_id: &str,
request_domain: &str,
) -> SqliteCookieStoreResult<Option<String>> {
self.cookie_header_for_request(account_id, request_domain, "/")
.await
}
/// 为请求域名和路径读取账号 Cookie 请求头。
pub async fn cookie_header_for_request(
&self,
account_id: &str,
request_domain: &str,
request_path: &str,
) -> SqliteCookieStoreResult<Option<String>> {
let rows = sqlx::query(
"select domain, name, value_cipher, path, expires_at from account_cookies where account_id = ?",
)
.bind(account_id)
.fetch_all(&self.pool)
.await?;
let mut pairs = Vec::new();
let now = Utc::now();
for row in rows {
let domain = row.get::<String, _>("domain");
if !domain_matches(request_domain, &domain) {
continue;
}
let path = row.get::<String, _>("path");
if !path_matches(request_path, &path) {
continue;
}
let expires_at = row.get::<Option<String>, _>("expires_at");
if cookie_is_expired(expires_at.as_deref(), now) {
continue;
}
let name = row.get::<String, _>("name");
let value_cipher = row.get::<String, _>("value_cipher");
let value = self.secret_box.decrypt(&value_cipher)?;
pairs.push(CookieHeaderPair {
path_len: path.len(),
name: name.clone(),
value: format!("{name}={}", value.expose_secret()),
});
}
if pairs.is_empty() {
Ok(None)
} else {
pairs.sort_by(|left, right| {
right
.path_len
.cmp(&left.path_len)
.then_with(|| left.name.cmp(&right.name))
});
Ok(Some(
pairs
.into_iter()
.map(|pair| pair.value)
.collect::<Vec<_>>()
.join("; "),
))
}
}
/// 删除账号全部 Cookie。
pub async fn delete_account_cookies(&self, account_id: &str) -> SqliteCookieStoreResult<u64> {
let result = sqlx::query("delete from account_cookies where account_id = ?")
.bind(account_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
/// 删除已经过期的 Cookie。
pub async fn cleanup_expired(&self, now: DateTime<Utc>) -> SqliteCookieStoreResult<u64> {
let result = sqlx::query(
"delete from account_cookies where expires_at is not null and expires_at <= ?",
)
.bind(now.to_rfc3339())
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
async fn upsert_cookie(
&self,
account_id: &str,
parsed: ParsedCookie,
) -> SqliteCookieStoreResult<()> {
let now = Utc::now().to_rfc3339();
let value_cipher = self
.secret_box
.encrypt(&SecretString::new(parsed.value.into()))?;
sqlx::query(
"insert into account_cookies (id, account_id, domain, name, value_cipher, path, expires_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?) on conflict(account_id, domain, name, path) do update set value_cipher = excluded.value_cipher, expires_at = excluded.expires_at, updated_at = excluded.updated_at",
)
.bind(Uuid::new_v4().to_string())
.bind(account_id)
.bind(parsed.domain)
.bind(parsed.name)
.bind(value_cipher)
.bind(parsed.path)
.bind(parsed.expires_at)
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedCookie {
domain: String,
name: String,
value: String,
path: String,
expires_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CookieHeaderPair {
path_len: usize,
name: String,
value: String,
}
fn parse_set_cookie(raw: &str) -> Option<ParsedCookie> {
let mut parts = raw.split(';').map(str::trim);
let (name, value) = parts.next()?.split_once('=')?;
let name = name.trim();
let value = value.trim();
if name.is_empty() || value.is_empty() {
return None;
}
let mut domain = DEFAULT_COOKIE_DOMAIN.to_string();
let mut path = "/".to_string();
let mut expires_at = None;
for part in parts {
let Some((attribute, value)) = part.split_once('=') else {
continue;
};
match attribute.trim().to_ascii_lowercase().as_str() {
"domain" => domain = value.trim().trim_start_matches('.').to_string(),
"path" => path = normalize_cookie_path(value),
"max-age" => {
if let Ok(seconds) = value.trim().parse::<i64>() {
expires_at = Some(max_age_expires_at(seconds));
}
break;
}
"expires" => expires_at = Some(value.trim().to_string()),
_ => {}
}
}
Some(ParsedCookie {
domain,
name: name.to_string(),
value: value.to_string(),
path,
expires_at,
})
}
fn max_age_expires_at(seconds: i64) -> String {
let now = Utc::now();
if seconds <= 0 {
return (now - Duration::seconds(1)).to_rfc3339();
}
(now + Duration::seconds(seconds.min(i32::MAX as i64))).to_rfc3339()
}
fn parse_cookie_header(raw: &str) -> Vec<ParsedCookie> {
raw.split(';')
.map(str::trim)
.filter_map(|part| {
let (name, value) = part.split_once('=')?;
let name = name.trim();
let value = value.trim();
if name.is_empty() || value.is_empty() {
return None;
}
Some(ParsedCookie {
domain: DEFAULT_COOKIE_DOMAIN.to_string(),
name: name.to_string(),
value: value.to_string(),
path: "/".to_string(),
expires_at: None,
})
})
.collect()
}
fn domain_matches(request_domain: &str, cookie_domain: &str) -> bool {
request_domain == cookie_domain
|| request_domain
.strip_suffix(cookie_domain)
.is_some_and(|prefix| prefix.ends_with('.'))
}
fn path_matches(request_path: &str, cookie_path: &str) -> bool {
let request_path = normalize_request_path(request_path);
let cookie_path = normalize_cookie_path(cookie_path);
request_path == cookie_path
|| (request_path.starts_with(&cookie_path)
&& (cookie_path.ends_with('/')
|| request_path
.as_bytes()
.get(cookie_path.len())
.is_some_and(|byte| *byte == b'/')))
}
fn normalize_request_path(path: &str) -> String {
let path = path.trim();
if path.starts_with('/') {
path.to_string()
} else {
format!("/{path}")
}
}
fn normalize_cookie_path(path: &str) -> String {
let path = path.trim();
if path.starts_with('/') {
path.to_string()
} else {
"/".to_string()
}
}
fn cookie_is_expired(expires_at: Option<&str>, now: DateTime<Utc>) -> bool {
expires_at
.and_then(parse_cookie_expires_at)
.is_some_and(|expires_at| expires_at <= now)
}
fn parse_cookie_expires_at(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc2822(value)
.or_else(|_| DateTime::parse_from_rfc3339(value))
.map(|expires_at| expires_at.with_timezone(&Utc))
.ok()
}
-22
View File
@@ -1,22 +0,0 @@
//! SQLite 适配器。
/// 账号 token 存储辅助。
pub mod account_tokens;
/// 账号用量存储。
pub mod account_usage;
/// 账号读取适配器。
pub mod accounts;
/// 管理员会话存储。
pub mod admin_sessions;
/// 客户端 API Key 存储。
pub mod client_keys;
/// Cookie 存储。
pub mod cookies;
/// 事件日志存储。
pub mod events;
/// 模型快照存储。
pub mod models;
/// 刷新租约存储。
pub mod refresh_leases;
/// 会话亲和性存储。
pub mod session_affinity;
-102
View File
@@ -1,102 +0,0 @@
//! 模型快照 SQLite 仓储。
use async_trait::async_trait;
use chrono::Utc;
use sqlx::{Row, SqlitePool};
use thiserror::Error;
use codex_proxy_core::models::{
model::ModelPlanSnapshot,
ports::{ModelSnapshotStore, ModelSnapshotStoreError, ModelSnapshotStoreResult},
};
/// 模型快照仓储实现。
#[derive(Debug, Clone)]
pub struct ModelSnapshotRepository {
pool: SqlitePool,
}
/// 模型快照仓储错误。
#[derive(Debug, Error)]
pub enum ModelSnapshotRepositoryError {
/// 存储层错误。
#[error("model snapshot store error: {0}")]
Store(#[from] ModelSnapshotStoreError),
/// 数据库错误。
#[error("model snapshot database error: {0}")]
Database(#[from] sqlx::Error),
/// JSON 错误。
#[error("model snapshot json error: {0}")]
Json(#[from] serde_json::Error),
}
impl ModelSnapshotRepository {
/// 构造仓储。
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// 直接写入单个计划快照。
pub async fn replace_plan_snapshot(
&self,
snapshot: &ModelPlanSnapshot,
) -> ModelSnapshotStoreResult<()> {
let models_json = serde_json::to_string(&snapshot.models).map_err(|error| {
ModelSnapshotStoreError::OperationFailed {
message: error.to_string(),
}
})?;
let fetched_at = Utc::now().to_rfc3339();
sqlx::query(
"insert into model_plan_snapshots (plan_type, models_json, fetched_at) values (?, ?, ?) \
on conflict(plan_type) do update set models_json = excluded.models_json, fetched_at = excluded.fetched_at",
)
.bind(&snapshot.plan_type)
.bind(models_json)
.bind(fetched_at)
.execute(&self.pool)
.await
.map_err(|error| ModelSnapshotStoreError::OperationFailed {
message: error.to_string(),
})?;
Ok(())
}
/// 列出所有快照。
pub async fn list_plan_snapshots(&self) -> ModelSnapshotStoreResult<Vec<ModelPlanSnapshot>> {
let rows = sqlx::query(
"select plan_type, models_json from model_plan_snapshots order by plan_type asc",
)
.fetch_all(&self.pool)
.await
.map_err(|error| ModelSnapshotStoreError::OperationFailed {
message: error.to_string(),
})?;
rows.into_iter()
.map(|row| {
let plan_type: String = row.get("plan_type");
let models_json: String = row.get("models_json");
let models = serde_json::from_str(&models_json).map_err(|error| {
ModelSnapshotStoreError::OperationFailed {
message: error.to_string(),
}
})?;
Ok(ModelPlanSnapshot { plan_type, models })
})
.collect()
}
}
#[async_trait]
impl ModelSnapshotStore for ModelSnapshotRepository {
async fn replace_plan_snapshot(
&self,
snapshot: &ModelPlanSnapshot,
) -> ModelSnapshotStoreResult<()> {
Self::replace_plan_snapshot(self, snapshot).await
}
async fn list_plan_snapshots(&self) -> ModelSnapshotStoreResult<Vec<ModelPlanSnapshot>> {
Self::list_plan_snapshots(self).await
}
}
@@ -1,80 +0,0 @@
//! SQLite 刷新租约存储。
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
use thiserror::Error;
/// SQLite 刷新租约存储结果。
pub type SqliteRefreshLeaseStoreResult<T> = Result<T, SqliteRefreshLeaseStoreError>;
/// SQLite 刷新租约存储错误。
#[derive(Debug, Error)]
pub enum SqliteRefreshLeaseStoreError {
/// SQLite 操作失败。
#[error("sqlite refresh lease query failed: {0}")]
Sqlx(#[from] sqlx::Error),
}
/// SQLite 刷新租约存储。
#[derive(Clone)]
pub struct SqliteRefreshLeaseStore {
pool: SqlitePool,
}
impl SqliteRefreshLeaseStore {
/// 构造存储。
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// 返回底层连接池。
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// 尝试获取账号刷新租约。
pub async fn try_acquire(
&self,
account_id: &str,
owner: &str,
expires_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> SqliteRefreshLeaseStoreResult<bool> {
let result = sqlx::query(
r#"
insert into account_refresh_leases (account_id, owner, expires_at, updated_at)
values (?, ?, ?, ?)
on conflict(account_id) do update set
owner = excluded.owner,
expires_at = excluded.expires_at,
updated_at = excluded.updated_at
where account_refresh_leases.expires_at <= ?
or account_refresh_leases.owner = ?
"#,
)
.bind(account_id)
.bind(owner)
.bind(expires_at.to_rfc3339())
.bind(now.to_rfc3339())
.bind(now.to_rfc3339())
.bind(owner)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// 释放账号刷新租约。
pub async fn release(
&self,
account_id: &str,
owner: &str,
) -> SqliteRefreshLeaseStoreResult<bool> {
let result =
sqlx::query("delete from account_refresh_leases where account_id = ? and owner = ?")
.bind(account_id)
.bind(owner)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
}
}
@@ -1,170 +0,0 @@
//! SQLite 会话亲和性存储。
use chrono::{DateTime, Duration, Utc};
use codex_proxy_core::serving::affinity::{SessionAffinityEntry, StoredSessionAffinity};
use sqlx::SqlitePool;
use thiserror::Error;
/// SQLite 会话亲和性存储。
#[derive(Clone)]
pub struct SqliteSessionAffinityStore {
pool: SqlitePool,
}
/// SQLite 会话亲和性存储错误。
#[derive(Debug, Error)]
pub enum SqliteSessionAffinityStoreError {
/// 数据库错误。
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
/// 时间戳格式无效。
#[error("invalid session affinity timestamp: {0}")]
InvalidTimestamp(#[from] chrono::ParseError),
/// 函数调用 ID JSON 无效。
#[error("invalid session affinity function call ids: {0}")]
InvalidFunctionCallIds(#[from] serde_json::Error),
}
/// SQLite 会话亲和性存储结果。
pub type SqliteSessionAffinityStoreResult<T> = Result<T, SqliteSessionAffinityStoreError>;
impl SqliteSessionAffinityStore {
/// 构造存储。
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
/// 返回底层连接池。
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// 插入或更新响应 ID 的会话亲和性条目。
pub async fn upsert(
&self,
response_id: &str,
entry: &SessionAffinityEntry,
ttl: Duration,
) -> SqliteSessionAffinityStoreResult<()> {
let function_call_ids_json = serde_json::to_string(&entry.function_call_ids)?;
let expires_at = entry
.created_at
.checked_add_signed(ttl)
.unwrap_or(DateTime::<Utc>::MAX_UTC);
sqlx::query(
r"
insert into session_affinities (
response_id,
account_id,
conversation_id,
turn_state,
instructions_hash,
input_tokens,
function_call_ids_json,
variant_hash,
expires_at,
created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict(response_id) do update set
account_id = excluded.account_id,
conversation_id = excluded.conversation_id,
turn_state = excluded.turn_state,
instructions_hash = excluded.instructions_hash,
input_tokens = excluded.input_tokens,
function_call_ids_json = excluded.function_call_ids_json,
variant_hash = excluded.variant_hash,
expires_at = excluded.expires_at,
created_at = excluded.created_at",
)
.bind(response_id)
.bind(&entry.account_id)
.bind(&entry.conversation_id)
.bind(&entry.turn_state)
.bind(&entry.instructions_hash)
.bind(entry.input_tokens.map(u64_to_i64_saturating))
.bind(function_call_ids_json)
.bind(&entry.variant_hash)
.bind(expires_at.to_rfc3339())
.bind(entry.created_at.to_rfc3339())
.execute(&self.pool)
.await?;
Ok(())
}
/// 列出当前未过期的会话亲和性条目。
pub async fn list_active(
&self,
now: DateTime<Utc>,
) -> SqliteSessionAffinityStoreResult<Vec<StoredSessionAffinity>> {
let rows = sqlx::query(
r"
select
response_id,
account_id,
conversation_id,
turn_state,
instructions_hash,
input_tokens,
function_call_ids_json,
variant_hash,
expires_at,
created_at
from session_affinities
where expires_at > ?
order by created_at asc, response_id asc",
)
.bind(now.to_rfc3339())
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| stored_session_affinity_from_row(&row))
.collect()
}
/// 删除已过期的会话亲和性条目。
pub async fn delete_expired(
&self,
now: DateTime<Utc>,
) -> SqliteSessionAffinityStoreResult<u64> {
let result = sqlx::query("delete from session_affinities where expires_at <= ?")
.bind(now.to_rfc3339())
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}
fn stored_session_affinity_from_row(
row: &sqlx::sqlite::SqliteRow,
) -> SqliteSessionAffinityStoreResult<StoredSessionAffinity> {
use sqlx::Row as _;
let function_call_ids_json = row.get::<String, _>("function_call_ids_json");
Ok(StoredSessionAffinity {
response_id: row.get("response_id"),
entry: SessionAffinityEntry {
account_id: row.get("account_id"),
conversation_id: row.get("conversation_id"),
turn_state: row.get("turn_state"),
instructions_hash: row.get("instructions_hash"),
input_tokens: optional_nonnegative_i64_to_u64(row.get("input_tokens")),
function_call_ids: serde_json::from_str(&function_call_ids_json)?,
variant_hash: row.get("variant_hash"),
created_at: parse_rfc3339(&row.get::<String, _>("created_at"))?,
},
expires_at: parse_rfc3339(&row.get::<String, _>("expires_at"))?,
})
}
fn parse_rfc3339(value: &str) -> SqliteSessionAffinityStoreResult<DateTime<Utc>> {
Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc))
}
fn optional_nonnegative_i64_to_u64(value: Option<i64>) -> Option<u64> {
value.and_then(|value| u64::try_from(value).ok())
}
fn u64_to_i64_saturating(value: u64) -> i64 {
value.min(i64::MAX as u64) as i64
}
-93
View File
@@ -1,93 +0,0 @@
use chrono::{Duration, Utc};
use codex_proxy_adapters::sqlite::{
accounts::{NewAccount, SqliteAccountStore},
cookies::SqliteCookieStore,
};
use codex_proxy_core::accounts::model::AccountStatus;
use codex_proxy_platform::{crypto::SecretBox, storage::connect_sqlite};
use secrecy::SecretString;
use sqlx::SqlitePool;
#[tokio::test]
async fn cookie_store_should_capture_cf_clearance_and_replay_by_domain_and_path() {
let (pool, secret_box, _dir) = sqlite_cookie_store_parts("captured-cookies.sqlite").await;
seed_account(&pool, &secret_box, "acct_a").await;
let store = SqliteCookieStore::new(pool, secret_box);
store
.capture_set_cookie("acct_a", "cf_clearance=root; Domain=.chatgpt.com; Path=/")
.await
.unwrap();
store
.capture_set_cookie(
"acct_a",
"cf_clearance=codex; Domain=.chatgpt.com; Path=/codex",
)
.await
.unwrap();
assert_eq!(
store
.cookie_header_for_request("acct_a", "chatgpt.com", "/codex/responses")
.await
.unwrap(),
Some("cf_clearance=codex; cf_clearance=root".to_string())
);
assert_eq!(
store
.cookie_header_for_request("acct_a", "chatgpt.com", "/api/codex/usage")
.await
.unwrap(),
Some("cf_clearance=root".to_string())
);
}
#[tokio::test]
async fn cookie_store_should_ignore_non_capturable_set_cookie_headers() {
let (pool, secret_box, _dir) = sqlite_cookie_store_parts("ignored-cookies.sqlite").await;
seed_account(&pool, &secret_box, "acct_a").await;
let store = SqliteCookieStore::new(pool.clone(), secret_box);
store
.capture_set_cookie("acct_a", "__cf_bm=bot-session; Domain=.chatgpt.com; Path=/")
.await
.unwrap();
let stored_count: (i64,) =
sqlx::query_as("select count(*) from account_cookies where account_id = ?")
.bind("acct_a")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored_count.0, 0);
assert_eq!(
store.cookie_header("acct_a", "chatgpt.com").await.unwrap(),
None
);
}
async fn sqlite_cookie_store_parts(name: &str) -> (SqlitePool, SecretBox, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join(name);
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.unwrap();
(pool, SecretBox::new([21u8; 32]), dir)
}
async fn seed_account(pool: &SqlitePool, secret_box: &SecretBox, id: &str) {
SqliteAccountStore::new(pool.clone(), secret_box.clone())
.insert(NewAccount {
id: id.to_string(),
email: Some(format!("{id}@example.com")),
account_id: Some(format!("chatgpt-{id}")),
user_id: Some(format!("user-{id}")),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(format!("access-{id}").into()),
refresh_token: None,
access_token_expires_at: Some(Utc::now() + Duration::hours(1)),
status: AccountStatus::Active,
})
.await
.unwrap();
}
-115
View File
@@ -1,115 +0,0 @@
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_adapters::sqlite::session_affinity::SqliteSessionAffinityStore;
use codex_proxy_core::serving::affinity::SessionAffinityEntry;
use codex_proxy_platform::storage::connect_sqlite;
#[tokio::test]
async fn session_affinity_store_should_upsert_and_list_active_records() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("session-affinity.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
insert_account(&pool, "acct_1").await;
let store = SqliteSessionAffinityStore::new(pool);
let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
store
.upsert(
"resp_1",
&SessionAffinityEntry {
account_id: "acct_1".to_string(),
conversation_id: "conv_1".to_string(),
turn_state: Some("turn_1".to_string()),
instructions_hash: Some("hash_1".to_string()),
input_tokens: Some(128),
function_call_ids: vec!["call_a".to_string(), "call_b".to_string()],
variant_hash: Some("variant_1".to_string()),
created_at: now,
},
Duration::hours(4),
)
.await
.expect("affinity should be stored");
let records = store
.list_active(now + Duration::minutes(1))
.await
.expect("active affinities should load");
assert_eq!(records.len(), 1);
assert_eq!(records[0].response_id, "resp_1");
assert_eq!(records[0].entry.account_id, "acct_1");
assert_eq!(records[0].entry.function_call_ids, vec!["call_a", "call_b"]);
}
#[tokio::test]
async fn session_affinity_store_should_delete_expired_records() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("session-affinity-cleanup.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
insert_account(&pool, "acct_1").await;
insert_account(&pool, "acct_2").await;
let store = SqliteSessionAffinityStore::new(pool);
let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
store
.upsert(
"resp_expired",
&SessionAffinityEntry {
account_id: "acct_1".to_string(),
conversation_id: "conv_1".to_string(),
turn_state: None,
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: None,
created_at: now - Duration::hours(5),
},
Duration::hours(1),
)
.await
.expect("expired affinity should be stored");
store
.upsert(
"resp_active",
&SessionAffinityEntry {
account_id: "acct_2".to_string(),
conversation_id: "conv_2".to_string(),
turn_state: None,
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: None,
created_at: now,
},
Duration::hours(1),
)
.await
.expect("active affinity should be stored");
let deleted = store
.delete_expired(now)
.await
.expect("expired affinities should be deleted");
let records = store
.list_active(now)
.await
.expect("active affinities should load");
assert_eq!(deleted, 1);
assert_eq!(records.len(), 1);
assert_eq!(records[0].response_id, "resp_active");
}
async fn insert_account(pool: &sqlx::SqlitePool, id: &str) {
sqlx::query(
"insert into accounts (id, access_token_cipher, status, added_at, updated_at) values (?, 'cipher', 'active', '2026-06-18T12:00:00Z', '2026-06-18T12:00:00Z')",
)
.bind(id)
.execute(pool)
.await
.expect("account should be inserted");
}
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "codex-proxy-assets"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
axum.workspace = true
tokio.workspace = true
[dev-dependencies]
tempfile = "3.27.0"
tokio.workspace = true
tower.workspace = true
[lints]
workspace = true
-4
View File
@@ -1,4 +0,0 @@
//! 前端静态资源层,提供 SPA 回退和静态文件服务辅助。
pub mod headers;
pub mod router;
-94
View File
@@ -1,94 +0,0 @@
use axum::{
body::{to_bytes, Body},
http::{header, Request, StatusCode},
};
use tower::ServiceExt;
#[test]
fn asset_headers_should_distinguish_spa_and_fingerprinted_assets() {
assert_eq!(
codex_proxy_assets::headers::cache_control_for_path("/"),
"no-cache"
);
assert_eq!(
codex_proxy_assets::headers::cache_control_for_path("/assets/app.abc123.js"),
"public, max-age=31536000, immutable"
);
}
#[tokio::test]
async fn asset_router_should_serve_index_and_spa_fallback() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("index.html"),
"<!doctype html><main id=\"app\">Codex Proxy</main>",
)
.expect("index should be written");
std::fs::create_dir(dir.path().join("assets")).expect("assets dir should be created");
let router = codex_proxy_assets::router::spa_router(dir.path());
let index = router
.clone()
.oneshot(request("/"))
.await
.expect("index response");
let fallback = router
.oneshot(request("/admin/accounts"))
.await
.expect("fallback response");
assert_eq!(index.status(), StatusCode::OK);
assert_eq!(fallback.status(), StatusCode::OK);
assert_body_contains(index, "Codex Proxy").await;
assert_body_contains(fallback, "Codex Proxy").await;
}
#[tokio::test]
async fn asset_router_should_serve_assets_with_cache_and_security_headers() {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(dir.path().join("index.html"), "<!doctype html>").expect("write index");
std::fs::create_dir(dir.path().join("assets")).expect("assets dir should be created");
std::fs::write(
dir.path().join("assets").join("app.abc123.js"),
"window.__codexProxy = true;",
)
.expect("asset should be written");
let response = codex_proxy_assets::router::spa_router(dir.path())
.oneshot(request("/assets/app.abc123.js"))
.await
.expect("asset response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response.headers().get(header::CACHE_CONTROL),
Some(&header::HeaderValue::from_static(
"public, max-age=31536000, immutable"
))
);
assert_eq!(
response.headers().get(header::X_CONTENT_TYPE_OPTIONS),
Some(&header::HeaderValue::from_static("nosniff"))
);
assert_body_contains(response, "__codexProxy").await;
}
fn request(uri: &str) -> Request<Body> {
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("request should build")
}
async fn assert_body_contains(response: axum::response::Response, expected: &str) {
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
let text = std::str::from_utf8(&body).expect("body should be utf8");
assert!(
text.contains(expected),
"body should contain `{expected}`, got `{text}`"
);
}
-28
View File
@@ -1,28 +0,0 @@
[package]
name = "codex-proxy-core"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
hex.workspace = true
indexmap.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[lints]
workspace = true
[dev-dependencies]
tokio.workspace = true
-21
View File
@@ -1,21 +0,0 @@
//! Cookie 捕获与重放策略。
/// 单个 Cookie 条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CookieEntry {
/// Cookie 名称。
pub name: String,
/// Cookie 值。
pub value: String,
}
/// 将 Cookie 条目组装为 HTTP Cookie 头。
pub fn cookie_header(cookies: &[CookieEntry]) -> Option<String> {
let header = cookies
.iter()
.filter(|cookie| !cookie.name.trim().is_empty())
.map(|cookie| format!("{}={}", cookie.name.trim(), cookie.value))
.collect::<Vec<_>>()
.join("; ");
(!header.is_empty()).then_some(header)
}
-47
View File
@@ -1,47 +0,0 @@
use base64::Engine;
use chrono::{DateTime, TimeZone, Utc};
use serde_json::Value;
/// JWT 过期状态。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JwtExpiry {
/// token 已过期。
Expired,
/// token 仍然有效。
Valid,
/// token 缺失、格式错误或不包含可解析的 exp。
MissingOrInvalid,
}
/// 按给定时间点判断 JWT 的 `exp` 是否已过期。
pub fn jwt_expiry(token: &str, now: DateTime<Utc>) -> JwtExpiry {
let Some(exp) = jwt_exp(token) else {
return JwtExpiry::MissingOrInvalid;
};
if now.timestamp() >= exp {
JwtExpiry::Expired
} else {
JwtExpiry::Valid
}
}
/// 读取 JWT `exp` 并转换成 UTC 时间。
pub fn jwt_expiration(token: &str) -> Option<DateTime<Utc>> {
Utc.timestamp_opt(jwt_exp(token)?, 0).single()
}
fn jwt_exp(token: &str) -> Option<i64> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
let value = serde_json::from_slice::<Value>(&decoded).ok()?;
value.get("exp")?.as_i64()
}
-20
View File
@@ -1,20 +0,0 @@
//! 账号生命周期状态转换规则。
use chrono::{DateTime, Utc};
use crate::accounts::model::{Account, AccountStatus};
/// 根据 token 过期时间推导账号运行时状态。
pub fn effective_status(account: &Account, now: DateTime<Utc>) -> AccountStatus {
if account.status != AccountStatus::Active {
return account.status;
}
if account
.access_token_expires_at
.is_some_and(|expires_at| expires_at <= now)
{
AccountStatus::Expired
} else {
AccountStatus::Active
}
}
-20
View File
@@ -1,20 +0,0 @@
//! 账号领域中的纯逻辑辅助。
/// Cloudflare 阻断恢复状态。
pub mod cloudflare;
/// Cookie 捕获与重放策略。
pub mod cookies;
/// JWT 过期判断。
pub mod jwt;
/// 账号生命周期状态转换。
pub mod lifecycle;
/// 账号领域模型。
pub mod model;
/// 账号池调度策略。
pub mod pool;
/// 账号领域端口。
pub mod ports;
/// 账号管理用例。
pub mod service;
/// 账号用量累积策略。
pub mod usage;
-112
View File
@@ -1,112 +0,0 @@
//! 账号领域端口。
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use crate::accounts::{
model::{Account, AccountStatus},
usage::AccountUsageDelta,
};
/// 账号存储错误。
#[derive(Debug, Error)]
pub enum AccountStoreError {
/// 底层存储失败。
#[error("account store operation failed: {message}")]
OperationFailed {
/// 错误说明。
message: String,
},
}
/// 账号存储结果类型。
pub type AccountStoreResult<T> = Result<T, AccountStoreError>;
/// 提供运行时账号列表的端口。
#[async_trait]
pub trait AccountStore: Send + Sync + 'static {
/// 列出当前账号池可见的账号。
async fn list_pool_accounts(&self) -> AccountStoreResult<Vec<Account>>;
/// 读取单个账号池账号快照。
async fn get_pool_account(&self, account_id: &str) -> AccountStoreResult<Option<Account>> {
let accounts = self.list_pool_accounts().await?;
Ok(accounts
.into_iter()
.find(|account| account.id == account_id))
}
/// 标记账号进入配额冷却期。
async fn mark_quota_limited_until(
&self,
account_id: &str,
cooldown_until: DateTime<Utc>,
) -> AccountStoreResult<bool>;
/// 标记账号进入 Cloudflare 冷却期。
async fn set_cloudflare_cooldown_until(
&self,
account_id: &str,
cooldown_until: DateTime<Utc>,
) -> AccountStoreResult<bool>;
/// 更新账号状态。
async fn set_status(&self, account_id: &str, status: AccountStatus)
-> AccountStoreResult<bool>;
/// 记录账号用量增量。
async fn record_usage_delta(
&self,
account_id: &str,
usage: AccountUsageDelta,
) -> AccountStoreResult<()>;
/// 读取账号当前配额 JSON。
async fn get_quota_json(&self, _account_id: &str) -> AccountStoreResult<Option<String>> {
Ok(None)
}
/// 更新账号当前配额 JSON。
async fn update_quota_json(
&self,
_account_id: &str,
_quota_json: &str,
) -> AccountStoreResult<bool> {
Ok(false)
}
/// 应用已经验证过的账号配额快照。
async fn apply_quota_snapshot(
&self,
account_id: &str,
quota_json: &str,
limit_reached: bool,
cooldown_until: Option<DateTime<Utc>>,
) -> AccountStoreResult<bool> {
let _ = (limit_reached, cooldown_until);
self.update_quota_json(account_id, quota_json).await
}
/// 同步账号当前 rate-limit 统计窗口。
async fn sync_rate_limit_window(
&self,
_account_id: &str,
_reset_at: DateTime<Utc>,
_limit_window_seconds: Option<u64>,
) -> AccountStoreResult<()> {
Ok(())
}
/// 记录账号被用于一次外部请求。
async fn record_request(&self, account_id: &str) -> AccountStoreResult<()> {
self.record_usage_delta(
account_id,
AccountUsageDelta {
requests: 1,
..AccountUsageDelta::default()
},
)
.await
}
}
-47
View File
@@ -1,47 +0,0 @@
//! 账号用量累积策略。
/// 账号用量增量。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AccountUsageDelta {
/// 请求数增量。
pub requests: u64,
/// 输入 token 增量。
pub input_tokens: u64,
/// 输出 token 增量。
pub output_tokens: u64,
/// 缓存 token 增量。
pub cached_tokens: u64,
/// reasoning token 增量。
pub reasoning_tokens: u64,
/// 上游返回的总 token 增量。
pub total_tokens: u64,
/// 空响应数增量。
pub empty_responses: u64,
/// 图片工具输入 token 增量。
pub image_input_tokens: u64,
/// 图片工具输出 token 增量。
pub image_output_tokens: u64,
/// 图片工具成功请求数增量。
pub image_requests: u64,
/// 图片工具失败请求数增量。
pub image_request_failures: u64,
}
impl AccountUsageDelta {
/// 合并两个用量增量。
pub fn merged(self, other: Self) -> Self {
Self {
requests: self.requests + other.requests,
input_tokens: self.input_tokens + other.input_tokens,
output_tokens: self.output_tokens + other.output_tokens,
cached_tokens: self.cached_tokens + other.cached_tokens,
reasoning_tokens: self.reasoning_tokens + other.reasoning_tokens,
total_tokens: self.total_tokens + other.total_tokens,
empty_responses: self.empty_responses + other.empty_responses,
image_input_tokens: self.image_input_tokens + other.image_input_tokens,
image_output_tokens: self.image_output_tokens + other.image_output_tokens,
image_requests: self.image_requests + other.image_requests,
image_request_failures: self.image_request_failures + other.image_request_failures,
}
}
}
-30
View File
@@ -1,30 +0,0 @@
//! 管理员认证领域服务。
/// 管理员登录请求。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminLoginRequest {
/// 用户名。
pub username: String,
/// 明文密码。
pub password: String,
}
/// 管理员认证服务。
#[derive(Debug, Clone)]
pub struct AdminAuthService {
default_username: String,
}
impl AdminAuthService {
/// 构造管理员认证服务。
pub fn new(default_username: impl Into<String>) -> Self {
Self {
default_username: default_username.into(),
}
}
/// 判断用户名是否匹配配置中的管理员用户名。
pub fn username_matches(&self, username: &str) -> bool {
self.default_username == username
}
}
-26
View File
@@ -1,26 +0,0 @@
//! 客户端 API Key 业务服务。
use std::sync::Arc;
use crate::admin::ports::{ClientKeyStore, ClientKeyStoreResult};
/// 客户端 API Key 服务。
#[derive(Clone)]
pub struct ClientKeyService {
store: Arc<dyn ClientKeyStore>,
}
impl ClientKeyService {
/// 构造服务。
pub fn new(store: Arc<dyn ClientKeyStore>) -> Self {
Self { store }
}
/// 验证客户端 API Key。
pub async fn verify(&self, plaintext: &str) -> ClientKeyStoreResult<bool> {
if !plaintext.starts_with("cpr_") {
return Ok(false);
}
self.store.verify_and_touch(plaintext).await
}
}
-6
View File
@@ -1,6 +0,0 @@
//! 管理员领域用例和端口。
pub mod auth;
pub mod client_keys;
pub mod ports;
pub mod settings;
-25
View File
@@ -1,25 +0,0 @@
//! 管理员领域端口。
use async_trait::async_trait;
use thiserror::Error;
/// 客户端 API Key 存储错误。
#[derive(Debug, Error)]
pub enum ClientKeyStoreError {
/// 底层存储失败。
#[error("client key store operation failed: {message}")]
OperationFailed {
/// 错误说明。
message: String,
},
}
/// 客户端 API Key 存储结果类型。
pub type ClientKeyStoreResult<T> = Result<T, ClientKeyStoreError>;
/// 提供客户端 API Key 验证能力的端口。
#[async_trait]
pub trait ClientKeyStore: Send + Sync + 'static {
/// 验证明文客户端 API Key,并在成功时记录使用时间。
async fn verify_and_touch(&self, plaintext: &str) -> ClientKeyStoreResult<bool>;
}
-8
View File
@@ -1,8 +0,0 @@
//! 认证领域中的纯逻辑与端口。
/// OAuth token 刷新策略。
pub mod oauth;
/// 认证上游端口定义。
pub mod ports;
/// 管理员会话领域逻辑。
pub mod session;
-30
View File
@@ -1,30 +0,0 @@
//! 认证相关的上游端口。
use async_trait::async_trait;
use crate::auth::oauth::{DeviceCode, OAuthError, RefreshFailure, TokenPair};
/// 刷新令牌的上游端口。
#[async_trait]
pub trait TokenRefresher: Send + Sync + 'static {
/// 使用给定刷新令牌换取新的 token 对。
async fn refresh(&self, refresh_token: &str) -> Result<TokenPair, RefreshFailure>;
}
/// OAuth 交互上游端口。
#[async_trait]
pub trait OAuthClient: Send + Sync + 'static {
/// 使用 PKCE 授权码交换 token 对。
async fn exchange_code(
&self,
code: &str,
code_verifier: &str,
redirect_uri: &str,
) -> Result<TokenPair, OAuthError>;
/// 请求设备码登录信息。
async fn request_device_code(&self) -> Result<DeviceCode, OAuthError>;
/// 轮询设备码换取 token 对。
async fn poll_device_token(&self, device_code: &str) -> Result<TokenPair, OAuthError>;
}
-24
View File
@@ -1,24 +0,0 @@
//! 管理员会话领域逻辑。
use chrono::{DateTime, Duration, Utc};
/// 管理员会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminSession {
/// 会话 ID。
pub id: String,
/// 过期时间。
pub expires_at: DateTime<Utc>,
}
impl AdminSession {
/// 判断会话在给定时间是否已经过期。
pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
now >= self.expires_at
}
}
/// 根据 TTL 计算会话过期时间。
pub fn session_expiry(now: DateTime<Utc>, ttl_minutes: i64) -> DateTime<Utc> {
now + Duration::minutes(ttl_minutes.max(0))
}
-8
View File
@@ -1,8 +0,0 @@
//! 事件日志领域。
/// 事件日志模型。
pub mod model;
/// 事件日志端口。
pub mod ports;
/// 事件日志策略服务。
pub mod service;
-27
View File
@@ -1,27 +0,0 @@
//! 事件日志端口。
use async_trait::async_trait;
use thiserror::Error;
use crate::events::model::EventLog;
/// 事件日志存储错误。
#[derive(Debug, Error)]
pub enum EventLogStoreError {
/// 底层存储失败。
#[error("event log store operation failed: {message}")]
OperationFailed {
/// 错误说明。
message: String,
},
}
/// 事件日志存储结果。
pub type EventLogStoreResult<T> = Result<T, EventLogStoreError>;
/// 事件日志存储端口。
#[async_trait]
pub trait EventLogStore: Send + Sync + 'static {
/// 写入事件日志。
async fn append(&self, event: &EventLog) -> EventLogStoreResult<()>;
}
-21
View File
@@ -1,21 +0,0 @@
//! 事件日志策略服务。
use crate::events::model::{EventLevel, EventLog};
/// 事件日志服务。
#[derive(Debug, Clone)]
pub struct EventLogService {
enabled: bool,
}
impl EventLogService {
/// 构造事件日志服务。
pub fn new(enabled: bool) -> Self {
Self { enabled }
}
/// 判断事件是否应该记录。
pub fn should_record(&self, event: &EventLog) -> bool {
self.enabled || event.level == EventLevel::Error
}
}
-168
View File
@@ -1,168 +0,0 @@
//! Conversation identity 构建器
//!
//! 从客户端提供的值派生账号作用域的 conversation 和 window ID。
//! 用于会话亲和性和提示缓存。
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::protocol::codex::responses::CodexResponsesRequest;
const LEADING_SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
const LEADING_SYSTEM_REMINDER_CLOSE: &str = "</system-reminder>";
/// 从请求上下文派生的 conversation identity
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationIdentity {
/// 账号作用域的 conversation ID(用作 session_id header)
pub conversation_id: Option<String>,
/// 账号作用域的 window ID
pub window_id: Option<String>,
}
/// 从 prompt_cache_key 和可选的 window_id 构建 conversation identity
///
/// 匹配 Node.js 实现:buildConversationIdentity() + buildAccountScopedIdentity()
pub fn build_conversation_identity(
prompt_cache_key: Option<&str>,
client_window_id: Option<&str>,
account_scope: &str,
) -> ConversationIdentity {
let conversation_id = prompt_cache_key
.filter(|s| !s.trim().is_empty())
.map(|key| build_account_scoped_identity("conversation", account_scope, key));
let window_id = if let Some(client_win) = client_window_id.filter(|s| !s.trim().is_empty()) {
Some(build_account_scoped_identity(
"window",
account_scope,
client_win,
))
} else {
conversation_id
.as_ref()
.map(|conv_id| format!("{}:0", conv_id))
};
ConversationIdentity {
conversation_id,
window_id,
}
}
/// 确保请求拥有上游可复用的 prompt cache key。
///
/// 优先保留客户端显式值;否则用模型、instructions 和首条 user 文本派生稳定 key。
/// 空请求没有稳定锚点时使用随机 UUID,避免所有空请求落到同一条缓存链。
pub fn ensure_prompt_cache_key(request: &mut CodexResponsesRequest) {
if let Some(existing) = request
.prompt_cache_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
request.prompt_cache_key = Some(existing.to_string());
return;
}
request.prompt_cache_key =
Some(derive_stable_conversation_key(request).unwrap_or_else(|| Uuid::new_v4().to_string()));
}
/// 按原版 `stable-conversation-key.ts` 的规则派生稳定 conversation key。
pub fn derive_stable_conversation_key(request: &CodexResponsesRequest) -> Option<String> {
let instructions = request.instructions.chars().take(2000).collect::<String>();
let first_user_text = first_user_text(&request.input);
let normalized_first_user_text = normalize_conversation_anchor_text(&first_user_text);
let first_user_text = if normalized_first_user_text.is_empty() {
first_user_text
} else {
normalized_first_user_text
};
if instructions.is_empty() && first_user_text.is_empty() {
return None;
}
let mut hasher = Sha256::new();
hasher.update(request.model.as_bytes());
hasher.update(b"\0");
hasher.update(instructions.as_bytes());
hasher.update(b"\0");
hasher.update(first_user_text.as_bytes());
let hex = hex::encode(hasher.finalize());
Some(format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
))
}
fn first_user_text(input: &[Value]) -> String {
for item in input {
if item.get("role").and_then(Value::as_str) != Some("user") {
continue;
}
let Some(content) = item.get("content") else {
return String::new();
};
if let Some(text) = content.as_str() {
return text.to_string();
}
if let Some(parts) = content.as_array() {
return parts
.iter()
.filter(|part| part.get("type").and_then(Value::as_str) == Some("input_text"))
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>();
}
return String::new();
}
String::new()
}
fn normalize_conversation_anchor_text(text: &str) -> String {
let mut rest = text.trim_start();
loop {
let lower = rest.to_ascii_lowercase();
if !lower.starts_with(LEADING_SYSTEM_REMINDER_OPEN) {
break;
}
let Some(close_start) = lower.find(LEADING_SYSTEM_REMINDER_CLOSE) else {
break;
};
let close_end = close_start + LEADING_SYSTEM_REMINDER_CLOSE.len();
rest = rest[close_end..].trim_start();
}
rest.to_string()
}
/// 构建账号作用域的身份哈希。
///
/// 格式:`{prefix}_{sha256(kind + "\0" + account_scope + "\0" + client_value)[..32]}`
/// 其中 prefix 为 `cp`(conversation)或 `cw`(window)。
fn build_account_scoped_identity(kind: &str, account_scope: &str, client_value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(kind.as_bytes());
hasher.update(b"\0");
hasher.update(account_scope.as_bytes());
hasher.update(b"\0");
hasher.update(client_value.as_bytes());
let digest = hasher.finalize();
let hex = hex::encode(digest);
let truncated = &hex[..32];
let prefix = match kind {
"conversation" => "cp",
"window" => "cw",
_ => "cx",
};
format!("{}_{}", prefix, truncated)
}
-109
View File
@@ -1,109 +0,0 @@
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
/// 上游请求指纹。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Fingerprint {
/// 客户端来源名。
pub originator: String,
/// 应用版本。
pub app_version: String,
/// 构建号。
pub build_number: String,
/// 平台名。
pub platform: String,
/// 架构名。
pub arch: String,
/// Chromium 主版本。
pub chromium_version: String,
/// User-Agent 模板。
pub user_agent_template: String,
/// 默认请求头。
pub default_headers: IndexMap<String, String>,
/// 请求头顺序。
pub header_order: Vec<String>,
/// DB 最后更新时间。
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Fingerprint {
/// 返回默认的 Codex Desktop 指纹。
pub fn default_codex_desktop() -> Self {
Self {
originator: "Codex Desktop".to_string(),
app_version: "26.519.81530".to_string(),
build_number: "3178".to_string(),
platform: "darwin".to_string(),
arch: "arm64".to_string(),
chromium_version: "146".to_string(),
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})".to_string(),
default_headers: Self::default_headers(),
header_order: Self::default_header_order(),
updated_at: None,
}
}
/// 返回测试用默认指纹。
pub fn default_for_tests() -> Self {
Self::default_codex_desktop()
}
/// 返回默认请求头集合。
pub fn default_headers() -> IndexMap<String, String> {
let mut headers = IndexMap::new();
headers.insert(
"Accept-Encoding".to_string(),
"gzip, deflate, br, zstd".to_string(),
);
headers.insert("Accept-Language".to_string(), "en-US,en;q=0.9".to_string());
headers.insert("sec-ch-ua-mobile".to_string(), "?0".to_string());
headers.insert("sec-ch-ua-platform".to_string(), "\"macOS\"".to_string());
headers.insert("sec-fetch-site".to_string(), "same-origin".to_string());
headers.insert("sec-fetch-mode".to_string(), "cors".to_string());
headers.insert("sec-fetch-dest".to_string(), "empty".to_string());
headers
}
/// 返回默认请求头顺序。
pub fn default_header_order() -> Vec<String> {
vec![
"authorization".to_string(),
"chatgpt-account-id".to_string(),
"originator".to_string(),
"x-openai-internal-codex-residency".to_string(),
"x-client-request-id".to_string(),
"x-codex-installation-id".to_string(),
"x-codex-turn-state".to_string(),
"openai-beta".to_string(),
"user-agent".to_string(),
"sec-ch-ua".to_string(),
"sec-ch-ua-mobile".to_string(),
"sec-ch-ua-platform".to_string(),
"accept-encoding".to_string(),
"accept-language".to_string(),
"sec-fetch-site".to_string(),
"sec-fetch-mode".to_string(),
"sec-fetch-dest".to_string(),
"content-type".to_string(),
"accept".to_string(),
"cookie".to_string(),
]
}
/// 根据模板展开最终 User-Agent。
pub fn user_agent(&self) -> String {
self.user_agent_template
.replace("{version}", &self.app_version)
.replace("{platform}", &self.platform)
.replace("{arch}", &self.arch)
}
/// 生成 `sec-ch-ua` 头值。
pub fn sec_ch_ua(&self) -> String {
format!(
"\"Chromium\";v=\"{}\", \"Not:A-Brand\";v=\"24\"",
self.chromium_version
)
}
}
-20
View File
@@ -1,20 +0,0 @@
//! Installation ID 的纯规则。
//!
//! 该模块只负责 UUID 格式校验和生成。`~/.codex/installation_id` 与数据目录
//! 持久化属于平台文件系统职责,放在 `platform::storage` 中。
use uuid::Uuid;
/// 生成新的 installation ID。
pub fn generate_installation_id() -> String {
Uuid::new_v4().to_string()
}
/// 解析并规范化 installation ID。
///
/// 输入会先去除首尾空白;非 UUID 字符串返回 `None`。
pub fn parse_installation_id(raw: &str) -> Option<String> {
let trimmed = raw.trim();
Uuid::parse_str(trimmed).ok()?;
Some(trimmed.to_string())
}
-10
View File
@@ -1,10 +0,0 @@
//! 上游网关领域中的纯逻辑辅助。
/// 会话与缓存键派生。
pub mod conversation;
/// 上游指纹。
pub mod fingerprint;
/// 安装 ID 生成。
pub mod installation;
/// 上游网关端口。
pub mod ports;
-13
View File
@@ -1,13 +0,0 @@
#![deny(missing_docs)]
//! 核心领域层,承载纯业务模型、协议语义和策略。
pub mod accounts;
pub mod admin;
pub mod auth;
pub mod events;
pub mod gateway;
pub mod models;
pub mod protocol;
pub mod serving;
pub mod usage;
-340
View File
@@ -1,340 +0,0 @@
//! 模型目录聚合与纯解析规则。
use std::collections::{BTreeMap, BTreeSet};
use crate::models::model::{
BackendModelEntry, BackendReasoningEffort, CodexModelInfo, ModelConfig, ModelDebugEntry,
ModelPlanSnapshot, ModelStoreDebug, ParsedModelName, ReasoningEffortInfo,
};
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_plan_index: BTreeMap<String, Vec<String>>,
}
impl ModelPlanSnapshot {
/// 将上游模型条目标准化为可持久化快照。
pub fn from_backend_entries(
plan_type: impl Into<String>,
entries: Vec<BackendModelEntry>,
) -> Self {
Self {
plan_type: plan_type.into(),
models: entries.into_iter().map(normalize_backend_model).collect(),
}
}
}
impl ModelCatalog {
/// 从静态配置构造模型目录。
pub fn from_config(config: &ModelConfig) -> Self {
let default_model = config.default_model.trim().to_string();
let aliases = normalize_aliases(&config.aliases);
Self {
models: vec![default_model_info(config)],
aliases,
default_model,
model_plan_index: BTreeMap::new(),
}
}
/// 从静态配置和后端快照构造模型目录。
pub fn from_config_and_snapshots(
config: &ModelConfig,
snapshots: &[ModelPlanSnapshot],
) -> Self {
if snapshots.is_empty() {
return Self::from_config(config);
}
let aliases = normalize_aliases(&config.aliases);
let default_model = config.default_model.trim().to_string();
let mut models_by_id = BTreeMap::new();
let mut model_plan_index: BTreeMap<String, Vec<String>> = BTreeMap::new();
for snapshot in snapshots {
for model in &snapshot.models {
// 后端模型按账号计划返回;目录对外去重,但仍保留 model -> plans 映射。
models_by_id.insert(model.id.clone(), model.clone());
let plans = model_plan_index.entry(model.id.clone()).or_default();
if !plans.contains(&snapshot.plan_type) {
plans.push(snapshot.plan_type.clone());
}
}
}
let models = models_by_id.into_values().collect::<Vec<_>>();
if models.is_empty() {
return Self::from_config(config);
}
Self {
models,
aliases,
default_model,
model_plan_index,
}
}
/// 返回对外可见的模型列表副本。
pub fn models(&self) -> Vec<CodexModelInfo> {
self.models.clone()
}
/// 按模型 ID 查询模型信息。
pub fn model_info(&self, model_id: &str) -> Option<CodexModelInfo> {
self.models
.iter()
.find(|model| model.id == model_id)
.cloned()
}
/// 返回模型目录调试视图。
pub fn debug(&self) -> ModelStoreDebug {
ModelStoreDebug {
total_models: self.models.len(),
static_models: self.models.len(),
alias_count: self.aliases.len(),
models: self
.models
.iter()
.map(|model| ModelDebugEntry {
id: model.id.clone(),
source: model.source.clone(),
})
.collect(),
}
}
/// 返回 model -> plans allowlist。
pub fn model_plan_allowlist(&self) -> BTreeMap<String, Vec<String>> {
self.model_plan_index.clone()
}
/// 判断输入模型名是否在目录中可识别。
pub fn is_recognized_model_name(&self, input: &str) -> bool {
let trimmed = input.trim();
if trimmed.is_empty() {
return false;
}
if self.aliases.contains_key(trimmed) || self.model_info(trimmed).is_some() {
return true;
}
let stripped = strip_known_model_suffixes(trimmed);
if stripped.model_name == trimmed
|| (stripped.reasoning_effort.is_none() && stripped.service_tier.is_none())
{
return false;
}
self.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() {
return ParsedModelName {
model_id: self.resolve_model_id(trimmed),
reasoning_effort: None,
service_tier: None,
};
}
let stripped = strip_known_model_suffixes(trimmed);
ParsedModelName {
model_id: self.resolve_model_id(&stripped.model_name),
reasoning_effort: stripped.reasoning_effort,
service_tier: stripped.service_tier,
}
}
/// 将标准化模型名重新拼成展示名。
pub fn build_display_model_name(parsed: &ParsedModelName) -> String {
let mut name = parsed.model_id.clone();
if let Some(reasoning_effort) = &parsed.reasoning_effort {
name.push('-');
name.push_str(reasoning_effort);
}
if let Some(service_tier) = &parsed.service_tier {
name.push('-');
name.push_str(service_tier);
}
name
}
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()
}
fn resolve_alias_chain(&self, input: &str) -> String {
let original = input.trim();
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 {
return current;
};
if !seen.insert(current.clone()) || seen.contains(target) {
return original.to_string();
}
current = target.to_string();
}
original.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StrippedModelName {
model_name: String,
reasoning_effort: Option<String>,
service_tier: Option<String>,
}
fn strip_known_model_suffixes(input: &str) -> StrippedModelName {
let mut remaining = input.trim().to_string();
let service_tier = take_suffix(&mut remaining, &SERVICE_TIER_SUFFIXES);
let reasoning_effort = take_suffix(&mut remaining, &REASONING_EFFORT_SUFFIXES);
StrippedModelName {
model_name: remaining,
reasoning_effort,
service_tier,
}
}
fn take_suffix(remaining: &mut String, suffixes: &[&str]) -> Option<String> {
let suffix = suffixes
.iter()
.find(|suffix| remaining.ends_with(&format!("-{suffix}")))?;
let truncate_to = remaining.len() - suffix.len() - 1;
remaining.truncate(truncate_to);
Some((*suffix).to_string())
}
fn normalize_aliases(input: &BTreeMap<String, String>) -> BTreeMap<String, String> {
input
.iter()
.filter_map(|(alias, target)| {
let alias = alias.trim();
let target = target.trim();
(!alias.is_empty() && !target.is_empty())
.then(|| (alias.to_string(), target.to_string()))
})
.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")
.to_string();
let display_name = first_non_empty([
raw.display_name.as_deref(),
raw.title.as_deref(),
raw.name.as_deref(),
])
.unwrap_or(&id)
.to_string();
let reasoning_entries = if raw.supported_reasoning_efforts.is_empty() {
raw.supported_reasoning_levels
} else {
raw.supported_reasoning_efforts
};
let mut supported_reasoning_efforts = reasoning_entries
.into_iter()
.filter_map(normalize_backend_reasoning_effort)
.collect::<Vec<_>>();
if supported_reasoning_efforts.is_empty() {
supported_reasoning_efforts.push(ReasoningEffortInfo {
reasoning_effort: "medium".to_string(),
description: "Default".to_string(),
});
}
let default_reasoning_effort = first_non_empty([
raw.default_reasoning_effort.as_deref(),
raw.default_reasoning_level.as_deref(),
])
.unwrap_or("medium")
.to_string();
CodexModelInfo {
id,
display_name,
description: raw.description.unwrap_or_default(),
is_default: raw.is_default.unwrap_or(false),
supported_reasoning_efforts,
default_reasoning_effort,
input_modalities: raw
.input_modalities
.unwrap_or_else(|| vec!["text".to_string()]),
output_modalities: raw
.output_modalities
.unwrap_or_else(|| vec!["text".to_string()]),
supports_personality: raw.supports_personality.unwrap_or(false),
upgrade: raw.upgrade,
source: "backend".to_string(),
context_window: raw.context_window,
max_context_window: raw.max_context_window,
max_output_tokens: raw.max_output_tokens,
truncation_policy_limit: raw.truncation_policy.and_then(|policy| policy.limit),
}
}
fn normalize_backend_reasoning_effort(raw: BackendReasoningEffort) -> Option<ReasoningEffortInfo> {
let reasoning_effort =
first_non_empty([raw.reasoning_effort.as_deref(), raw.effort.as_deref()])?.to_string();
Some(ReasoningEffortInfo {
description: raw.description.unwrap_or_default(),
reasoning_effort,
})
}
fn first_non_empty<'a>(values: impl IntoIterator<Item = Option<&'a str>>) -> Option<&'a str> {
values
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
}
-10
View File
@@ -1,10 +0,0 @@
//! 模型目录领域。
/// 模型目录聚合与纯解析规则。
pub mod catalog;
/// 模型目录配置与纯数据类型。
pub mod model;
/// 模型快照存储端口。
pub mod ports;
/// 模型目录服务用例。
pub mod service;
-183
View File
@@ -1,183 +0,0 @@
//! 模型目录领域中的纯数据类型。
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
/// 模型目录默认值与别名配置。
#[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>,
}
/// 单个 reasoning effort 的标准化展示信息。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ReasoningEffortInfo {
/// 推理强度名。
pub reasoning_effort: String,
/// 展示描述。
pub description: String,
}
/// 对外暴露的单个模型目录条目。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CodexModelInfo {
/// 模型唯一 ID。
pub id: String,
/// 展示名。
pub display_name: String,
/// 描述文本。
pub description: String,
/// 是否默认模型。
pub is_default: bool,
/// 支持的推理强度列表。
pub supported_reasoning_efforts: Vec<ReasoningEffortInfo>,
/// 默认推理强度。
pub default_reasoning_effort: String,
/// 输入模态。
pub input_modalities: Vec<String>,
/// 输出模态。
pub output_modalities: Vec<String>,
/// 是否支持 personality。
pub supports_personality: bool,
/// 升级提示。
pub upgrade: Option<String>,
/// 来源标记。
pub source: String,
/// 当前上下文窗口。
#[serde(skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
/// 最大上下文窗口。
#[serde(skip_serializing_if = "Option::is_none")]
pub max_context_window: Option<u64>,
/// 最大输出 token 数。
#[serde(skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<u64>,
/// 截断策略限制。
#[serde(skip_serializing_if = "Option::is_none")]
pub truncation_policy_limit: Option<u64>,
}
/// 解析模型名后得到的标准化结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedModelName {
/// 最终模型 ID。
pub model_id: String,
/// 推理强度后缀。
pub reasoning_effort: Option<String>,
/// 服务层级后缀。
pub service_tier: Option<String>,
}
/// 模型目录调试视图。
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelStoreDebug {
/// 模型总数。
pub total_models: usize,
/// 静态模型数。
pub static_models: usize,
/// 别名数量。
pub alias_count: usize,
/// 调试模型条目。
pub models: Vec<ModelDebugEntry>,
}
/// 单个调试模型条目。
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelDebugEntry {
/// 模型 ID。
pub id: String,
/// 来源标记。
pub source: String,
}
/// 按计划类型持久化的模型快照。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelPlanSnapshot {
/// 订阅计划类型。
pub plan_type: String,
/// 该计划可见的模型列表。
pub models: Vec<CodexModelInfo>,
}
/// 上游声明的 reasoning effort 条目。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendReasoningEffort {
/// reasoning_effort 字段。
#[serde(default, rename = "reasoning_effort", alias = "reasoningEffort")]
pub reasoning_effort: Option<String>,
/// effort 字段的兼容别名。
#[serde(default)]
pub effort: Option<String>,
/// 展示描述。
#[serde(default)]
pub description: Option<String>,
}
/// 上游截断策略定义。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendTruncationPolicy {
/// 上下文截断限制。
pub limit: Option<u64>,
}
/// 上游 `/codex/models` 返回的原始模型条目。
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendModelEntry {
/// slug。
pub slug: Option<String>,
/// id。
pub id: Option<String>,
/// name。
pub name: Option<String>,
/// display_name。
pub display_name: Option<String>,
/// title。
pub title: Option<String>,
/// description。
pub description: Option<String>,
/// 是否默认模型。
pub is_default: Option<bool>,
/// 默认 reasoning effort。
pub default_reasoning_effort: Option<String>,
/// 默认 reasoning level。
pub default_reasoning_level: Option<String>,
/// 支持的 reasoning effort 列表。
#[serde(default)]
pub supported_reasoning_efforts: Vec<BackendReasoningEffort>,
/// 支持的 reasoning level 列表。
#[serde(default)]
pub supported_reasoning_levels: Vec<BackendReasoningEffort>,
/// 输入模态。
pub input_modalities: Option<Vec<String>>,
/// 输出模态。
pub output_modalities: Option<Vec<String>>,
/// 是否支持 personality。
pub supports_personality: Option<bool>,
/// 升级提示。
pub upgrade: Option<String>,
/// contextWindow 别名。
#[serde(alias = "contextWindow")]
pub context_window: Option<u64>,
/// maxContextWindow 别名。
#[serde(alias = "maxContextWindow")]
pub max_context_window: Option<u64>,
/// maxOutputTokens 别名。
#[serde(alias = "maxOutputTokens")]
pub max_output_tokens: Option<u64>,
/// truncationPolicy 别名。
#[serde(alias = "truncationPolicy")]
pub truncation_policy: Option<BackendTruncationPolicy>,
}
-33
View File
@@ -1,33 +0,0 @@
//! 模型快照存储端口。
use async_trait::async_trait;
use thiserror::Error;
use crate::models::model::ModelPlanSnapshot;
/// 模型快照存储错误。
#[derive(Debug, Error)]
pub enum ModelSnapshotStoreError {
/// 底层存储失败。
#[error("model snapshot store operation failed: {message}")]
OperationFailed {
/// 错误说明。
message: String,
},
}
/// 模型快照存储结果类型。
pub type ModelSnapshotStoreResult<T> = Result<T, ModelSnapshotStoreError>;
/// 模型快照存储端口。
#[async_trait]
pub trait ModelSnapshotStore: Send + Sync + 'static {
/// 用单个计划快照替换同名快照。
async fn replace_plan_snapshot(
&self,
snapshot: &ModelPlanSnapshot,
) -> ModelSnapshotStoreResult<()>;
/// 列出所有计划快照。
async fn list_plan_snapshots(&self) -> ModelSnapshotStoreResult<Vec<ModelPlanSnapshot>>;
}
-233
View File
@@ -1,233 +0,0 @@
//! 模型目录服务用例。
use std::{collections::BTreeMap, sync::Arc};
use thiserror::Error;
use crate::{
accounts::model::{Account, AccountStatus},
gateway::ports::{CodexModelCatalogClient, CodexModelCatalogRequest},
models::{
catalog::ModelCatalog,
model::{ModelConfig, ModelPlanSnapshot},
ports::{ModelSnapshotStore, ModelSnapshotStoreError},
},
};
/// 模型刷新摘要。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelRefreshResult {
/// 成功刷新并写入的计划数。
pub refreshed_plans: usize,
/// 本次成功写入的模型数。
pub model_count: usize,
/// 刷新失败的计划数。
pub failed_plans: usize,
}
/// 模型服务错误。
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ModelServiceError {
/// 没有注入快照存储。
#[error("model snapshot store is unavailable")]
SnapshotStoreUnavailable,
/// 没有注入上游模型客户端。
#[error("model catalog client is unavailable")]
UpstreamClientUnavailable,
/// 没有可用账号。
#[error("no active accounts available for model refresh")]
NoAccounts,
/// 快照写入失败。
#[error("failed to store model snapshot")]
StoreSnapshot,
/// 刷新后重新读取快照失败。
#[error("failed to load model snapshots")]
LoadSnapshots,
/// 所有计划都刷新失败。
#[error("all model refresh plans failed")]
AllPlansFailed(ModelRefreshResult),
}
/// 模型到可用计划的映射。
pub type ModelPlanAllowlist = BTreeMap<String, Vec<String>>;
/// 可共享更新的模型计划映射缓存。
pub type SharedModelPlanAllowlist = Arc<tokio::sync::Mutex<ModelPlanAllowlist>>;
/// 模型目录服务。
#[derive(Clone)]
pub struct ModelService {
config: ModelConfig,
snapshot_store: Option<Arc<dyn ModelSnapshotStore>>,
upstream_client: Option<Arc<dyn CodexModelCatalogClient>>,
model_plan_allowlist: Option<SharedModelPlanAllowlist>,
}
impl ModelService {
/// 构造模型服务。
pub fn new(
config: ModelConfig,
snapshot_store: Option<Arc<dyn ModelSnapshotStore>>,
upstream_client: Option<Arc<dyn CodexModelCatalogClient>>,
model_plan_allowlist: Option<SharedModelPlanAllowlist>,
) -> Self {
Self {
config,
snapshot_store,
upstream_client,
model_plan_allowlist,
}
}
/// 返回模型目录的静态配置。
pub fn config(&self) -> &ModelConfig {
&self.config
}
/// 构造当前对外暴露的模型目录。
pub async fn catalog(&self) -> ModelCatalog {
let Some(snapshot_store) = self.snapshot_store.as_ref() else {
return ModelCatalog::from_config(&self.config);
};
match snapshot_store.list_plan_snapshots().await {
Ok(snapshots) if !snapshots.is_empty() => {
ModelCatalog::from_config_and_snapshots(&self.config, &snapshots)
}
Ok(_) => ModelCatalog::from_config(&self.config),
Err(error) => {
tracing::warn!(error = %error, "加载模型快照失败,回退到静态目录");
ModelCatalog::from_config(&self.config)
}
}
}
/// 刷新活跃账号对应的后端模型目录。
pub async fn refresh_backend_models(
&self,
accounts: &[Account],
request_id: &str,
) -> Result<ModelRefreshResult, ModelServiceError> {
self.refresh_backend_models_with_installation_id(accounts, request_id, None)
.await
}
/// 使用运行时 installation id 刷新活跃账号对应的后端模型目录。
pub async fn refresh_backend_models_with_installation_id(
&self,
accounts: &[Account],
request_id: &str,
installation_id: Option<&str>,
) -> Result<ModelRefreshResult, ModelServiceError> {
let snapshot_store = self
.snapshot_store
.as_ref()
.ok_or(ModelServiceError::SnapshotStoreUnavailable)?;
let upstream_client = self
.upstream_client
.as_ref()
.ok_or(ModelServiceError::UpstreamClientUnavailable)?;
let plan_accounts = distinct_active_plan_accounts(accounts);
if plan_accounts.is_empty() {
return Err(ModelServiceError::NoAccounts);
}
let mut result = ModelRefreshResult {
refreshed_plans: 0,
model_count: 0,
failed_plans: 0,
};
for (plan_type, account) in plan_accounts {
let request = CodexModelCatalogRequest {
access_token: &account.access_token,
account_id: account.account_id.as_deref(),
request_id,
installation_id,
plan_type: &plan_type,
};
let entries = match upstream_client.fetch_models(&request).await {
Ok(entries) if !entries.is_empty() => entries,
Ok(_) => {
result.failed_plans += 1;
continue;
}
Err(error) => {
tracing::warn!(error = %error, plan_type, "刷新后端模型失败");
result.failed_plans += 1;
continue;
}
};
let snapshot = ModelPlanSnapshot::from_backend_entries(plan_type, entries);
result.model_count += snapshot.models.len();
snapshot_store
.replace_plan_snapshot(&snapshot)
.await
.map_err(map_store_snapshot_error)?;
result.refreshed_plans += 1;
}
if result.refreshed_plans == 0 {
return Err(ModelServiceError::AllPlansFailed(result));
}
let allowlist = self.model_plan_allowlist_from_store().await?;
if let Some(shared_allowlist) = &self.model_plan_allowlist {
*shared_allowlist.lock().await = allowlist;
}
Ok(result)
}
/// 读取当前缓存的 model -> plans allowlist。
pub async fn model_plan_allowlist(&self) -> Result<ModelPlanAllowlist, ModelServiceError> {
self.model_plan_allowlist_from_store().await
}
async fn model_plan_allowlist_from_store(
&self,
) -> Result<ModelPlanAllowlist, ModelServiceError> {
let snapshot_store = self
.snapshot_store
.as_ref()
.ok_or(ModelServiceError::SnapshotStoreUnavailable)?;
let snapshots = snapshot_store
.list_plan_snapshots()
.await
.map_err(map_load_snapshots_error)?;
Ok(
ModelCatalog::from_config_and_snapshots(&self.config, &snapshots)
.model_plan_allowlist(),
)
}
}
fn distinct_active_plan_accounts(accounts: &[Account]) -> Vec<(String, Account)> {
let mut by_plan = BTreeMap::new();
for account in accounts {
if account.status != AccountStatus::Active {
continue;
}
let plan_type = account
.plan_type
.as_deref()
.unwrap_or("unknown")
.to_string();
by_plan.entry(plan_type).or_insert_with(|| account.clone());
}
by_plan.into_iter().collect()
}
fn map_store_snapshot_error(_: ModelSnapshotStoreError) -> ModelServiceError {
ModelServiceError::StoreSnapshot
}
fn map_load_snapshots_error(_: ModelSnapshotStoreError) -> ModelServiceError {
ModelServiceError::LoadSnapshots
}
-4
View File
@@ -1,4 +0,0 @@
//! 协议语义与纯数据类型。
pub mod codex;
pub mod openai;
-5
View File
@@ -1,5 +0,0 @@
//! OpenAI 协议输入类型与纯转换逻辑。
pub mod chat;
pub mod models;
pub mod responses;
-25
View File
@@ -1,25 +0,0 @@
//! OpenAI 模型列表响应类型。
use serde::{Deserialize, Serialize};
/// OpenAI 模型对象。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpenAiModel {
/// 模型 ID。
pub id: String,
/// 对象类型。
pub object: String,
/// 创建时间戳。
pub created: i64,
/// 所有者。
pub owned_by: String,
}
/// OpenAI 模型列表。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OpenAiModelList {
/// 对象类型。
pub object: String,
/// 模型数据。
pub data: Vec<OpenAiModel>,
}
-238
View File
@@ -1,238 +0,0 @@
//! 会话亲和性策略。
use std::collections::BTreeMap;
use chrono::{DateTime, Duration, Utc};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::{
gateway::conversation::derive_stable_conversation_key,
protocol::codex::responses::CodexResponsesRequest,
};
/// 会话亲和性键。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionAffinityKey {
/// 客户端会话 ID。
pub conversation_id: String,
}
/// 会话亲和性条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionAffinityEntry {
/// 创建该响应的账号 ID。
pub account_id: String,
/// 对话 ID。
pub conversation_id: String,
/// 上游 turn state。
pub turn_state: Option<String>,
/// 指令哈希。
pub instructions_hash: Option<String>,
/// 输入 token 数。
pub input_tokens: Option<u64>,
/// 函数调用 ID 列表。
pub function_call_ids: Vec<String>,
/// 用于区分并发对话分支的变体哈希。
pub variant_hash: Option<String>,
/// 创建时间。
pub created_at: DateTime<Utc>,
}
/// 持久化的会话亲和性条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoredSessionAffinity {
/// 响应 ID。
pub response_id: String,
/// 亲和性条目。
pub entry: SessionAffinityEntry,
/// 持久化过期时间。
pub expires_at: DateTime<Utc>,
}
/// 纯内存会话亲和性映射。
#[derive(Debug, Clone)]
pub struct SessionAffinityMap {
entries: BTreeMap<String, SessionAffinityEntry>,
ttl: Duration,
}
impl SessionAffinityMap {
/// 构造指定 TTL 的会话亲和性映射。
pub fn new(ttl: Duration) -> Self {
Self {
entries: BTreeMap::new(),
ttl,
}
}
/// 记录响应 ID 到亲和性条目的映射。
pub fn record(&mut self, response_id: String, entry: SessionAffinityEntry) {
self.entries.insert(response_id, entry);
}
/// 从持久化记录恢复未过期条目。
pub fn restore(&mut self, records: Vec<StoredSessionAffinity>, now: DateTime<Utc>) -> usize {
let mut restored = 0usize;
for record in records {
if self.stored_record_is_active(&record, now) {
self.entries.insert(record.response_id, record.entry);
restored = restored.saturating_add(1);
}
}
restored
}
/// 根据响应 ID 查找账号 ID。
pub fn lookup_account(&self, response_id: &str, now: DateTime<Utc>) -> Option<String> {
self.active_entry(response_id, now)
.map(|entry| entry.account_id.clone())
}
/// 根据响应 ID 查找对话 ID。
pub fn lookup_conversation_id(&self, response_id: &str, now: DateTime<Utc>) -> Option<String> {
self.active_entry(response_id, now)
.map(|entry| entry.conversation_id.clone())
}
/// 根据响应 ID 查找 turn state。
pub fn lookup_turn_state(&self, response_id: &str, now: DateTime<Utc>) -> Option<String> {
self.active_entry(response_id, now)
.and_then(|entry| entry.turn_state.clone())
}
/// 根据响应 ID 查找指令哈希。
pub fn lookup_instructions_hash(
&self,
response_id: &str,
now: DateTime<Utc>,
) -> Option<String> {
self.active_entry(response_id, now)
.and_then(|entry| entry.instructions_hash.clone())
}
/// 根据响应 ID 查找函数调用 ID 列表。
pub fn lookup_function_call_ids(&self, response_id: &str, now: DateTime<Utc>) -> Vec<String> {
self.active_entry(response_id, now)
.map(|entry| entry.function_call_ids.clone())
.unwrap_or_default()
}
/// 查找指定对话和可选变体下最新的响应 ID。
pub fn lookup_latest_response_by_conversation(
&self,
conversation_id: &str,
max_age: Option<Duration>,
variant_hash: Option<&str>,
now: DateTime<Utc>,
) -> Option<String> {
self.entries
.iter()
.filter(|(_, entry)| entry.conversation_id == conversation_id)
.filter(|(_, entry)| self.entry_is_active(entry, now))
.filter(|(_, entry)| {
variant_hash
.is_none_or(|variant_hash| entry.variant_hash.as_deref() == Some(variant_hash))
})
.filter(|(_, entry)| {
max_age.is_none_or(|max_age| now.signed_duration_since(entry.created_at) <= max_age)
})
.max_by_key(|(_, entry)| entry.created_at)
.map(|(response_id, _)| response_id.clone())
}
/// 删除响应 ID 映射。
pub fn forget(&mut self, response_id: &str) -> bool {
self.entries.remove(response_id).is_some()
}
/// 返回当前内存条目数。
pub fn len(&self) -> usize {
self.entries.len()
}
/// 返回是否没有内存条目。
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn active_entry(&self, response_id: &str, now: DateTime<Utc>) -> Option<&SessionAffinityEntry> {
self.entries
.get(response_id)
.filter(|entry| self.entry_is_active(entry, now))
}
fn stored_record_is_active(&self, record: &StoredSessionAffinity, now: DateTime<Utc>) -> bool {
record.expires_at > now && self.entry_is_active(&record.entry, now)
}
fn entry_is_active(&self, entry: &SessionAffinityEntry, now: DateTime<Utc>) -> bool {
entry
.created_at
.checked_add_signed(self.ttl)
.is_some_and(|expires_at| expires_at > now)
}
}
/// 准备用于区分并发分支的变体身份。
pub fn prepare_variant_identity(request: &mut CodexResponsesRequest) {
request.variant_identity = build_variant_identity(request);
}
/// 计算请求变体哈希。
pub fn compute_variant_hash(request: &CodexResponsesRequest) -> String {
compute_variant_hash_with_identity(request, request.variant_identity.as_deref())
}
fn compute_variant_hash_with_identity(
request: &CodexResponsesRequest,
identity: Option<&str>,
) -> String {
let tools_json = request
.tools
.as_ref()
.map_or_else(|| "[]".to_string(), |tools| tools_json(tools));
let mut hasher = Sha256::new();
hasher.update(request.instructions.as_bytes());
hasher.update(b"\0");
hasher.update(tools_json.as_bytes());
if let Some(identity) = identity
.map(str::trim)
.filter(|identity| !identity.is_empty())
{
hasher.update(b"\0");
hasher.update(identity.as_bytes());
}
hex::encode(hasher.finalize()).chars().take(12).collect()
}
fn tools_json(tools: &[Value]) -> String {
serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string())
}
fn build_variant_identity(request: &CodexResponsesRequest) -> Option<String> {
let mut parts = Vec::with_capacity(2);
if let Some(window_id) = non_empty_str(request.codex_window_id.as_deref()) {
parts.push(format!("window:{window_id}"));
}
if request.explicit_prompt_cache_key
|| non_empty_str(request.client_conversation_id.as_deref()).is_some()
{
if let Some(anchor) = derive_stable_conversation_key(request) {
parts.push(format!("anchor:{anchor}"));
}
}
(!parts.is_empty()).then(|| parts.join("\0"))
}
fn non_empty_str(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
/// 计算 instructions 哈希。
pub fn hash_instructions(instructions: Option<&str>) -> String {
let mut hasher = Sha256::new();
hasher.update(instructions.unwrap_or_default().as_bytes());
hex::encode(hasher.finalize())
}
-16
View File
@@ -1,16 +0,0 @@
//! 对外服务编排策略。
/// 亲和性策略。
pub mod affinity;
/// 回退策略。
pub mod fallback;
/// Responses 隐式续接策略。
pub mod implicit_resume;
/// 配额策略。
pub mod quota;
/// Responses reasoning replay 缓存策略。
pub mod reasoning_replay;
/// 错误恢复策略。
pub mod recovery;
/// Responses 编排。
pub mod responses;
-129
View File
@@ -1,129 +0,0 @@
//! Responses 创建编排。
use serde_json::{Map, Value};
use crate::{
models::model::{ModelConfig, ParsedModelName},
protocol::codex::responses::CodexResponsesRequest,
};
/// Codex Responses 请求的上游传输决策。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexTransport {
/// 使用 HTTP SSE。
HttpSse,
/// 优先使用 WebSocket,失败时允许回退 HTTP SSE。
WebSocketPreferred,
/// 必须使用 WebSocket,不允许 HTTP SSE 回退。
WebSocketRequired,
}
/// 判断 Responses 请求应使用哪种上游传输。
pub fn transport_for_request(request: &CodexResponsesRequest) -> CodexTransport {
if request.force_http_sse {
return CodexTransport::HttpSse;
}
if request.previous_response_id.is_some() {
return CodexTransport::WebSocketRequired;
}
CodexTransport::WebSocketPreferred
}
/// 判断请求在 WebSocket 失败后是否允许 HTTP SSE 回退。
pub fn http_sse_fallback_allowed(request: &CodexResponsesRequest) -> bool {
!matches!(
transport_for_request(request),
CodexTransport::WebSocketRequired
)
}
/// 应用模型名后缀和模型默认值对应的 Responses 上游请求选项。
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);
}
fn apply_reasoning_options(
request: &mut CodexResponsesRequest,
parsed_model: &ParsedModelName,
config: &ModelConfig,
) {
let existing_reasoning = request.reasoning.take();
let existing_object = match existing_reasoning {
Some(Value::Object(object)) => object,
Some(_) | None => Map::new(),
};
let effort = existing_object
.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()));
if effort.is_none() && existing_object.is_empty() {
request.reasoning = None;
return;
}
let summary = existing_object
.get("summary")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("auto");
let mut reasoning = Map::new();
reasoning.insert("summary".to_string(), Value::String(summary.to_string()));
if let Some(effort) = effort {
reasoning.insert("effort".to_string(), Value::String(effort));
}
request.reasoning = Some(Value::Object(reasoning));
ensure_reasoning_include(request);
}
fn apply_service_tier_options(
request: &mut CodexResponsesRequest,
parsed_model: &ParsedModelName,
config: &ModelConfig,
) {
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);
}
fn ensure_reasoning_include(request: &mut CodexResponsesRequest) {
if request.reasoning.is_none() {
return;
}
if request
.include
.as_ref()
.is_some_and(|include| !include.is_empty())
{
return;
}
request.include = Some(vec!["reasoning.encrypted_content".to_string()]);
}
fn normalize_service_tier_for_upstream(service_tier: String) -> String {
if service_tier == "fast" {
"priority".to_string()
} else {
service_tier
}
}
fn non_empty_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
}
-8
View File
@@ -1,8 +0,0 @@
//! 用量聚合领域。
/// 用量模型。
pub mod model;
/// 用量存储端口。
pub mod ports;
/// 用量聚合服务。
pub mod service;
-28
View File
@@ -1,28 +0,0 @@
//! 用量聚合模型。
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// 用量聚合窗口。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UsageWindow {
/// 窗口开始时间。
pub started_at: DateTime<Utc>,
/// 请求数。
pub request_count: u64,
/// 输入 token 数。
pub input_tokens: u64,
/// 输出 token 数。
pub output_tokens: u64,
}
/// 用量快照。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct UsageSnapshot {
/// 账号 ID。
pub account_id: String,
/// 当前窗口。
pub window: UsageWindow,
}
-27
View File
@@ -1,27 +0,0 @@
//! 用量存储端口。
use async_trait::async_trait;
use thiserror::Error;
use crate::usage::model::UsageSnapshot;
/// 用量存储错误。
#[derive(Debug, Error)]
pub enum UsageStoreError {
/// 底层存储失败。
#[error("usage store operation failed: {message}")]
OperationFailed {
/// 错误说明。
message: String,
},
}
/// 用量存储结果。
pub type UsageStoreResult<T> = Result<T, UsageStoreError>;
/// 用量存储端口。
#[async_trait]
pub trait UsageStore: Send + Sync + 'static {
/// 写入用量快照。
async fn record_snapshot(&self, snapshot: &UsageSnapshot) -> UsageStoreResult<()>;
}
-36
View File
@@ -1,36 +0,0 @@
//! 用量聚合策略。
use crate::{
accounts::usage::AccountUsageDelta, protocol::codex::events::TokenUsage,
usage::model::UsageWindow,
};
/// 用量聚合服务。
#[derive(Debug, Clone, Default)]
pub struct UsageService;
impl UsageService {
/// 将 token 增量累加到窗口。
pub fn add_tokens(window: &mut UsageWindow, input_tokens: u64, output_tokens: u64) {
window.request_count += 1;
window.input_tokens += input_tokens;
window.output_tokens += output_tokens;
}
/// 将标准化 token 用量转换为账号持久化用量增量。
pub fn account_delta_from_token_usage(usage: TokenUsage) -> AccountUsageDelta {
AccountUsageDelta {
requests: 0,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
total_tokens: usage.total_tokens,
empty_responses: 0,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_requests: 0,
image_request_failures: 0,
}
}
}
-106
View File
@@ -1,106 +0,0 @@
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_core::serving::affinity::{
SessionAffinityEntry, SessionAffinityMap, StoredSessionAffinity,
};
#[test]
fn session_affinity_map_should_restore_active_records_and_skip_expired_records() {
let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
let mut map = SessionAffinityMap::new(Duration::hours(4));
let restored = map.restore(
vec![
StoredSessionAffinity {
response_id: "resp_active".to_string(),
entry: SessionAffinityEntry {
account_id: "acct_active".to_string(),
conversation_id: "conv_active".to_string(),
turn_state: Some("turn_active".to_string()),
instructions_hash: Some("hash_active".to_string()),
input_tokens: Some(42),
function_call_ids: vec!["call_1".to_string()],
variant_hash: Some("variant_a".to_string()),
created_at: now - Duration::minutes(30),
},
expires_at: now + Duration::hours(1),
},
StoredSessionAffinity {
response_id: "resp_expired".to_string(),
entry: SessionAffinityEntry {
account_id: "acct_expired".to_string(),
conversation_id: "conv_expired".to_string(),
turn_state: None,
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: None,
created_at: now - Duration::hours(5),
},
expires_at: now + Duration::hours(1),
},
],
now,
);
assert_eq!(restored, 1);
assert_eq!(
map.lookup_account("resp_active", now),
Some("acct_active".to_string())
);
assert_eq!(map.lookup_account("resp_expired", now), None);
}
#[test]
fn session_affinity_map_should_find_latest_response_by_conversation_and_variant() {
let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
let mut map = SessionAffinityMap::new(Duration::hours(4));
map.record(
"resp_old".to_string(),
SessionAffinityEntry {
account_id: "acct_a".to_string(),
conversation_id: "conv".to_string(),
turn_state: Some("turn_old".to_string()),
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: Some("variant_a".to_string()),
created_at: now - Duration::minutes(20),
},
);
map.record(
"resp_new".to_string(),
SessionAffinityEntry {
account_id: "acct_b".to_string(),
conversation_id: "conv".to_string(),
turn_state: Some("turn_new".to_string()),
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: Some("variant_a".to_string()),
created_at: now - Duration::minutes(5),
},
);
map.record(
"resp_other_variant".to_string(),
SessionAffinityEntry {
account_id: "acct_c".to_string(),
conversation_id: "conv".to_string(),
turn_state: Some("turn_other".to_string()),
instructions_hash: None,
input_tokens: None,
function_call_ids: Vec::new(),
variant_hash: Some("variant_b".to_string()),
created_at: now - Duration::minutes(1),
},
);
assert_eq!(
map.lookup_latest_response_by_conversation("conv", None, Some("variant_a"), now),
Some("resp_new".to_string())
);
assert_eq!(
map.lookup_turn_state("resp_new", now),
Some("turn_new".to_string())
);
}
-40
View File
@@ -1,40 +0,0 @@
[package]
name = "codex-proxy-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
aes-gcm.workspace = true
argon2.workspace = true
base64.workspace = true
chrono.workspace = true
config.workspace = true
dirs.workspace = true
hex.workspace = true
hmac.workspace = true
rand.workspace = true
rustls.workspace = true
rustls-native-certs.workspace = true
rustls-pki-types.workspace = true
secrecy.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yml.workspace = true
sha2.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
zeroize.workspace = true
[lints]
workspace = true
[dev-dependencies]
tempfile = "3.27.0"
-69
View File
@@ -1,69 +0,0 @@
use std::path::Path;
use serde::Serialize;
use super::{
types::{AppConfig, AuthConfig, LoggingConfig, ModelConfig, QuotaConfig, UsageStatsConfig},
ConfigResult, ConfigWriteError, ConfigWriteResult,
};
impl AppConfig {
/// 从当前目录加载配置文件。
pub fn load() -> ConfigResult<Self> {
Self::load_from_dir(".")
}
/// 从指定目录加载配置文件。
pub fn load_from_dir(config_dir: impl AsRef<Path>) -> ConfigResult<Self> {
let config_dir = config_dir.as_ref();
::config::Config::builder()
.add_source(::config::File::from(config_dir.join("config.yaml")).required(true))
.add_source(::config::File::from(config_dir.join("local.yaml")).required(false))
.add_source(::config::File::from(config_dir.join("local.yml")).required(false))
.build()?
.try_deserialize()
}
/// 写入管理端设置使用的本地配置覆盖文件。
pub async fn write_settings_overlay(
&self,
local_config_path: impl AsRef<Path>,
) -> ConfigWriteResult<()> {
let local_config_path = local_config_path.as_ref();
if let Some(parent) = local_config_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
{
tokio::fs::create_dir_all(parent)
.await
.map_err(ConfigWriteError::CreateDirectory)?;
}
let overlay = SettingsConfigOverlay::from(self);
let content = serde_yml::to_string(&overlay).map_err(ConfigWriteError::Serialize)?;
tokio::fs::write(local_config_path, content)
.await
.map_err(ConfigWriteError::Write)
}
}
#[derive(Debug, Serialize)]
struct SettingsConfigOverlay {
model: ModelConfig,
auth: AuthConfig,
quota: QuotaConfig,
usage_stats: UsageStatsConfig,
logging: LoggingConfig,
}
impl From<&AppConfig> for SettingsConfigOverlay {
fn from(config: &AppConfig) -> Self {
Self {
model: config.model.clone(),
auth: config.auth.clone(),
quota: config.quota.clone(),
usage_stats: config.usage_stats.clone(),
logging: config.logging.clone(),
}
}
}
-25
View File
@@ -1,25 +0,0 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
/// HMAC-SHA256 类型别名。
pub type HmacSha256 = Hmac<Sha256>;
/// 计算 HMAC-SHA256 并以 URL-safe base64 输出。
pub fn hmac_sha256_base64(key: &[u8], payload: &[u8]) -> Option<String> {
let mut mac = HmacSha256::new_from_slice(key).ok()?;
mac.update(payload);
Some(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
}
/// 常数时间比较两个字节切片。
pub fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0u8, |acc, (left, right)| acc | (left ^ right))
== 0
}
-7
View File
@@ -1,7 +0,0 @@
//! 加密原语。
/// HMAC 和常数时间比较辅助。
pub mod hash;
mod secret_box;
pub use secret_box::{CryptoError, CryptoResult, SecretBox};
@@ -1,22 +0,0 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
use crate::identity::AuthResult;
/// 生成管理员密码哈希。
pub fn hash_admin_password(password: &str) -> AuthResult<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)?
.to_string())
}
/// 验证管理员密码。
pub fn verify_admin_password(password: &str, hash: &str) -> AuthResult<bool> {
let parsed = PasswordHash::new(hash)?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
-35
View File
@@ -1,35 +0,0 @@
//! 身份哈希与本地认证相关原语。
mod admin_password;
mod client_key;
pub use admin_password::{hash_admin_password, verify_admin_password};
pub use client_key::{ApiKeyHasher, GeneratedClientApiKey};
use thiserror::Error;
/// 身份原语错误。
#[derive(Debug, Error)]
pub enum AuthError {
/// 密码哈希错误。
#[error("password hash error: {0}")]
PasswordHash(String),
/// API Key 编码错误。
#[error("invalid api key encoding: {0}")]
ApiKeyEncoding(#[from] base64::DecodeError),
/// Pepper 长度错误。
#[error("invalid api key pepper length")]
InvalidPepperLength,
/// Pepper 文件读写错误。
#[error("api key pepper file io error: {0}")]
PepperIo(#[from] std::io::Error),
}
/// 身份原语结果。
pub type AuthResult<T> = Result<T, AuthError>;
impl From<argon2::password_hash::Error> for AuthError {
fn from(value: argon2::password_hash::Error) -> Self {
Self::PasswordHash(value.to_string())
}
}
-25
View File
@@ -1,25 +0,0 @@
//! 无业务含义的 JSON 与分页辅助。
mod pagination;
pub use pagination::{clamp_limit, decode_cursor, encode_cursor, Page};
use serde_json::Value;
/// 从多个路径中提取第一个非空字符串。
pub fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
paths.iter().find_map(|path| string_at(value, path))
}
/// 从指定路径提取字符串。
pub fn string_at(value: &Value, path: &[&str]) -> Option<String> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
-30
View File
@@ -1,30 +0,0 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::Serialize;
/// 通用分页结果。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Page<T> {
/// 当前页条目。
pub items: Vec<T>,
/// 下一页游标。
pub next_cursor: Option<String>,
}
/// 编码游标。
pub fn encode_cursor(created_at: &str, id: &str) -> String {
URL_SAFE_NO_PAD.encode(format!("{created_at}|{id}"))
}
/// 解码游标。
pub fn decode_cursor(cursor: &str) -> Option<(String, String)> {
let raw = URL_SAFE_NO_PAD.decode(cursor).ok()?;
let text = String::from_utf8(raw).ok()?;
let (created_at, id) = text.split_once('|')?;
Some((created_at.to_string(), id.to_string()))
}
/// 限制分页大小。
pub fn clamp_limit(limit: u32) -> u32 {
limit.clamp(1, 200)
}
-10
View File
@@ -1,10 +0,0 @@
#![deny(missing_docs)]
//! 平台基础设施层,承载配置、加密、身份、存储、日志和 JSON 原语。
pub mod config;
pub mod crypto;
pub mod identity;
pub mod json;
pub mod logging;
pub mod storage;
-5
View File
@@ -1,5 +0,0 @@
//! 日志初始化与文件轮转。
mod rotation;
pub use rotation::{build_file_appender, init_tracing, LogError, LogGuard, RotationConfig};
-11
View File
@@ -1,11 +0,0 @@
//! SQLite 存储初始化与路径辅助。
mod paths;
/// SQLite 连接初始化。
pub mod sqlite;
pub use paths::{
codex_desktop_installation_id_path, data_dir, data_installation_id_path, ensure_data_dir,
load_or_create_installation_id, load_or_create_installation_id_from_paths,
};
pub use sqlite::{connect_sqlite, SqlitePool};
-35
View File
@@ -1,35 +0,0 @@
[package]
name = "codex-proxy-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
base64.workspace = true
bytes.workspace = true
chrono.workspace = true
codex-proxy-adapters = { path = "../adapters" }
codex-proxy-core = { path = "../core" }
codex-proxy-platform = { path = "../platform" }
futures.workspace = true
reqwest.workspace = true
secrecy.workspace = true
serde_json.workspace = true
sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
async-trait.workspace = true
reqwest.workspace = true
tempfile = "3.27.0"
tokio.workspace = true
tokio-tungstenite.workspace = true
wiremock = "0.6.5"
[lints]
workspace = true
-42
View File
@@ -1,42 +0,0 @@
//! 启动编排。
use codex_proxy_adapters::codex::fingerprint::FingerprintRepository;
use codex_proxy_core::gateway::fingerprint::Fingerprint;
use codex_proxy_platform::{config::FingerprintConfig, storage::SqlitePool};
/// 从配置默认值构造指纹。
pub fn fingerprint_from_config(config: &FingerprintConfig) -> Fingerprint {
Fingerprint {
originator: config.originator.clone(),
app_version: config.app_version.clone(),
build_number: config.build_number.clone(),
platform: config.platform.clone(),
arch: config.arch.clone(),
chromium_version: config.chromium_version.clone(),
user_agent_template: config.user_agent_template.clone(),
default_headers: config
.default_headers
.iter()
.map(|header| (header.name.clone(), header.value.clone()))
.collect(),
header_order: config.header_order.clone(),
updated_at: None,
}
}
/// 从指纹仓储加载运行时当前指纹;首次启动时用配置默认值写入当前槽位。
pub async fn load_runtime_fingerprint(
repository: &FingerprintRepository,
default_fingerprint: &Fingerprint,
) -> Result<Fingerprint, sqlx::Error> {
repository.ensure_current_seed(default_fingerprint).await
}
/// 从 SQLite 连接池加载运行时当前指纹。
pub async fn load_runtime_fingerprint_from_pool(
pool: SqlitePool,
default_fingerprint: &Fingerprint,
) -> Result<Fingerprint, sqlx::Error> {
let repository = FingerprintRepository::new(pool);
load_runtime_fingerprint(&repository, default_fingerprint).await
}
-27
View File
@@ -1,27 +0,0 @@
//! 配置映射。
use codex_proxy_platform::config as platform;
/// 运行时配置视图。
#[derive(Debug, Clone)]
pub struct RuntimeConfig {
/// 模型配置。
pub model: platform::ModelConfig,
/// 管理端配置。
pub admin: platform::AdminConfig,
/// 认证与刷新配置。
pub auth: platform::AuthConfig,
/// 配额刷新配置。
pub quota: platform::QuotaConfig,
}
impl From<platform::AppConfig> for RuntimeConfig {
fn from(config: platform::AppConfig) -> Self {
Self {
model: config.model,
admin: config.admin,
auth: config.auth,
quota: config.quota,
}
}
}
-9
View File
@@ -1,9 +0,0 @@
//! 运行时组装层,提供依赖注入、应用状态和后台任务协调。
pub mod bootstrap;
pub mod config;
pub mod repositories;
pub mod services;
pub mod state;
pub mod tasks;
pub mod upstream;
-54
View File
@@ -1,54 +0,0 @@
//! 仓储组装。
use codex_proxy_adapters::{
codex::fingerprint::FingerprintRepository,
sqlite::{
accounts::SqliteAccountStore, admin_sessions::SqliteAdminSessionStore,
client_keys::SqliteClientKeyStore, cookies::SqliteCookieStore, events::SqliteEventLogStore,
models::ModelSnapshotRepository, refresh_leases::SqliteRefreshLeaseStore,
session_affinity::SqliteSessionAffinityStore,
},
};
use codex_proxy_platform::{crypto::SecretBox, identity::ApiKeyHasher, storage::SqlitePool};
/// 运行时仓储集合。
#[derive(Clone)]
pub struct Repositories {
/// 账号存储。
pub accounts: SqliteAccountStore,
/// 模型快照存储。
pub model_snapshots: ModelSnapshotRepository,
/// 客户端 API Key 存储。
pub client_keys: SqliteClientKeyStore,
/// 管理员会话存储。
pub admin_sessions: SqliteAdminSessionStore,
/// 事件日志存储。
pub event_logs: SqliteEventLogStore,
/// Cookie 存储。
pub cookies: SqliteCookieStore,
/// 指纹存储。
pub fingerprints: FingerprintRepository,
/// 会话亲和性存储。
pub session_affinity: SqliteSessionAffinityStore,
/// 账号刷新租约存储。
pub refresh_leases: SqliteRefreshLeaseStore,
}
/// 从 SQLite 连接池构造仓储集合。
pub fn sqlite_repositories(
pool: SqlitePool,
secret_box: SecretBox,
api_key_hasher: ApiKeyHasher,
) -> Repositories {
Repositories {
accounts: SqliteAccountStore::new(pool.clone(), secret_box.clone()),
model_snapshots: ModelSnapshotRepository::new(pool.clone()),
client_keys: SqliteClientKeyStore::new(pool.clone(), api_key_hasher),
admin_sessions: SqliteAdminSessionStore::new(pool.clone()),
event_logs: SqliteEventLogStore::new(pool.clone()),
cookies: SqliteCookieStore::new(pool.clone(), secret_box),
fingerprints: FingerprintRepository::new(pool.clone()),
session_affinity: SqliteSessionAffinityStore::new(pool.clone()),
refresh_leases: SqliteRefreshLeaseStore::new(pool),
}
}
-525
View File
@@ -1,525 +0,0 @@
//! 服务组装。
use std::{
collections::BTreeMap,
path::PathBuf,
pin::Pin,
sync::{Arc, RwLock as StdRwLock},
task::{Context, Poll},
time::{Duration as StdDuration, Instant},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bytes::Bytes;
use chrono::{DateTime, Duration, Utc};
use codex_proxy_adapters::{
codex::client::{
CodexBackendClient, CodexBackendResponse, CodexBackendSseStream,
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError,
CodexCompactResponse, CodexRateLimitHeaderUpdates, CodexRequestContext,
CodexTurnStateUpdate,
},
codex::fingerprint::FingerprintRepository,
oauth::openai::default_openai_oauth_client,
sqlite::{
accounts::{
AccountClaimsUpdate, AccountQuotaSnapshot, AccountUsageListRecord, AccountUsageSummary,
ImportedAccountUpdate, NewAccount, SqliteAccountStore, StoredAccount,
StoredAccountMetadata,
},
admin_sessions::SqliteAdminSessionStore,
client_keys::{CreatedClientApiKey, SqliteClientKeyStore, StoredClientApiKey},
cookies::SqliteCookieStore,
events::{EventLogFilter, SqliteEventLogStore},
refresh_leases::SqliteRefreshLeaseStore,
session_affinity::{SqliteSessionAffinityStore, SqliteSessionAffinityStoreError},
},
};
use codex_proxy_core::{
accounts::{
cloudflare::{CloudflareChallengeCooldownTracker, CloudflarePathBlockTracker},
jwt::{jwt_expiry, JwtExpiry},
model::{Account, AccountStatus},
pool::{
AccountAcquireRequest, AccountCapacitySummary, AccountPool, AccountPoolOptions,
AccountWindowUsageDelta, AcquiredAccount, RotationStrategy,
},
ports::{AccountStore, AccountStoreError},
},
admin::{
auth::AdminAuthService,
client_keys::ClientKeyService,
settings::{
AdminQuotaWarningThresholds, AdminSettings, AdminSettingsPatch, SettingsService,
SettingsServiceError,
},
},
auth::{
oauth::{
DeviceCode, OAuthConfig, OAuthError, PkceLogin, PkceSessionStore, RefreshFailure,
TokenPair,
},
ports::{OAuthClient, TokenRefresher},
},
events::{
model::{EventLevel, EventLog},
service::EventLogService,
},
gateway::{
conversation::{build_conversation_identity, ensure_prompt_cache_key},
fingerprint::Fingerprint,
ports::CodexModelCatalogClient,
},
models::{
model::ModelConfig,
ports::ModelSnapshotStore,
service::{ModelRefreshResult, ModelService, ModelServiceError},
},
protocol::{
codex::{
events::{
extract_sse_usage, extract_usage, parse_rate_limit_headers, rate_limit_quota,
TokenUsage,
},
responses::{CodexCompactRequest, CodexResponsesRequest},
sse::{
encode_sse_event, parse_sse_events, sse_body_has_done, SseError, DONE_SSE_FRAME,
},
},
openai::chat::chat_completion_from_codex_sse,
openai::responses::{
completed_response_metadata, reconvert_responses_sse_event_tuple_values,
response_failed_sse_event, response_from_codex_sse, CollectedResponse,
ResponsesSseFailure,
},
},
serving::{
affinity::{
compute_variant_hash, hash_instructions, prepare_variant_identity,
SessionAffinityEntry, SessionAffinityMap,
},
fallback::{
status_code_is_quota_exhausted, status_code_is_rate_limited,
status_code_is_transient_upstream,
},
implicit_resume::{
continuation_input_start, implicit_resume_allowed, ImplicitResumeSnapshot,
},
quota::{
quota_from_usage, quota_reached, quota_snapshot_limit_reached,
quota_snapshot_limit_window_seconds, quota_snapshot_reset_at,
},
reasoning_replay::ReasoningReplayCache,
recovery::status_code_allows_same_account_retry,
responses::apply_response_model_options,
},
usage::service::UsageService,
};
use codex_proxy_platform::{
config::{AppConfig, ConfigWriteError, QuotaWarningThresholds},
identity::{hash_admin_password, verify_admin_password},
json::Page,
};
use futures::{stream, Stream, StreamExt};
use secrecy::{ExposeSecret, SecretString};
use serde_json::{Map, Value};
use thiserror::Error;
use tokio::{
sync::{mpsc, oneshot, RwLock},
time::sleep,
};
use crate::{repositories::Repositories, upstream};
mod account_pool;
mod admin_accounts;
mod admin_client_keys;
mod admin_logs;
mod admin_models;
mod admin_oauth;
mod admin_sessions;
mod admin_usage;
mod dispatch_chat;
mod dispatch_responses;
mod session_affinity;
mod settings;
use account_pool::{account_pool_options, CloudflareRecovery};
pub use account_pool::{RuntimeAccountPoolError, RuntimeAccountPoolService};
pub use admin_accounts::{
AdminAccountError, AdminAccountMetadata, AdminAccountProbeOutcome, AdminAccountProbeResult,
AdminAccountQuota, AdminAccountQuotaWarning, AdminAccountQuotaWarnings, AdminAccountRefresh,
AdminAccountResetUsage, AdminAccountService, AdminAuthLogout, AdminAuthPoolStatus,
AdminAuthStatus, AdminQuotaWarningLevel, AdminQuotaWarningWindow, AdminStoredAccount,
BatchDeleteAccounts, BatchUpdateAccountStatus, ImportedAccounts, UpdatedAccountStatus,
};
pub use admin_client_keys::{
AdminClientKeyError, AdminClientKeyService, AdminCreatedClientApiKey, AdminStoredClientApiKey,
BatchDeleteClientApiKeys, ImportedClientApiKey, ImportedClientApiKeys,
UpdatedClientApiKeyStatus,
};
pub use admin_logs::{
AdminClearLogs, AdminLogError, AdminLogFilter, AdminLogService, AdminLogState,
AdminLogStateUpdate,
};
pub use admin_models::{AdminModelError, AdminModelService};
pub use admin_oauth::{AdminDevicePoll, AdminOAuthCallback, AdminOAuthError, AdminOAuthService};
pub use admin_sessions::{AdminLoginSession, AdminSessionError, AdminSessionService};
pub use admin_usage::{AdminUsageError, AdminUsageRecord, AdminUsageService, AdminUsageSummary};
pub use dispatch_chat::{ChatDispatchError, ChatDispatchService};
pub use dispatch_responses::{
ResponseBodyStream, ResponseDispatchError, ResponseDispatchService, ResponseDispatchStream,
ResponseDispatchStreamError,
};
pub use session_affinity::{RuntimeSessionAffinityError, RuntimeSessionAffinityService};
pub use settings::{RuntimeSettingsError, RuntimeSettingsService};
/// 运行时服务集合。
#[derive(Clone)]
pub struct Services {
/// 模型目录服务。
pub models: Arc<ModelService>,
/// 管理端模型服务。
pub admin_models: Arc<AdminModelService>,
/// 账号存储。
pub accounts: Arc<dyn AccountStore>,
/// 模型快照存储。
pub model_snapshots: Arc<dyn ModelSnapshotStore>,
/// 客户端 API Key 服务。
pub client_keys: Arc<ClientKeyService>,
/// 管理端客户端 API Key 服务。
pub admin_client_keys: Arc<AdminClientKeyService>,
/// 管理员会话服务。
pub admin_sessions: Arc<AdminSessionService>,
/// 管理端设置服务。
pub settings: Arc<RuntimeSettingsService>,
/// 管理端账号服务。
pub admin_accounts: Arc<AdminAccountService>,
/// 管理端 OAuth 服务。
pub admin_oauth: Arc<AdminOAuthService>,
/// 管理端日志服务。
pub logs: Arc<AdminLogService>,
/// 管理端用量服务。
pub usage: Arc<AdminUsageService>,
/// 运行时账号池服务。
pub account_pool: Arc<RuntimeAccountPoolService>,
/// OpenAI Chat Completions 调度服务。
pub chat: Arc<ChatDispatchService>,
/// OpenAI Responses 调度服务。
pub responses: Arc<ResponseDispatchService>,
/// 运行时会话亲和性服务。
pub session_affinity: Arc<RuntimeSessionAffinityService>,
/// Codex 上游客户端。
pub codex: Arc<CodexBackendClient>,
/// 当前运行时指纹。
pub fingerprint: Fingerprint,
/// 当前运行时 Codex installation id。
pub installation_id: Option<String>,
/// 后台任务需要的具体存储适配器。
pub background_tasks: BackgroundTaskStores,
}
/// Codex upstream connectivity probe result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeUpstreamProbe {
/// Probed target name.
pub target: &'static str,
/// Configured backend base URL.
pub backend_base_url: String,
/// Full endpoint URL.
pub endpoint: String,
/// Whether the endpoint responded at transport level.
pub reachable: bool,
/// HTTP status code, when available.
pub status_code: Option<u16>,
/// Authentication outcome inferred from status.
pub authorization: &'static str,
}
/// 后台任务需要的具体存储适配器集合。
#[derive(Clone)]
pub struct BackgroundTaskStores {
/// 账号存储。
pub accounts: SqliteAccountStore,
/// 管理员会话存储。
pub admin_sessions: SqliteAdminSessionStore,
/// Cookie 存储。
pub cookies: SqliteCookieStore,
/// 指纹存储。
pub fingerprints: FingerprintRepository,
/// 会话亲和性存储。
pub session_affinity: SqliteSessionAffinityStore,
/// 账号刷新租约存储。
pub refresh_leases: SqliteRefreshLeaseStore,
}
impl Services {
/// 从仓储和上游适配器构造运行时服务集合。
pub fn new(config: &AppConfig, repositories: Repositories, fingerprint: Fingerprint) -> Self {
Self::with_installation_id(config, repositories, fingerprint, None)
}
/// 从仓储、上游适配器和 installation id 构造运行时服务集合。
pub fn with_installation_id(
config: &AppConfig,
repositories: Repositories,
fingerprint: Fingerprint,
installation_id: Option<String>,
) -> Self {
Self::with_installation_id_and_local_config_path(
config,
repositories,
fingerprint,
installation_id,
"local.yaml",
)
}
/// 从仓储、上游适配器、installation id 和本地配置路径构造运行时服务集合。
pub fn with_installation_id_and_local_config_path(
config: &AppConfig,
repositories: Repositories,
fingerprint: Fingerprint,
installation_id: Option<String>,
local_config_path: impl Into<PathBuf>,
) -> Self {
let token_refresher: Arc<dyn TokenRefresher> =
Arc::new(default_openai_oauth_client(oauth_config(config)));
let oauth_client: Arc<dyn OAuthClient> =
Arc::new(default_openai_oauth_client(oauth_config(config)));
Self::with_installation_id_local_config_path_and_oauth_clients(
config,
repositories,
fingerprint,
installation_id,
local_config_path,
token_refresher,
oauth_client,
)
}
/// 从仓储、上游适配器、installation id、本地配置路径和 token refresher 构造服务集合。
pub fn with_installation_id_local_config_path_and_token_refresher(
config: &AppConfig,
repositories: Repositories,
fingerprint: Fingerprint,
installation_id: Option<String>,
local_config_path: impl Into<PathBuf>,
token_refresher: Arc<dyn TokenRefresher>,
) -> Self {
let oauth_client: Arc<dyn OAuthClient> =
Arc::new(default_openai_oauth_client(oauth_config(config)));
Self::with_installation_id_local_config_path_and_oauth_clients(
config,
repositories,
fingerprint,
installation_id,
local_config_path,
token_refresher,
oauth_client,
)
}
/// 从仓储、上游适配器、installation id、本地配置路径和 OAuth 端口构造服务集合。
pub fn with_installation_id_local_config_path_and_oauth_clients(
config: &AppConfig,
repositories: Repositories,
fingerprint: Fingerprint,
installation_id: Option<String>,
local_config_path: impl Into<PathBuf>,
token_refresher: Arc<dyn TokenRefresher>,
oauth_client: Arc<dyn OAuthClient>,
) -> Self {
let installation_id = installation_id.filter(|id| !id.trim().is_empty());
let runtime_fingerprint = fingerprint.clone();
let model_config = 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(),
};
let account_store = repositories.accounts;
let cookies = repositories.cookies;
let background_tasks = BackgroundTaskStores {
accounts: account_store.clone(),
admin_sessions: repositories.admin_sessions.clone(),
cookies: cookies.clone(),
fingerprints: repositories.fingerprints.clone(),
session_affinity: repositories.session_affinity.clone(),
refresh_leases: repositories.refresh_leases.clone(),
};
let accounts = Arc::new(account_store.clone()) as Arc<dyn AccountStore>;
let account_pool = Arc::new(RuntimeAccountPoolService::new(
accounts.clone(),
account_pool_options(config),
config.auth.request_interval_ms,
));
let model_snapshots = Arc::new(repositories.model_snapshots) as Arc<dyn ModelSnapshotStore>;
let model_catalog_client =
upstream::model_catalog_client(config.api.base_url.clone(), fingerprint.clone())
as Arc<dyn CodexModelCatalogClient>;
let models = Arc::new(ModelService::new(
model_config,
Some(model_snapshots.clone()),
Some(model_catalog_client),
Some(Arc::new(tokio::sync::Mutex::new(BTreeMap::new()))),
));
let client_key_store = repositories.client_keys;
let client_keys = Arc::new(ClientKeyService::new(Arc::new(client_key_store.clone())));
let admin_client_keys = Arc::new(AdminClientKeyService::new(client_key_store));
let admin_sessions = Arc::new(AdminSessionService::new(
repositories.admin_sessions,
config.admin.default_username.clone(),
config.admin.session_ttl_minutes,
));
let settings = Arc::new(RuntimeSettingsService::with_local_config_path(
config.clone(),
local_config_path,
));
let admin_models = Arc::new(AdminModelService::new(
models.clone(),
accounts.clone(),
installation_id.clone(),
));
let codex = upstream::codex_backend_client(
config.api.base_url.clone(),
fingerprint,
&config.ws_pool,
);
let admin_accounts = Arc::new(AdminAccountService::new(
account_store.clone(),
cookies.clone(),
config.quota.warning_thresholds.clone(),
codex.clone(),
account_pool.clone(),
token_refresher,
config.auth.refresh_margin_seconds,
installation_id.clone(),
));
let admin_oauth = Arc::new(AdminOAuthService::new(oauth_config(config), oauth_client));
let logs = Arc::new(AdminLogService::new(
repositories.event_logs,
config.logging.enabled,
config.logging.capacity,
config.logging.capture_body,
));
let session_affinity = Arc::new(RuntimeSessionAffinityService::new(
repositories.session_affinity,
));
let usage = Arc::new(AdminUsageService::new(account_store));
let cloudflare_recovery = CloudflareRecovery::new(
cookies,
CloudflarePathBlockTracker::new(),
CloudflareChallengeCooldownTracker::new(),
);
let chat = Arc::new(ChatDispatchService::new(
account_pool.clone(),
models.clone(),
codex.clone(),
logs.clone(),
installation_id.clone(),
cloudflare_recovery.clone(),
));
let responses = Arc::new(ResponseDispatchService::new(
account_pool.clone(),
models.clone(),
codex.clone(),
session_affinity.clone(),
logs.clone(),
installation_id.clone(),
cloudflare_recovery,
));
Self {
models,
admin_models,
accounts,
model_snapshots,
client_keys,
admin_client_keys,
admin_sessions,
settings,
admin_accounts,
admin_oauth,
logs,
usage,
account_pool,
chat,
responses,
session_affinity,
codex,
fingerprint: runtime_fingerprint,
installation_id,
background_tasks,
}
}
}
impl Services {
/// Probe the Codex models endpoint without exposing account secrets.
pub async fn probe_codex_models_endpoint(&self, request_id: &str) -> RuntimeUpstreamProbe {
let config = self.settings.current();
let probe = self
.codex
.probe_models_endpoint(CodexRequestContext {
access_token: "",
account_id: None,
request_id,
turn_state: None,
turn_metadata: None,
beta_features: None,
include_timing_metrics: None,
version: None,
codex_window_id: None,
parent_thread_id: None,
cookie_header: None,
installation_id: self.installation_id.as_deref(),
session_id: None,
})
.await;
match probe {
Ok(probe) => RuntimeUpstreamProbe {
target: "codexModels",
backend_base_url: config.api.base_url.clone(),
endpoint: probe.endpoint,
reachable: true,
status_code: Some(probe.status.as_u16()),
authorization: if probe.status.as_u16() == 401 {
"rejected"
} else {
"unknown"
},
},
Err(_) => RuntimeUpstreamProbe {
target: "codexModels",
backend_base_url: config.api.base_url.clone(),
endpoint: format!(
"{}/codex/models?client_version={}",
config.api.base_url.trim_end_matches('/'),
self.fingerprint.app_version
),
reachable: false,
status_code: None,
authorization: "unknown",
},
}
}
}
pub(crate) fn oauth_config(config: &AppConfig) -> OAuthConfig {
OAuthConfig {
client_id: config.auth.oauth_client_id.clone(),
auth_endpoint: config.auth.oauth_auth_endpoint.clone(),
device_code_endpoint: oauth_device_code_endpoint(&config.auth.oauth_token_endpoint),
token_endpoint: config.auth.oauth_token_endpoint.clone(),
}
}
fn oauth_device_code_endpoint(token_endpoint: &str) -> String {
token_endpoint
.strip_suffix("/token")
.map(|prefix| format!("{prefix}/device/code"))
.unwrap_or_else(|| "https://auth.openai.com/oauth/device/code".to_string())
}
-498
View File
@@ -1,498 +0,0 @@
use super::*;
pub(super) fn account_pool_options(config: &AppConfig) -> AccountPoolOptions {
AccountPoolOptions {
max_concurrent_per_account: config.auth.max_concurrent_per_account,
stale_slot_ttl: Duration::minutes(5),
rotation_strategy: match config.auth.rotation_strategy.as_str() {
"round_robin" => RotationStrategy::RoundRobin,
"sticky" => RotationStrategy::Sticky,
_ => RotationStrategy::LeastUsed,
},
skip_quota_limited: config.quota.skip_exhausted,
tier_priority: config.auth.tier_priority.clone(),
model_plan_allowlist: BTreeMap::new(),
}
}
/// 运行时账号池服务。
#[derive(Clone)]
pub struct RuntimeAccountPoolService {
accounts: Arc<dyn AccountStore>,
pool: Arc<tokio::sync::Mutex<AccountPool>>,
request_interval: StdDuration,
}
impl RuntimeAccountPoolService {
/// 构造运行时账号池服务。
pub fn new(
accounts: Arc<dyn AccountStore>,
options: AccountPoolOptions,
request_interval_ms: u64,
) -> Self {
Self {
accounts,
pool: Arc::new(tokio::sync::Mutex::new(AccountPool::with_options(options))),
request_interval: StdDuration::from_millis(request_interval_ms),
}
}
/// 从账号存储恢复账号池内容。
pub async fn restore_from_repository(&self) -> Result<usize, RuntimeAccountPoolError> {
let accounts = self.accounts.list_pool_accounts().await?;
let restored = accounts.len();
let mut pool = self.pool.lock().await;
pool.clear();
for account in accounts {
pool.insert(account);
}
Ok(restored)
}
/// 从账号存储同步单个账号到运行时账号池;账号已不存在时从池中移除。
pub async fn sync_account_from_repository(
&self,
account_id: &str,
) -> Result<bool, RuntimeAccountPoolError> {
let account = self.accounts.get_pool_account(account_id).await?;
let mut pool = self.pool.lock().await;
if let Some(account) = account {
pool.insert(account);
return Ok(true);
}
Ok(pool.remove(account_id))
}
/// 获取运行时账号池中的账号快照。
pub async fn account_snapshot(&self, account_id: &str) -> Option<Account> {
self.pool.lock().await.get(account_id)
}
/// 从运行时账号池移除账号。
pub async fn remove_account(&self, account_id: &str) -> bool {
self.pool.lock().await.remove(account_id)
}
/// 清空运行时账号池。
pub async fn clear(&self) {
self.pool.lock().await.clear();
}
/// 从账号池获取指定模型可用账号。
pub async fn acquire(&self, model: &str, now: DateTime<Utc>) -> Option<AcquiredAccount> {
self.acquire_with(AccountAcquireRequest::new(model, now))
.await
}
/// 使用完整获取请求从账号池获取账号。
pub async fn acquire_with(&self, request: AccountAcquireRequest) -> Option<AcquiredAccount> {
let acquired = self.pool.lock().await.acquire_with(request)?;
if let Err(error) = self.accounts.record_request(&acquired.account.id).await {
tracing::warn!(
account_id = acquired.account.id,
error = %error,
"failed to persist account request usage"
);
}
Some(acquired)
}
/// 等待同一账号前一个在途请求满足配置的发送间隔。
pub async fn wait_for_request_interval(&self, acquired: &AcquiredAccount) {
if self.request_interval.is_zero() {
return;
}
let Some(previous_slot_at) = acquired.previous_slot_at else {
return;
};
let elapsed = Utc::now()
.signed_duration_since(previous_slot_at)
.to_std()
.unwrap_or_default();
if elapsed < self.request_interval {
sleep(self.request_interval - elapsed).await;
}
}
/// 释放账号的一个在途槽位。
pub async fn release(&self, account_id: &str) {
self.pool.lock().await.release(account_id);
}
/// Return a snapshot of runtime account-pool capacity.
pub async fn capacity_summary(&self, now: DateTime<Utc>) -> AccountCapacitySummary {
self.pool.lock().await.capacity_summary(now)
}
/// Return a snapshot of runtime account-pool capacity using the current time.
pub async fn capacity_summary_now(&self) -> AccountCapacitySummary {
self.capacity_summary(Utc::now()).await
}
/// 标记账号因配额限流进入冷却。
pub async fn mark_quota_limited_until(
&self,
account_id: &str,
cooldown_until: DateTime<Utc>,
) -> bool {
let persisted = match self
.accounts
.mark_quota_limited_until(account_id, cooldown_until)
.await
{
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
account_id,
error = %error,
"failed to persist quota cooldown state"
);
false
}
};
let in_memory = self
.pool
.lock()
.await
.mark_quota_limited_until(account_id, cooldown_until);
persisted || in_memory
}
/// 应用已经验证过的账号配额快照。
pub async fn apply_quota_snapshot(&self, account_id: &str, quota: &Value) -> bool {
let limit_reached = quota_snapshot_limit_reached(quota);
let reset_at = quota_snapshot_reset_at(quota);
let cooldown_until = limit_reached.then_some(reset_at).flatten();
let quota_json = quota.to_string();
let persisted = match self
.accounts
.apply_quota_snapshot(account_id, &quota_json, limit_reached, cooldown_until)
.await
{
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
account_id,
error = %error,
"failed to persist verified quota snapshot"
);
false
}
};
let in_memory =
self.pool
.lock()
.await
.apply_quota_state(account_id, limit_reached, cooldown_until);
if let Some(reset_at) = reset_at {
let limit_window_seconds = quota_snapshot_limit_window_seconds(quota);
if let Err(error) = self
.accounts
.sync_rate_limit_window(account_id, reset_at, limit_window_seconds)
.await
{
tracing::warn!(
account_id,
error = %error,
"failed to persist verified quota window"
);
}
self.pool.lock().await.sync_rate_limit_window(
account_id,
reset_at,
limit_window_seconds,
);
}
persisted || in_memory
}
/// 标记账号处于 Cloudflare 冷却期。
pub async fn set_cloudflare_cooldown_until(
&self,
account_id: &str,
cooldown_until: DateTime<Utc>,
) -> bool {
let persisted = match self
.accounts
.set_cloudflare_cooldown_until(account_id, cooldown_until)
.await
{
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
account_id,
error = %error,
"failed to persist Cloudflare cooldown state"
);
false
}
};
let in_memory = self
.pool
.lock()
.await
.set_cloudflare_cooldown_until(account_id, cooldown_until);
persisted || in_memory
}
/// 更新账号状态。
pub async fn set_status(&self, account_id: &str, status: AccountStatus) -> bool {
let persisted = match self.accounts.set_status(account_id, status).await {
Ok(persisted) => persisted,
Err(error) => {
tracing::warn!(
account_id,
error = %error,
"failed to persist account status"
);
false
}
};
let in_memory = self.pool.lock().await.set_status(account_id, status);
persisted || in_memory
}
/// 清零运行时账号池中的累计和窗口用量。
pub async fn reset_usage(&self, account_id: &str) -> bool {
self.pool.lock().await.reset_usage(account_id)
}
/// 记录账号成功响应的 token 用量。
pub async fn record_token_usage(&self, account_id: &str, usage: TokenUsage) {
self.record_response_usage(account_id, usage, false).await;
}
/// 记录 Responses 成功响应的 token 与工具用量。
pub async fn record_response_usage(
&self,
account_id: &str,
usage: TokenUsage,
image_generation_requested: bool,
) {
let image_request_succeeded = image_generation_requested && usage.image_output_tokens > 0;
let image_request_failed = image_generation_requested && !image_request_succeeded;
let mut persisted_usage = UsageService::account_delta_from_token_usage(usage);
persisted_usage.image_requests = bool_to_u64(image_request_succeeded);
persisted_usage.image_request_failures = bool_to_u64(image_request_failed);
if let Err(error) = self
.accounts
.record_usage_delta(account_id, persisted_usage)
.await
{
tracing::warn!(
account_id,
error = %error,
"failed to persist account token usage"
);
}
self.pool.lock().await.record_window_token_usage(
account_id,
AccountWindowUsageDelta {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_succeeded,
image_request_failed,
},
);
}
/// 记录 Responses 空响应尝试。
pub async fn record_empty_response_attempt(
&self,
account_id: &str,
image_generation_requested: bool,
) {
let usage = codex_proxy_core::accounts::usage::AccountUsageDelta {
empty_responses: 1,
image_request_failures: bool_to_u64(image_generation_requested),
..codex_proxy_core::accounts::usage::AccountUsageDelta::default()
};
if let Err(error) = self.accounts.record_usage_delta(account_id, usage).await {
tracing::warn!(
account_id,
error = %error,
"failed to persist empty response usage"
);
}
if image_generation_requested {
self.pool.lock().await.record_window_token_usage(
account_id,
AccountWindowUsageDelta {
image_request_failed: true,
..AccountWindowUsageDelta::default()
},
);
}
}
/// 将上游成功响应头里的 rate-limit 状态被动写回配额和窗口缓存。
pub async fn sync_passive_rate_limit_headers(
&self,
account: &Account,
headers: &[(String, String)],
) {
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
return;
};
let existing_quota = match self.accounts.get_quota_json(&account.id).await {
Ok(Some(quota_json)) => serde_json::from_str::<Value>(&quota_json).ok(),
Ok(None) => None,
Err(error) => {
tracing::warn!(
account_id = %account.id,
error = %error,
"failed to read existing quota json before passive rate-limit sync"
);
None
}
};
let quota = rate_limit_quota(
&rate_limits,
account.plan_type.as_deref(),
existing_quota.as_ref(),
);
self.apply_quota_snapshot(&account.id, &quota).await;
}
}
/// 运行时账号池错误。
#[derive(Debug, Error)]
pub enum RuntimeAccountPoolError {
/// 账号存储访问失败。
#[error("account store error: {0}")]
Store(#[from] AccountStoreError),
}
#[derive(Clone)]
pub(crate) struct CloudflareRecovery {
cookies: SqliteCookieStore,
path_block_tracker: CloudflarePathBlockTracker,
challenge_cooldowns: CloudflareChallengeCooldownTracker,
}
impl CloudflareRecovery {
pub(super) fn new(
cookies: SqliteCookieStore,
path_block_tracker: CloudflarePathBlockTracker,
challenge_cooldowns: CloudflareChallengeCooldownTracker,
) -> Self {
Self {
cookies,
path_block_tracker,
challenge_cooldowns,
}
}
pub(super) async fn cookie_header_for_request(
&self,
account_id: &str,
request_path: &str,
) -> Option<String> {
match self
.cookies
.cookie_header_for_request(account_id, "chatgpt.com", request_path)
.await
{
Ok(cookie_header) => cookie_header,
Err(error) => {
tracing::warn!(
account_id,
error = %error,
"failed to read account cookies for upstream request"
);
None
}
}
}
pub(super) async fn capture_set_cookie_headers(&self, account_id: &str, headers: &[String]) {
for header in headers {
if let Err(error) = self.cookies.capture_set_cookie(account_id, header).await {
tracing::warn!(
account_id,
error = %error,
"failed to persist upstream set-cookie header"
);
}
}
}
pub(super) async fn apply_challenge(
&self,
account_pool: &RuntimeAccountPoolService,
account_id: &str,
) {
self.delete_account_cookies(account_id, "Cloudflare challenge")
.await;
let cooldown = self
.challenge_cooldowns
.record_challenge(account_id, Utc::now())
.await;
account_pool
.set_cloudflare_cooldown_until(account_id, cooldown.cooldown_until)
.await;
tracing::warn!(
account_id,
challenge_count = cooldown.challenge_count,
delay_seconds = cooldown.delay_seconds,
"upstream returned Cloudflare challenge"
);
}
pub(super) async fn apply_path_block(
&self,
account_pool: &RuntimeAccountPoolService,
account_id: &str,
) {
self.delete_account_cookies(account_id, "Cloudflare path-block")
.await;
let now = Utc::now();
let count = self
.path_block_tracker
.record_path_block(account_id, now)
.await;
if self
.path_block_tracker
.should_disable(account_id, now)
.await
{
account_pool
.set_status(account_id, AccountStatus::Disabled)
.await;
}
tracing::warn!(
account_id,
path_block_count = count,
"upstream returned Cloudflare path-block"
);
}
pub(super) async fn reset_account_recovery(&self, account_id: &str) {
self.path_block_tracker.reset(account_id).await;
self.challenge_cooldowns.reset(account_id).await;
}
async fn delete_account_cookies(&self, account_id: &str, reason: &str) {
if let Err(error) = self.cookies.delete_account_cookies(account_id).await {
tracing::warn!(
account_id,
reason,
error = %error,
"failed to delete account cookies after Cloudflare recovery signal"
);
}
}
}
fn bool_to_u64(value: bool) -> u64 {
if value {
1
} else {
0
}
}
File diff suppressed because it is too large Load Diff
@@ -1,442 +0,0 @@
use super::*;
/// 管理端客户端 API Key 服务。
#[derive(Clone)]
pub struct AdminClientKeyService {
store: SqliteClientKeyStore,
}
impl AdminClientKeyService {
/// 构造管理端客户端 API Key 服务。
pub fn new(store: SqliteClientKeyStore) -> Self {
Self { store }
}
/// 创建新的客户端 API Key。
pub async fn create(
&self,
name: &str,
) -> Result<AdminCreatedClientApiKey, AdminClientKeyError> {
let name = name.trim();
if name.is_empty() {
return Err(AdminClientKeyError::EmptyName);
}
self.store
.create(name)
.await
.map(AdminCreatedClientApiKey::from)
.map_err(|_| AdminClientKeyError::Create)
}
/// 分页列出客户端 API Key。
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<Page<AdminStoredClientApiKey>, AdminClientKeyError> {
let page = self
.store
.list(cursor, limit)
.await
.map_err(|_| AdminClientKeyError::List)?;
Ok(Page {
items: page
.items
.into_iter()
.map(AdminStoredClientApiKey::from)
.collect(),
next_cursor: page.next_cursor,
})
}
/// 更新客户端 API Key 标签。
pub async fn update_label(
&self,
key_id: &str,
label: Option<String>,
) -> Result<Option<AdminStoredClientApiKey>, AdminClientKeyError> {
if label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(AdminClientKeyError::LabelTooLong);
}
self.store
.set_label(key_id, label)
.await
.map(|key| key.map(AdminStoredClientApiKey::from))
.map_err(|_| AdminClientKeyError::UpdateLabel)
}
/// 更新客户端 API Key 启用状态。
pub async fn update_status(
&self,
key_id: &str,
status: &str,
) -> Result<Option<UpdatedClientApiKeyStatus>, AdminClientKeyError> {
let enabled = parse_client_key_status(status)?;
match self.store.set_enabled(key_id, enabled).await {
Ok(true) => Ok(Some(UpdatedClientApiKeyStatus {
id: key_id.to_string(),
enabled,
})),
Ok(false) => Ok(None),
Err(_) => Err(AdminClientKeyError::UpdateStatus),
}
}
/// 删除客户端 API Key。
pub async fn delete(&self, key_id: &str) -> Result<bool, AdminClientKeyError> {
self.store
.delete(key_id)
.await
.map_err(|_| AdminClientKeyError::Delete)
}
/// 批量删除客户端 API Key。
pub async fn batch_delete(
&self,
ids: Vec<String>,
) -> Result<BatchDeleteClientApiKeys, AdminClientKeyError> {
if ids.is_empty() {
return Err(AdminClientKeyError::EmptyIds);
}
let mut deleted = 0u32;
let mut not_found = Vec::new();
for id in ids {
match self.store.delete(&id).await {
Ok(true) => deleted += 1,
Ok(false) => not_found.push(id),
Err(_) => return Err(AdminClientKeyError::Delete),
}
}
Ok(BatchDeleteClientApiKeys { deleted, not_found })
}
/// 导出客户端 API Key 元数据,不包含明文和哈希材料。
pub async fn export(
&self,
ids: Vec<String>,
) -> Result<Vec<AdminStoredClientApiKey>, AdminClientKeyError> {
if ids.is_empty() {
let mut all_keys = Vec::new();
let mut cursor = None;
loop {
let page = self
.store
.list(cursor, 200)
.await
.map_err(|_| AdminClientKeyError::Export)?;
all_keys.extend(page.items.into_iter().map(AdminStoredClientApiKey::from));
if page.next_cursor.is_none() {
return Ok(all_keys);
}
cursor = page.next_cursor;
}
}
let mut keys = Vec::with_capacity(ids.len());
for id in ids {
match self.store.get(&id).await {
Ok(Some(key)) => keys.push(AdminStoredClientApiKey::from(key)),
Ok(None) => {}
Err(_) => return Err(AdminClientKeyError::Export),
}
}
Ok(keys)
}
/// 导入导出的客户端 API Key 元数据,并轮换为新的本地明文。
pub async fn import(
&self,
payload: &Value,
) -> Result<ImportedClientApiKeys, AdminClientKeyError> {
let entries = parse_client_key_import_payload(payload);
if entries.is_empty() {
return Err(AdminClientKeyError::NoImportableKeys);
}
let mut imported = 0u32;
let mut skipped = 0u32;
let mut keys = Vec::with_capacity(entries.len());
for entry in entries {
let name = entry.name.trim();
if name.is_empty() {
skipped += 1;
continue;
}
if entry
.label
.as_ref()
.is_some_and(|label| label.chars().count() > 64)
{
return Err(AdminClientKeyError::LabelTooLong);
}
let mut created = self.create(name).await?;
if entry.label.is_some() || !entry.enabled {
self.store
.set_label(&created.id, entry.label)
.await
.map_err(|_| AdminClientKeyError::Import)?;
if !entry.enabled {
self.store
.set_enabled(&created.id, false)
.await
.map_err(|_| AdminClientKeyError::Import)?;
}
let Some(stored) = self
.store
.get(&created.id)
.await
.map_err(|_| AdminClientKeyError::Import)?
else {
return Err(AdminClientKeyError::Import);
};
created.label = stored.label;
created.enabled = stored.enabled;
}
imported += 1;
keys.push(ImportedClientApiKey {
source_id: entry.source_id,
source_prefix: entry.source_prefix,
key: created,
});
}
Ok(ImportedClientApiKeys {
imported,
skipped,
keys,
})
}
}
/// 管理端可见的客户端 API Key 元数据。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminStoredClientApiKey {
/// API Key 记录 ID。
pub id: String,
/// API Key 名称。
pub name: String,
/// 管理员可见标签。
pub label: Option<String>,
/// 明文 API Key 的短前缀。
pub prefix: String,
/// 是否允许用于 `/v1` 认证。
pub enabled: bool,
/// 创建时间。
pub created_at: String,
/// 最近一次成功使用时间。
pub last_used_at: Option<String>,
}
/// 新建客户端 API Key 后的一次性结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminCreatedClientApiKey {
/// API Key 记录 ID。
pub id: String,
/// API Key 名称。
pub name: String,
/// 管理员可见标签。
pub label: Option<String>,
/// 明文 API Key 的短前缀。
pub prefix: String,
/// 是否允许用于 `/v1` 认证。
pub enabled: bool,
/// 创建时间。
pub created_at: String,
/// 最近一次成功使用时间。
pub last_used_at: Option<String>,
/// 仅返回一次的明文 API Key。
pub plaintext: String,
}
/// 客户端 API Key 状态更新结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdatedClientApiKeyStatus {
/// API Key 记录 ID。
pub id: String,
/// 是否启用。
pub enabled: bool,
}
/// 批量删除客户端 API Key 的结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BatchDeleteClientApiKeys {
/// 成功删除数量。
pub deleted: u32,
/// 未找到的 ID。
pub not_found: Vec<String>,
}
/// 导入后的客户端 API Key。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedClientApiKey {
/// 来源 ID。
pub source_id: Option<String>,
/// 来源短前缀。
pub source_prefix: Option<String>,
/// 新建的本地 API Key。
pub key: AdminCreatedClientApiKey,
}
/// 客户端 API Key 导入结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedClientApiKeys {
/// 成功导入数量。
pub imported: u32,
/// 跳过数量。
pub skipped: u32,
/// 新建 API Key 列表。
pub keys: Vec<ImportedClientApiKey>,
}
/// 管理端客户端 API Key 错误。
#[derive(Debug, Error)]
pub enum AdminClientKeyError {
/// 列表失败。
#[error("failed to list client API keys")]
List,
/// 导出失败。
#[error("failed to export client API keys")]
Export,
/// 导入失败。
#[error("failed to import client API keys")]
Import,
/// 创建失败。
#[error("failed to create client API key")]
Create,
/// 删除失败。
#[error("failed to delete client API key")]
Delete,
/// 更新标签失败。
#[error("failed to update client API key label")]
UpdateLabel,
/// 更新状态失败。
#[error("failed to update client API key status")]
UpdateStatus,
/// 状态值无效。
#[error("unsupported client API key status: {0}")]
InvalidStatus(String),
/// 名称为空。
#[error("client API key name is required")]
EmptyName,
/// ID 列表为空。
#[error("client API key ids are required")]
EmptyIds,
/// 标签过长。
#[error("client API key label must be 64 characters or fewer")]
LabelTooLong,
/// 没有可导入的 API Key。
#[error("no importable client API keys found")]
NoImportableKeys,
}
#[derive(Debug, Clone)]
struct ClientApiKeyImportEntry {
source_id: Option<String>,
source_prefix: Option<String>,
name: String,
label: Option<String>,
enabled: bool,
}
fn parse_client_key_status(status: &str) -> Result<bool, AdminClientKeyError> {
match status.trim().to_ascii_lowercase().as_str() {
"active" => Ok(true),
"disabled" => Ok(false),
other => Err(AdminClientKeyError::InvalidStatus(other.to_string())),
}
}
fn parse_client_key_import_payload(payload: &Value) -> Vec<ClientApiKeyImportEntry> {
let payload = payload
.get("data")
.filter(|data| data.get("apiKeys").is_some() || data.get("keys").is_some())
.unwrap_or(payload);
if let Some(keys) = payload.get("apiKeys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.get("keys").and_then(Value::as_array) {
return keys
.iter()
.filter_map(client_key_import_entry_from_value)
.collect();
}
if let Some(keys) = payload.as_array() {
return keys
.iter()
.filter_map(client_key_import_entry_from_value)
.collect();
}
client_key_import_entry_from_value(payload)
.into_iter()
.collect()
}
fn client_key_import_entry_from_value(value: &Value) -> Option<ClientApiKeyImportEntry> {
value.as_object()?;
let name = first_string(value, &["name"])?;
Some(ClientApiKeyImportEntry {
source_id: first_string(value, &["id", "sourceId"]),
source_prefix: first_string(value, &["prefix", "sourcePrefix"]),
name,
label: first_string(value, &["label"]),
enabled: client_key_import_enabled(value),
})
}
fn client_key_import_enabled(value: &Value) -> bool {
if let Some(enabled) = value.get("enabled").and_then(Value::as_bool) {
return enabled;
}
!first_string(value, &["status"])
.unwrap_or_else(|| "active".to_string())
.trim()
.eq_ignore_ascii_case("disabled")
}
fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| value.get(key).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
impl From<StoredClientApiKey> for AdminStoredClientApiKey {
fn from(key: StoredClientApiKey) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
}
}
}
impl From<CreatedClientApiKey> for AdminCreatedClientApiKey {
fn from(key: CreatedClientApiKey) -> Self {
Self {
id: key.id,
name: key.name,
label: key.label,
prefix: key.prefix,
enabled: key.enabled,
created_at: key.created_at,
last_used_at: key.last_used_at,
plaintext: key.plaintext,
}
}
}
-252
View File
@@ -1,252 +0,0 @@
use super::*;
/// 管理端日志服务。
#[derive(Clone)]
pub struct AdminLogService {
store: SqliteEventLogStore,
settings: Arc<RwLock<AdminLogSettings>>,
}
#[derive(Debug, Clone, Copy)]
struct AdminLogSettings {
enabled: bool,
capacity: u32,
capture_body: bool,
}
impl AdminLogService {
/// 构造管理端日志服务。
pub fn new(
store: SqliteEventLogStore,
enabled: bool,
capacity: u32,
capture_body: bool,
) -> Self {
Self {
store,
settings: Arc::new(RwLock::new(AdminLogSettings {
enabled,
capacity,
capture_body,
})),
}
}
/// 分页查询日志。
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
filter: AdminLogFilter,
) -> Result<Page<EventLog>, AdminLogError> {
self.store
.list(filter.into(), cursor, limit)
.await
.map_err(|_| AdminLogError::List)
}
/// 按 ID 读取日志。
pub async fn get(&self, id: &str) -> Result<Option<EventLog>, AdminLogError> {
self.store.get(id).await.map_err(|_| AdminLogError::Get)
}
/// 读取日志状态。
pub async fn state(&self) -> Result<AdminLogState, AdminLogError> {
let settings = *self.settings.read().await;
Ok(AdminLogState {
enabled: settings.enabled,
capacity: settings.capacity,
capture_body: settings.capture_body,
stored_count: self.store.count().await.map_err(|_| AdminLogError::Count)?,
})
}
/// 更新日志状态。
pub async fn update_state(
&self,
update: AdminLogStateUpdate,
) -> Result<AdminLogState, AdminLogError> {
if matches!(update.capacity, Some(0)) {
return Err(AdminLogError::InvalidCapacity);
}
let trim_capacity = {
let mut settings = self.settings.write().await;
if let Some(enabled) = update.enabled {
settings.enabled = enabled;
}
if let Some(capacity) = update.capacity {
settings.capacity = capacity;
}
if let Some(capture_body) = update.capture_body {
settings.capture_body = capture_body;
}
update.capacity
};
if let Some(capacity) = trim_capacity {
self.store
.trim_to_capacity(capacity)
.await
.map_err(|_| AdminLogError::Trim)?;
}
self.state().await
}
/// 清空日志。
pub async fn clear(&self) -> Result<AdminClearLogs, AdminLogError> {
self.store
.clear()
.await
.map(|cleared| AdminClearLogs { cleared })
.map_err(|_| AdminLogError::Clear)
}
pub(super) async fn record(&self, mut event: EventLog) -> Result<(), AdminLogError> {
let settings = *self.settings.read().await;
let policy = EventLogService::new(settings.enabled);
if !policy.should_record(&event) {
return Ok(());
}
apply_capture_body_policy(&mut event, settings.capture_body);
self.store
.append(&event)
.await
.map_err(|_| AdminLogError::Append)?;
self.store
.trim_to_capacity(settings.capacity)
.await
.map_err(|_| AdminLogError::Trim)?;
Ok(())
}
}
fn apply_capture_body_policy(event: &mut EventLog, capture_body: bool) {
if capture_body {
return;
}
let Some(metadata) = event.metadata.as_object_mut() else {
return;
};
for key in [
"body",
"rawBody",
"requestBody",
"responseBody",
"upstreamBody",
] {
metadata.remove(key);
}
}
/// 日志查询过滤器。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdminLogFilter {
/// 事件类别。
pub kind: Option<String>,
/// 事件等级。
pub level: Option<EventLevel>,
/// 请求 ID。
pub request_id: Option<String>,
/// 账号 ID。
pub account_id: Option<String>,
/// 路由。
pub route: Option<String>,
/// 模型。
pub model: Option<String>,
/// HTTP 状态码。
pub status_code: Option<i64>,
/// 上游传输方式。
pub transport: Option<String>,
/// 同一请求内的上游尝试序号。
pub attempt_index: Option<i64>,
/// 上游 HTTP 状态码。
pub upstream_status_code: Option<i64>,
/// 失败分类。
pub failure_class: Option<String>,
/// 上游响应 ID。
pub response_id: Option<String>,
/// 上游请求 ID。
pub upstream_request_id: Option<String>,
/// 搜索关键词。
pub search: Option<String>,
}
/// 日志状态。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminLogState {
/// 是否启用。
pub enabled: bool,
/// 内存容量。
pub capacity: u32,
/// 是否捕获请求体。
pub capture_body: bool,
/// 已存储数量。
pub stored_count: u64,
}
/// 日志状态更新。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdminLogStateUpdate {
/// 是否启用。
pub enabled: Option<bool>,
/// 日志容量。
pub capacity: Option<u32>,
/// 是否捕获请求体。
pub capture_body: Option<bool>,
}
/// 清空日志结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminClearLogs {
/// 清理数量。
pub cleared: u64,
}
/// 管理端日志错误。
#[derive(Debug, Error)]
pub enum AdminLogError {
/// 列表失败。
#[error("failed to list event logs")]
List,
/// 读取失败。
#[error("failed to get event log")]
Get,
/// 计数失败。
#[error("failed to count event logs")]
Count,
/// 清空失败。
#[error("failed to clear event logs")]
Clear,
/// 写入失败。
#[error("failed to append event log")]
Append,
/// 裁剪失败。
#[error("failed to trim event logs")]
Trim,
/// 日志容量非法。
#[error("log capacity must be greater than zero")]
InvalidCapacity,
}
impl From<AdminLogFilter> for EventLogFilter {
fn from(filter: AdminLogFilter) -> Self {
Self {
kind: filter.kind,
level: filter.level,
request_id: filter.request_id,
account_id: filter.account_id,
route: filter.route,
model: filter.model,
status_code: filter.status_code,
transport: filter.transport,
attempt_index: filter.attempt_index,
upstream_status_code: filter.upstream_status_code,
failure_class: filter.failure_class,
response_id: filter.response_id,
upstream_request_id: filter.upstream_request_id,
search: filter.search,
}
}
}
@@ -1,83 +0,0 @@
use super::*;
/// 管理端模型服务。
#[derive(Clone)]
pub struct AdminModelService {
models: Arc<ModelService>,
accounts: Arc<dyn AccountStore>,
installation_id: Option<String>,
}
impl AdminModelService {
/// 构造管理端模型服务。
pub fn new(
models: Arc<ModelService>,
accounts: Arc<dyn AccountStore>,
installation_id: Option<String>,
) -> Self {
Self {
models,
accounts,
installation_id,
}
}
/// 用当前活跃账号刷新上游模型快照。
pub async fn refresh_backend_models(
&self,
request_id: &str,
) -> Result<ModelRefreshResult, AdminModelError> {
let accounts = self
.accounts
.list_pool_accounts()
.await
.map_err(|_| AdminModelError::ListAccounts)?;
self.models
.refresh_backend_models_with_installation_id(
&accounts,
request_id,
self.installation_id.as_deref(),
)
.await
.map_err(AdminModelError::from)
}
}
/// 管理端模型错误。
#[derive(Debug, Error)]
pub enum AdminModelError {
/// 列出账号失败。
#[error("failed to list accounts")]
ListAccounts,
/// 没有可用账号。
#[error("no active accounts available for model refresh")]
NoAccounts,
/// 模型快照存储不可用。
#[error("model snapshot store is unavailable")]
SnapshotStoreUnavailable,
/// 上游客户端不可用。
#[error("model upstream client is unavailable")]
UpstreamClientUnavailable,
/// 写入快照失败。
#[error("failed to store model snapshot")]
StoreSnapshot,
/// 读取快照失败。
#[error("failed to load model snapshots")]
LoadSnapshots,
/// 所有计划刷新失败。
#[error("all model refresh plans failed")]
AllPlansFailed(ModelRefreshResult),
}
impl From<ModelServiceError> for AdminModelError {
fn from(error: ModelServiceError) -> Self {
match error {
ModelServiceError::SnapshotStoreUnavailable => Self::SnapshotStoreUnavailable,
ModelServiceError::UpstreamClientUnavailable => Self::UpstreamClientUnavailable,
ModelServiceError::NoAccounts => Self::NoAccounts,
ModelServiceError::StoreSnapshot => Self::StoreSnapshot,
ModelServiceError::LoadSnapshots => Self::LoadSnapshots,
ModelServiceError::AllPlansFailed(result) => Self::AllPlansFailed(result),
}
}
}
-120
View File
@@ -1,120 +0,0 @@
use super::*;
/// 管理端 OAuth 服务。
#[derive(Clone)]
pub struct AdminOAuthService {
config: OAuthConfig,
client: Arc<dyn OAuthClient>,
sessions: Arc<tokio::sync::Mutex<PkceSessionStore>>,
}
impl AdminOAuthService {
/// 构造管理端 OAuth 服务。
pub fn new(config: OAuthConfig, client: Arc<dyn OAuthClient>) -> Self {
Self {
config,
client,
sessions: Arc::new(tokio::sync::Mutex::new(PkceSessionStore::default())),
}
}
/// 开始 PKCE 登录。
pub async fn start_pkce_login(&self, return_host: &str) -> PkceLogin {
self.sessions
.lock()
.await
.start_login(return_host, &self.config)
}
/// 请求设备码登录信息。
pub async fn request_device_code(&self) -> Result<DeviceCode, AdminOAuthError> {
self.client
.request_device_code()
.await
.map_err(AdminOAuthError::OAuth)
}
/// 轮询设备码 token。
pub async fn poll_device_token(
&self,
device_code: &str,
) -> Result<AdminDevicePoll, AdminOAuthError> {
match self.client.poll_device_token(device_code).await {
Ok(tokens) => Ok(AdminDevicePoll::Authorized(tokens)),
Err(error) => {
if let Some(code) = error.pending_code() {
Ok(AdminDevicePoll::Pending { code })
} else {
Err(AdminOAuthError::OAuth(error))
}
}
}
}
/// 使用回调 code/state 完成 PKCE token 交换。
pub async fn exchange_callback(
&self,
code: &str,
state: &str,
) -> Result<AdminOAuthCallback, AdminOAuthError> {
let session = self
.sessions
.lock()
.await
.try_acquire(state)
.ok_or(AdminOAuthError::InvalidState)?;
match self
.client
.exchange_code(code, &session.code_verifier, &session.redirect_uri)
.await
{
Ok(tokens) => {
self.sessions.lock().await.complete(state);
Ok(AdminOAuthCallback {
tokens,
return_host: session.return_host,
})
}
Err(error) => {
self.sessions.lock().await.release(state);
Err(AdminOAuthError::OAuth(error))
}
}
}
}
/// 设备码轮询结果。
#[derive(Debug, Clone)]
pub enum AdminDevicePoll {
/// 授权还未完成。
Pending {
/// OAuth 标准 pending 错误码。
code: &'static str,
},
/// 已换取 token。
Authorized(TokenPair),
}
/// PKCE 回调换取的 token 和返回 host。
#[derive(Debug, Clone)]
pub struct AdminOAuthCallback {
/// OAuth token 对。
pub tokens: TokenPair,
/// 登录前的管理端 host。
pub return_host: String,
}
/// 管理端 OAuth 错误。
#[derive(Debug, Error)]
pub enum AdminOAuthError {
/// callback URL 或 query 缺少必需字段。
#[error("invalid OAuth callback")]
InvalidCallback,
/// OAuth state 不存在、过期或正在处理。
#[error("invalid OAuth state")]
InvalidState,
/// OAuth 上游错误。
#[error("{0}")]
OAuth(OAuthError),
}
@@ -1,129 +0,0 @@
use super::*;
/// 管理员会话服务。
#[derive(Clone)]
pub struct AdminSessionService {
store: SqliteAdminSessionStore,
auth: AdminAuthService,
default_username: String,
session_ttl_minutes: u64,
}
impl AdminSessionService {
/// 构造管理员会话服务。
pub fn new(
store: SqliteAdminSessionStore,
default_username: String,
session_ttl_minutes: u64,
) -> Self {
Self {
store,
auth: AdminAuthService::new(default_username.clone()),
default_username,
session_ttl_minutes,
}
}
/// 校验管理员会话是否存在且未过期。
pub async fn validate(&self, session_id: Option<&str>) -> Result<bool, AdminSessionError> {
let Some(session_id) = session_id else {
return Ok(false);
};
self.store
.validate_session(session_id)
.await
.map_err(|_| AdminSessionError::Validate)
}
/// 如果还没有管理员用户,则根据配置密码创建默认管理员。
pub async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminSessionError> {
let password_hash =
hash_admin_password(password).map_err(|_| AdminSessionError::HashPassword)?;
self.store
.ensure_default_admin(&password_hash)
.await
.map_err(|_| AdminSessionError::CreateAdmin)
}
/// 使用管理员用户名和密码创建会话。
pub async fn login(
&self,
username: Option<&str>,
password: &str,
) -> Result<Option<AdminLoginSession>, AdminSessionError> {
let username = username.unwrap_or(&self.default_username);
if !self.auth.username_matches(username) {
return Ok(None);
}
let Some(admin) = self
.store
.load_first_admin()
.await
.map_err(|_| AdminSessionError::LoadAdmin)?
else {
return Ok(None);
};
let password_matches = verify_admin_password(password, &admin.password_hash)
.map_err(|_| AdminSessionError::VerifyPassword)?;
if !password_matches {
return Ok(None);
}
let session_id = format!("sess_{}", uuid::Uuid::new_v4().simple());
let ttl_minutes = self.session_ttl_minutes.min(i64::MAX as u64) as i64;
let expires_at = Utc::now() + Duration::minutes(ttl_minutes);
self.store
.create_session(&session_id, &admin.id, expires_at)
.await
.map_err(|_| AdminSessionError::CreateSession)?;
Ok(Some(AdminLoginSession {
session_id,
expires_at,
}))
}
/// 删除指定的管理员会话。
pub async fn delete_session(&self, session_id: &str) -> Result<bool, AdminSessionError> {
self.store
.delete_session(session_id)
.await
.map_err(|_| AdminSessionError::DeleteSession)
}
}
/// 管理员登录成功后的会话。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminLoginSession {
/// 会话 ID。
pub session_id: String,
/// 过期时间。
pub expires_at: DateTime<Utc>,
}
/// 管理员会话错误。
#[derive(Debug, Error)]
pub enum AdminSessionError {
/// 会话校验失败。
#[error("failed to validate admin session")]
Validate,
/// 密码哈希失败。
#[error("failed to hash admin password")]
HashPassword,
/// 创建管理员失败。
#[error("failed to create default admin user")]
CreateAdmin,
/// 读取管理员失败。
#[error("failed to load admin user")]
LoadAdmin,
/// 密码校验失败。
#[error("failed to verify admin password")]
VerifyPassword,
/// 创建会话失败。
#[error("failed to create admin session")]
CreateSession,
/// 删除会话失败。
#[error("failed to delete admin session")]
DeleteSession,
}
-159
View File
@@ -1,159 +0,0 @@
use super::*;
/// 管理端用量服务。
#[derive(Clone)]
pub struct AdminUsageService {
store: SqliteAccountStore,
}
impl AdminUsageService {
/// 构造管理端用量服务。
pub fn new(store: SqliteAccountStore) -> Self {
Self { store }
}
/// 分页列出账号用量统计。
pub async fn list(
&self,
cursor: Option<String>,
limit: u32,
) -> Result<Page<AdminUsageRecord>, AdminUsageError> {
let page = self
.store
.list_usage(cursor, limit)
.await
.map_err(|_| AdminUsageError::List)?;
Ok(Page {
items: page.items.into_iter().map(AdminUsageRecord::from).collect(),
next_cursor: page.next_cursor,
})
}
/// 汇总账号用量统计。
pub async fn summary(&self) -> Result<AdminUsageSummary, AdminUsageError> {
self.store
.usage_summary()
.await
.map(AdminUsageSummary::from)
.map_err(|_| AdminUsageError::Summary)
}
}
/// 管理端用量错误。
#[derive(Debug, Error)]
pub enum AdminUsageError {
/// 列表失败。
#[error("failed to list account usage")]
List,
/// 汇总失败。
#[error("failed to summarize account usage")]
Summary,
}
/// 管理端用量记录。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdminUsageRecord {
/// 账号 ID。
pub account_id: String,
/// 邮箱。
pub email: Option<String>,
/// 管理标签。
pub label: Option<String>,
/// 计划类型。
pub plan_type: Option<String>,
/// 请求数。
pub request_count: i64,
/// 空响应数。
pub empty_response_count: i64,
/// 输入 token 数。
pub input_tokens: i64,
/// 输出 token 数。
pub output_tokens: i64,
/// 缓存 token 数。
pub cached_tokens: i64,
/// reasoning token 数。
pub reasoning_tokens: i64,
/// 上游返回的总 token 数。
pub total_tokens: i64,
/// 图片输入 token 数。
pub image_input_tokens: i64,
/// 图片输出 token 数。
pub image_output_tokens: i64,
/// 图片请求数。
pub image_request_count: i64,
/// 图片请求失败数。
pub image_request_failed_count: i64,
/// 最近使用时间。
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 管理端用量汇总。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AdminUsageSummary {
/// 有用量记录的账号数。
pub account_count: i64,
/// 请求总数。
pub request_count: i64,
/// 空响应总数。
pub empty_response_count: i64,
/// 输入 token 总数。
pub input_tokens: i64,
/// 输出 token 总数。
pub output_tokens: i64,
/// 缓存 token 总数。
pub cached_tokens: i64,
/// reasoning token 总数。
pub reasoning_tokens: i64,
/// 上游返回 token 总数。
pub total_tokens: i64,
/// 图片输入 token 总数。
pub image_input_tokens: i64,
/// 图片输出 token 总数。
pub image_output_tokens: i64,
/// 图片请求总数。
pub image_request_count: i64,
/// 图片请求失败总数。
pub image_request_failed_count: i64,
}
impl From<AccountUsageListRecord> for AdminUsageRecord {
fn from(usage: AccountUsageListRecord) -> Self {
Self {
account_id: usage.account_id,
email: usage.email,
label: usage.label,
plan_type: usage.plan_type,
request_count: usage.request_count,
empty_response_count: usage.empty_response_count,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
total_tokens: usage.total_tokens,
image_input_tokens: usage.image_input_tokens,
image_output_tokens: usage.image_output_tokens,
image_request_count: usage.image_request_count,
image_request_failed_count: usage.image_request_failed_count,
last_used_at: usage.last_used_at,
}
}
}
impl From<AccountUsageSummary> for AdminUsageSummary {
fn from(summary: AccountUsageSummary) -> Self {
Self {
account_count: summary.account_count,
request_count: summary.request_count,
empty_response_count: summary.empty_response_count,
input_tokens: summary.input_tokens,
output_tokens: summary.output_tokens,
cached_tokens: summary.cached_tokens,
reasoning_tokens: summary.reasoning_tokens,
total_tokens: summary.total_tokens,
image_input_tokens: summary.image_input_tokens,
image_output_tokens: summary.image_output_tokens,
image_request_count: summary.image_request_count,
image_request_failed_count: summary.image_request_failed_count,
}
}
}
@@ -1,117 +0,0 @@
use super::*;
/// 默认会话亲和性 TTL 秒数。
const DEFAULT_SESSION_AFFINITY_TTL_SECS: i64 = 4 * 60 * 60;
/// 运行时会话亲和性服务。
#[derive(Clone)]
pub struct RuntimeSessionAffinityService {
store: SqliteSessionAffinityStore,
map: Arc<tokio::sync::RwLock<SessionAffinityMap>>,
ttl: Duration,
}
impl RuntimeSessionAffinityService {
/// 构造运行时会话亲和性服务。
pub fn new(store: SqliteSessionAffinityStore) -> Self {
let ttl = Duration::seconds(DEFAULT_SESSION_AFFINITY_TTL_SECS);
Self {
store,
map: Arc::new(tokio::sync::RwLock::new(SessionAffinityMap::new(ttl))),
ttl,
}
}
/// 从 SQLite 恢复未过期的会话亲和性记录。
pub async fn restore_from_repository(
&self,
now: DateTime<Utc>,
) -> Result<usize, RuntimeSessionAffinityError> {
let records = self.store.list_active(now).await?;
Ok(self.map.write().await.restore(records, now))
}
/// 记录并持久化响应 ID 的亲和性条目。
pub async fn record(
&self,
response_id: String,
entry: SessionAffinityEntry,
) -> Result<(), RuntimeSessionAffinityError> {
self.store.upsert(&response_id, &entry, self.ttl).await?;
self.map.write().await.record(response_id, entry);
Ok(())
}
/// 根据响应 ID 查找账号 ID。
pub async fn lookup_account(&self, response_id: &str, now: DateTime<Utc>) -> Option<String> {
self.map.read().await.lookup_account(response_id, now)
}
/// 根据响应 ID 查找对话 ID。
pub async fn lookup_conversation_id(
&self,
response_id: &str,
now: DateTime<Utc>,
) -> Option<String> {
self.map
.read()
.await
.lookup_conversation_id(response_id, now)
}
/// 根据响应 ID 查找 turn state。
pub async fn lookup_turn_state(&self, response_id: &str, now: DateTime<Utc>) -> Option<String> {
self.map.read().await.lookup_turn_state(response_id, now)
}
/// 根据响应 ID 查找指令哈希。
pub async fn lookup_instructions_hash(
&self,
response_id: &str,
now: DateTime<Utc>,
) -> Option<String> {
self.map
.read()
.await
.lookup_instructions_hash(response_id, now)
}
/// 根据响应 ID 查找函数调用 ID 列表。
pub async fn lookup_function_call_ids(
&self,
response_id: &str,
now: DateTime<Utc>,
) -> Vec<String> {
self.map
.read()
.await
.lookup_function_call_ids(response_id, now)
}
/// 查找指定对话和变体下最新的响应 ID。
pub async fn lookup_latest_response_by_conversation(
&self,
conversation_id: &str,
max_age: Option<Duration>,
variant_hash: Option<&str>,
now: DateTime<Utc>,
) -> Option<String> {
self.map
.read()
.await
.lookup_latest_response_by_conversation(conversation_id, max_age, variant_hash, now)
}
/// 删除响应 ID 的内存亲和性映射。
pub async fn forget(&self, response_id: &str) -> bool {
self.map.write().await.forget(response_id)
}
}
/// 运行时会话亲和性错误。
#[derive(Debug, Error)]
pub enum RuntimeSessionAffinityError {
/// 存储访问失败。
#[error("session affinity store error: {0}")]
Store(#[from] SqliteSessionAffinityStoreError),
}
-119
View File
@@ -1,119 +0,0 @@
use super::*;
/// 运行时设置服务。
#[derive(Clone)]
pub struct RuntimeSettingsService {
current: Arc<StdRwLock<Arc<AppConfig>>>,
local_config_path: Arc<PathBuf>,
}
impl RuntimeSettingsService {
/// 构造运行时设置服务。
pub fn new(config: AppConfig) -> Self {
Self::with_local_config_path(config, "local.yaml")
}
/// 构造带本地配置覆盖路径的运行时设置服务。
pub fn with_local_config_path(
config: AppConfig,
local_config_path: impl Into<PathBuf>,
) -> Self {
Self {
current: Arc::new(StdRwLock::new(Arc::new(config))),
local_config_path: Arc::new(local_config_path.into()),
}
}
/// 返回当前配置快照。
pub fn current(&self) -> Arc<AppConfig> {
self.current
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// Return the local settings overlay path configured for this runtime.
pub fn local_config_path(&self) -> Arc<PathBuf> {
self.local_config_path.clone()
}
/// 更新当前设置并写入本地配置覆盖文件。
pub async fn update(
&self,
patch: AdminSettingsPatch,
) -> Result<Arc<AppConfig>, RuntimeSettingsError> {
let mut next = (*self.current()).clone();
let mut settings = admin_settings_from_config(&next);
SettingsService::apply_patch(&mut settings, patch)?;
apply_admin_settings_to_config(&mut next, settings);
next.write_settings_overlay(self.local_config_path.as_ref())
.await?;
let next = Arc::new(next);
*self
.current
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = next.clone();
Ok(next)
}
}
/// 运行时设置错误。
#[derive(Debug, Error)]
pub enum RuntimeSettingsError {
/// 设置补丁验证失败。
#[error(transparent)]
InvalidField(#[from] SettingsServiceError),
/// 本地配置覆盖写入失败。
#[error(transparent)]
Persist(#[from] ConfigWriteError),
}
fn admin_settings_from_config(config: &AppConfig) -> AdminSettings {
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(),
refresh_enabled: config.auth.refresh_enabled,
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: config.quota.skip_exhausted,
logs_enabled: config.logging.enabled,
logs_capacity: config.logging.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.default_reasoning_effort = settings.default_reasoning_effort;
config.model.service_tier = settings.service_tier;
config.model.aliases = settings.model_aliases;
config.auth.refresh_enabled = settings.refresh_enabled;
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;
config.auth.request_interval_ms = settings.request_interval_ms;
config.auth.rotation_strategy = settings.rotation_strategy;
config.auth.tier_priority = settings.tier_priority;
config.quota.refresh_interval_minutes = settings.quota_refresh_interval_minutes;
config.quota.warning_thresholds = QuotaWarningThresholds {
primary: settings.quota_warning_thresholds.primary,
secondary: settings.quota_warning_thresholds.secondary,
};
config.quota.skip_exhausted = settings.quota_skip_exhausted;
config.logging.enabled = settings.logs_enabled;
config.logging.capacity = settings.logs_capacity;
config.logging.capture_body = settings.logs_capture_body;
config.usage_stats.history_retention_days = settings.usage_history_retention_days;
}
-316
View File
@@ -1,316 +0,0 @@
//! 运行时状态。
use std::{path::PathBuf, sync::Arc};
use chrono::{DateTime, Utc};
use codex_proxy_core::{
auth::ports::{OAuthClient, TokenRefresher},
gateway::fingerprint::Fingerprint,
};
use codex_proxy_platform::{
config::AppConfig, crypto::SecretBox, identity::ApiKeyHasher, storage::SqlitePool,
};
use crate::{
config::RuntimeConfig,
repositories::sqlite_repositories,
services::{RuntimeAccountPoolError, RuntimeSessionAffinityError, Services},
};
/// 应用状态。
#[derive(Clone)]
pub struct AppState {
/// 运行时配置。
pub config: RuntimeConfig,
/// 运行时服务。
pub services: Services,
}
struct AppStateServiceOptions {
fingerprint: Fingerprint,
installation_id: Option<String>,
local_config_path: PathBuf,
token_refresher: Option<Arc<dyn TokenRefresher>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
}
impl AppState {
/// 从连接池和密钥箱构造状态。
pub fn with_pool_and_secret_box(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
) -> Self {
Self::with_pool_secret_api_key_hasher_and_fingerprint(
config,
pool,
secret_box,
ApiKeyHasher::new([0u8; 32]),
Fingerprint::default_codex_desktop(),
)
}
/// 从连接池、密钥箱和 API key hasher 构造状态。
pub fn with_pool_secret_and_api_key_hasher(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
) -> Self {
Self::with_pool_secret_api_key_hasher_and_fingerprint(
config,
pool,
secret_box,
hasher,
Fingerprint::default_codex_desktop(),
)
}
/// 从连接池、密钥箱、API key hasher 和 installation id 构造状态。
pub fn with_pool_secret_api_key_hasher_and_installation_id(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
installation_id: String,
) -> Self {
Self::with_pool_secret_api_key_hasher_fingerprint_and_installation_id(
config,
pool,
secret_box,
hasher,
Fingerprint::default_codex_desktop(),
Some(installation_id),
)
}
/// 从连接池、密钥箱、API key hasher 和指纹构造状态。
pub fn with_pool_secret_api_key_hasher_and_fingerprint(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
fingerprint: Fingerprint,
) -> Self {
Self::with_pool_secret_api_key_hasher_fingerprint_and_installation_id(
config,
pool,
secret_box,
hasher,
fingerprint,
None,
)
}
/// 从连接池、密钥箱、API key hasher、指纹和 installation id 构造状态。
pub fn with_pool_secret_api_key_hasher_fingerprint_and_installation_id(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
fingerprint: Fingerprint,
installation_id: Option<String>,
) -> Self {
Self::with_pool_secret_api_key_hasher_fingerprint_installation_id_and_local_config_path(
config,
pool,
secret_box,
hasher,
fingerprint,
installation_id,
"local.yaml",
)
}
/// 从连接池、密钥箱、API key hasher 和本地配置路径构造状态。
pub fn with_pool_secret_api_key_hasher_and_local_config_path(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
local_config_path: impl Into<PathBuf>,
) -> Self {
Self::with_pool_secret_api_key_hasher_fingerprint_installation_id_and_local_config_path(
config,
pool,
secret_box,
hasher,
Fingerprint::default_codex_desktop(),
None,
local_config_path,
)
}
/// 从连接池、密钥箱、API key hasher、指纹、installation id 和本地配置路径构造状态。
pub fn with_pool_secret_api_key_hasher_fingerprint_installation_id_and_local_config_path(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
fingerprint: Fingerprint,
installation_id: Option<String>,
local_config_path: impl Into<PathBuf>,
) -> Self {
Self::with_pool_secret_api_key_hasher_and_options(
config,
pool,
secret_box,
hasher,
AppStateServiceOptions {
fingerprint,
installation_id,
local_config_path: local_config_path.into(),
token_refresher: None,
oauth_client: None,
},
)
}
/// 从连接池、密钥箱、API key hasher 和 token refresher 构造状态。
pub fn with_pool_secret_api_key_hasher_and_token_refresher<C>(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
token_refresher: C,
) -> Self
where
C: TokenRefresher,
{
Self::with_pool_secret_api_key_hasher_and_options(
config,
pool,
secret_box,
hasher,
AppStateServiceOptions {
fingerprint: Fingerprint::default_codex_desktop(),
installation_id: None,
local_config_path: PathBuf::from("local.yaml"),
token_refresher: Some(Arc::new(token_refresher)),
oauth_client: None,
},
)
}
/// 从连接池、密钥箱、API key hasher 和 OAuth client 构造状态。
pub fn with_pool_secret_api_key_hasher_and_oauth_client<C>(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
oauth_client: C,
) -> Self
where
C: OAuthClient + TokenRefresher + Clone,
{
let token_refresher: Arc<dyn TokenRefresher> = Arc::new(oauth_client.clone());
let oauth_client: Arc<dyn OAuthClient> = Arc::new(oauth_client);
Self::with_pool_secret_api_key_hasher_and_options(
config,
pool,
secret_box,
hasher,
AppStateServiceOptions {
fingerprint: Fingerprint::default_codex_desktop(),
installation_id: None,
local_config_path: PathBuf::from("local.yaml"),
token_refresher: Some(token_refresher),
oauth_client: Some(oauth_client),
},
)
}
fn with_pool_secret_api_key_hasher_and_options(
config: AppConfig,
pool: SqlitePool,
secret_box: SecretBox,
hasher: ApiKeyHasher,
options: AppStateServiceOptions,
) -> Self {
let AppStateServiceOptions {
fingerprint,
installation_id,
local_config_path,
token_refresher,
oauth_client,
} = options;
let runtime_config: RuntimeConfig = config.clone().into();
let repositories = sqlite_repositories(pool, secret_box, hasher);
let services = match (token_refresher, oauth_client) {
(Some(token_refresher), Some(oauth_client)) => {
Services::with_installation_id_local_config_path_and_oauth_clients(
&config,
repositories,
fingerprint,
installation_id,
local_config_path,
token_refresher,
oauth_client,
)
}
(Some(token_refresher), None) => {
Services::with_installation_id_local_config_path_and_token_refresher(
&config,
repositories,
fingerprint,
installation_id,
local_config_path,
token_refresher,
)
}
(None, Some(oauth_client)) => {
let token_refresher: Arc<dyn TokenRefresher> = Arc::new(
codex_proxy_adapters::oauth::openai::default_openai_oauth_client(
crate::services::oauth_config(&config),
),
);
Services::with_installation_id_local_config_path_and_oauth_clients(
&config,
repositories,
fingerprint,
installation_id,
local_config_path,
token_refresher,
oauth_client,
)
}
(None, None) => Services::with_installation_id_and_local_config_path(
&config,
repositories,
fingerprint,
installation_id,
local_config_path,
),
};
Self {
config: runtime_config,
services,
}
}
/// 从持久化存储恢复未过期的会话亲和性映射。
pub async fn restore_session_affinity_from_repository(
&self,
now: DateTime<Utc>,
) -> Result<usize, RuntimeSessionAffinityError> {
self.services
.session_affinity
.restore_from_repository(now)
.await
}
/// 使用当前时间从持久化存储恢复未过期的会话亲和性映射。
pub async fn restore_session_affinity_from_repository_now(
&self,
) -> Result<usize, RuntimeSessionAffinityError> {
self.restore_session_affinity_from_repository(Utc::now())
.await
}
/// 从持久化存储恢复运行时账号池。
pub async fn restore_account_pool_from_repository(
&self,
) -> Result<usize, RuntimeAccountPoolError> {
self.services.account_pool.restore_from_repository().await
}
}
-177
View File
@@ -1,177 +0,0 @@
//! 后台任务协调器。
use std::path::PathBuf;
use codex_proxy_adapters::oauth::openai::default_openai_oauth_client;
use codex_proxy_core::auth::oauth::{OAuthConfig, RefreshPolicy};
use tokio::task::JoinHandle;
use crate::{config::RuntimeConfig, state::AppState};
use super::{
cookie_cleanup::CookieCleanupTask,
fingerprint_update::{
start_fingerprint_update_task, CODEX_DESKTOP_APPCAST_URL,
DEFAULT_EXTRACTED_FINGERPRINT_PATH,
},
model_refresh::ModelRefreshTask,
quota_refresh::QuotaRefreshTask,
session_affinity_cleanup::SessionAffinityCleanupTask,
session_cleanup::SessionCleanupTask,
token_refresh::TokenRefreshTask,
};
/// 后台任务协调器。
#[derive(Default)]
pub struct BackgroundTaskCoordinator {
handles: Vec<(&'static str, SchedulerHandle)>,
}
impl BackgroundTaskCoordinator {
/// 注册一个后台任务句柄。
pub fn push(&mut self, name: &'static str, handle: SchedulerHandle) {
self.handles.push((name, handle));
}
/// 返回已注册任务名称。
pub fn task_names(&self) -> Vec<&'static str> {
self.handles.iter().map(|(name, _)| *name).collect()
}
/// 关闭所有后台任务。
pub async fn shutdown(self) {
tracing::info!("正在关闭后台任务");
for (name, handle) in self.handles {
handle.shutdown().await;
tracing::info!(task = name, "后台任务已停止");
}
tracing::info!("所有后台任务已停止");
}
}
/// 启动后台任务。
pub async fn start_background_tasks(state: &AppState) -> BackgroundTaskCoordinator {
let mut coordinator = BackgroundTaskCoordinator::default();
let stores = &state.services.background_tasks;
coordinator.push(
"cookie_cleanup",
CookieCleanupTask::new(stores.cookies.clone()).start(),
);
coordinator.push(
"session_cleanup",
SessionCleanupTask::new(
stores.admin_sessions.clone(),
state.config.admin.session_cleanup_interval_secs,
)
.start(),
);
coordinator.push(
"session_affinity_cleanup",
SessionAffinityCleanupTask::new(
stores.session_affinity.clone(),
state.config.admin.session_cleanup_interval_secs,
)
.start(),
);
coordinator.push(
"model_refresh",
ModelRefreshTask::new(
state.services.models.clone(),
state.services.accounts.clone(),
)
.with_installation_id(state.services.installation_id.clone())
.start(),
);
if state.config.auth.refresh_enabled {
coordinator.push(
"token_refresh",
TokenRefreshTask::new(
stores.accounts.clone(),
RefreshPolicy {
refresh_margin_seconds: state.config.auth.refresh_margin_seconds,
refresh_concurrency: state.config.auth.refresh_concurrency,
},
default_openai_oauth_client(oauth_config(&state.config)),
)
.with_refresh_lease_store(stores.refresh_leases.clone())
.start(),
);
}
coordinator.push(
"quota_refresh",
QuotaRefreshTask::with_intervals(
stores.accounts.clone(),
state.services.codex.clone(),
state
.config
.quota
.refresh_interval_minutes
.saturating_mul(60)
.max(1),
30 * 60,
)
.with_installation_id(state.services.installation_id.clone())
.start(),
);
coordinator.push(
"fingerprint_update",
start_fingerprint_update_task(
Some(stores.fingerprints.clone()),
CODEX_DESKTOP_APPCAST_URL.to_string(),
PathBuf::from(DEFAULT_EXTRACTED_FINGERPRINT_PATH),
state.services.fingerprint.app_version.clone(),
state.services.fingerprint.build_number.clone(),
),
);
coordinator
}
fn oauth_config(config: &RuntimeConfig) -> OAuthConfig {
OAuthConfig {
client_id: config.auth.oauth_client_id.clone(),
auth_endpoint: config.auth.oauth_auth_endpoint.clone(),
device_code_endpoint: oauth_device_code_endpoint(&config.auth.oauth_token_endpoint),
token_endpoint: config.auth.oauth_token_endpoint.clone(),
}
}
fn oauth_device_code_endpoint(token_endpoint: &str) -> String {
token_endpoint
.strip_suffix("/token")
.map(|prefix| format!("{prefix}/device/code"))
.unwrap_or_else(|| "https://auth.openai.com/oauth/device/code".to_string())
}
/// 后台任务关闭句柄。
pub enum SchedulerHandle {
/// 通过 channel 发送关闭信号。
Channel(tokio::sync::mpsc::Sender<()>),
/// 直接持有 tokio 任务句柄。
JoinHandle(JoinHandle<()>),
}
impl SchedulerHandle {
/// 使用关闭 channel 构造句柄。
pub fn new(shutdown_tx: tokio::sync::mpsc::Sender<()>) -> Self {
Self::Channel(shutdown_tx)
}
/// 使用 `JoinHandle` 构造句柄。
pub fn from_join_handle(handle: JoinHandle<()>) -> Self {
Self::JoinHandle(handle)
}
/// 关闭任务。
pub async fn shutdown(self) {
match self {
Self::Channel(shutdown_tx) => {
let _ = shutdown_tx.send(()).await;
}
Self::JoinHandle(handle) => {
handle.abort();
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
//! 上游适配器组装。
use std::{sync::Arc, time::Duration};
use codex_proxy_adapters::codex::{
client::CodexBackendClient,
websocket::pool::{CodexWebSocketPool, CodexWebSocketPoolConfig as AdapterWebSocketPoolConfig},
};
use codex_proxy_core::gateway::{fingerprint::Fingerprint, ports::CodexModelCatalogClient};
use codex_proxy_platform::config::WebSocketPoolConfig;
/// 构造 Codex 模型目录客户端。
pub fn model_catalog_client(
base_url: String,
fingerprint: Fingerprint,
) -> Arc<dyn CodexModelCatalogClient> {
Arc::new(CodexBackendClient::new(
reqwest::Client::new(),
base_url,
fingerprint,
))
}
/// 构造 Codex 后端请求客户端。
pub fn codex_backend_client(
base_url: String,
fingerprint: Fingerprint,
ws_pool: &WebSocketPoolConfig,
) -> Arc<CodexBackendClient> {
let client = CodexBackendClient::new(reqwest::Client::new(), base_url, fingerprint);
if ws_pool.enabled {
Arc::new(
client.with_websocket_pool(Arc::new(CodexWebSocketPool::with_config(
adapter_websocket_pool_config(ws_pool),
))),
)
} else {
Arc::new(client)
}
}
fn adapter_websocket_pool_config(config: &WebSocketPoolConfig) -> AdapterWebSocketPoolConfig {
AdapterWebSocketPoolConfig {
enabled: config.enabled,
max_age: Duration::from_millis(config.max_age_ms),
max_per_account: config.max_per_account,
..AdapterWebSocketPoolConfig::default()
}
}
@@ -1,127 +0,0 @@
use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use codex_proxy_platform::{
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
UsageStatsConfig, WebSocketPoolConfig,
},
crypto::SecretBox,
storage::connect_sqlite,
};
use codex_proxy_runtime::state::AppState;
use secrecy::SecretString;
#[tokio::test]
async fn app_state_should_restore_active_accounts_into_runtime_pool() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("runtime-account-pool.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.expect("sqlite pool");
let secret_box = SecretBox::new([34u8; 32]);
insert_account(&pool, &secret_box, "acct_pool", "active").await;
let state = AppState::with_pool_and_secret_box(test_config(url), pool, secret_box);
let restored = state
.restore_account_pool_from_repository()
.await
.expect("account pool should restore");
let acquired = state
.services
.account_pool
.acquire(
"gpt-5.5",
Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap(),
)
.await
.expect("active account should be acquired");
assert_eq!(restored, 1);
assert_eq!(acquired.account.id, "acct_pool");
}
async fn insert_account(pool: &sqlx::SqlitePool, secret_box: &SecretBox, id: &str, status: &str) {
let access_token = secret_box
.encrypt(&SecretString::new(format!("access-{id}").into()))
.expect("access token should encrypt");
sqlx::query(
"insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token_cipher, access_token_expires_at, status, added_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(id)
.bind(format!("{id}@example.com"))
.bind(format!("chatgpt-{id}"))
.bind(format!("user-{id}"))
.bind(access_token)
.bind("2026-06-18T13:00:00Z")
.bind(status)
.bind("2026-06-18T00:00:00Z")
.bind("2026-06-18T00:00:00Z")
.execute(pool)
.await
.expect("account should be inserted");
}
fn test_config(database_url: String) -> AppConfig {
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://example.invalid".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
},
auth: AuthConfig {
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_id".to_string(),
oauth_auth_endpoint: "https://auth.invalid".to_string(),
oauth_token_endpoint: "https://token.invalid".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
ws_pool: WebSocketPoolConfig::default(),
fingerprint: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: 3600,
default_username: "admin".to_string(),
default_password: "admin".to_string(),
},
logging: LoggingConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
-203
View File
@@ -1,203 +0,0 @@
use codex_proxy_adapters::codex::fingerprint::FingerprintRepository;
use codex_proxy_core::models::model::{BackendModelEntry, ModelPlanSnapshot};
use codex_proxy_platform::storage::connect_sqlite;
use std::collections::BTreeMap;
use codex_proxy_platform::config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig, UsageStatsConfig,
};
use codex_proxy_platform::crypto::SecretBox;
#[tokio::test]
async fn runtime_should_seed_and_load_current_fingerprint() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("fingerprints.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let repo = FingerprintRepository::new(pool);
let default_fingerprint =
codex_proxy_core::gateway::fingerprint::Fingerprint::default_for_tests();
let stored =
codex_proxy_runtime::bootstrap::load_runtime_fingerprint(&repo, &default_fingerprint)
.await
.expect("load runtime fingerprint");
assert_eq!(stored.app_version, default_fingerprint.app_version);
assert_eq!(stored.default_headers, default_fingerprint.default_headers);
}
#[tokio::test]
async fn runtime_should_build_state_with_model_service() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("runtime-state.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://example.invalid".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
},
auth: AuthConfig {
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_id".to_string(),
oauth_auth_endpoint: "https://auth.invalid".to_string(),
oauth_token_endpoint: "https://token.invalid".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig {
url: format!("sqlite://{}", db.display()),
},
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
fingerprint: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: 3600,
default_username: "admin".to_string(),
default_password: "admin".to_string(),
},
logging: LoggingConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
},
pool,
SecretBox::new([42u8; 32]),
);
let catalog = state.services.models.catalog().await;
assert_eq!(catalog.models().len(), 1);
}
#[tokio::test]
async fn runtime_state_should_expose_backend_model_snapshot_through_model_service() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("runtime-models.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let config = AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://example.invalid".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: BTreeMap::new(),
},
auth: AuthConfig {
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_id".to_string(),
oauth_auth_endpoint: "https://auth.invalid".to_string(),
oauth_token_endpoint: "https://token.invalid".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig {
url: format!("sqlite://{}", db.display()),
},
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
ws_pool: Default::default(),
fingerprint: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: 3600,
default_username: "admin".to_string(),
default_password: "admin".to_string(),
},
logging: LoggingConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
};
let snapshot_store =
codex_proxy_adapters::sqlite::models::ModelSnapshotRepository::new(pool.clone());
snapshot_store
.replace_plan_snapshot(&ModelPlanSnapshot::from_backend_entries(
"plus",
vec![BackendModelEntry {
id: Some("gpt-6".to_string()),
name: Some("GPT-6".to_string()),
..BackendModelEntry::default()
}],
))
.await
.expect("replace snapshot");
let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box(
config,
pool,
SecretBox::new([43u8; 32]),
);
let catalog = state.services.models.catalog().await;
assert_eq!(catalog.models()[0].id, "gpt-6");
}
-133
View File
@@ -1,133 +0,0 @@
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_adapters::sqlite::session_affinity::SqliteSessionAffinityStore;
use codex_proxy_core::serving::affinity::SessionAffinityEntry;
use codex_proxy_platform::{
config::{
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
UsageStatsConfig, WebSocketPoolConfig,
},
crypto::SecretBox,
storage::connect_sqlite,
};
use codex_proxy_runtime::state::AppState;
#[tokio::test]
async fn app_state_should_restore_session_affinity_from_sqlite() {
let dir = tempfile::tempdir().expect("tempdir");
let db = dir.path().join("runtime-session-affinity.sqlite");
let url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&url).await.expect("sqlite pool");
let store = SqliteSessionAffinityStore::new(pool.clone());
let now = Utc.with_ymd_and_hms(2026, 6, 18, 12, 0, 0).unwrap();
insert_account(&pool, "acct_restore").await;
store
.upsert(
"resp_restore",
&SessionAffinityEntry {
account_id: "acct_restore".to_string(),
conversation_id: "conv_restore".to_string(),
turn_state: Some("turn_restore".to_string()),
instructions_hash: Some("hash_restore".to_string()),
input_tokens: Some(7),
function_call_ids: vec!["call_restore".to_string()],
variant_hash: Some("variant_restore".to_string()),
created_at: now,
},
Duration::hours(4),
)
.await
.expect("affinity should be stored");
let state =
AppState::with_pool_and_secret_box(test_config(url), pool, SecretBox::new([33u8; 32]));
let restored = state
.restore_session_affinity_from_repository(now + Duration::minutes(1))
.await
.expect("session affinity should restore");
assert_eq!(restored, 1);
assert_eq!(
state
.services
.session_affinity
.lookup_account("resp_restore", now + Duration::minutes(1))
.await,
Some("acct_restore".to_string())
);
}
async fn insert_account(pool: &sqlx::SqlitePool, id: &str) {
sqlx::query(
"insert into accounts (id, access_token_cipher, status, added_at, updated_at) values (?, 'cipher', 'active', '2026-06-18T12:00:00Z', '2026-06-18T12:00:00Z')",
)
.bind(id)
.execute(pool)
.await
.expect("account should be inserted");
}
fn test_config(database_url: String) -> AppConfig {
AppConfig {
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 0,
},
api: ApiConfig {
base_url: "https://example.invalid".to_string(),
},
model: ModelConfig {
default_model: "gpt-5.5".to_string(),
default_reasoning_effort: None,
service_tier: None,
aliases: Default::default(),
},
auth: AuthConfig {
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_id".to_string(),
oauth_auth_endpoint: "https://auth.invalid".to_string(),
oauth_token_endpoint: "https://token.invalid".to_string(),
},
quota: QuotaConfig {
refresh_interval_minutes: 5,
warning_thresholds: QuotaWarningThresholds {
primary: vec![80, 90],
secondary: vec![80, 90],
},
skip_exhausted: true,
},
usage_stats: UsageStatsConfig {
history_retention_days: None,
},
database: DatabaseConfig { url: database_url },
security: SecurityConfig {
master_key_file: "data/master.key".to_string(),
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
},
tls: TlsConfig {
force_http11: false,
},
ws_pool: WebSocketPoolConfig::default(),
fingerprint: Default::default(),
admin: AdminConfig {
session_ttl_minutes: 1440,
session_cleanup_interval_secs: 3600,
default_username: "admin".to_string(),
default_password: "admin".to_string(),
},
logging: LoggingConfig {
directory: "logs".to_string(),
retention_days: 14,
enabled: false,
capacity: 2_000,
capture_body: false,
},
}
}
-32
View File
@@ -1,32 +0,0 @@
use super::*;
#[tokio::test]
async fn start_background_tasks_should_register_migrated_runtime_tasks() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("background-tasks.sqlite");
let database_url = format!("sqlite://{}", db.display());
let pool = connect_sqlite(&database_url).await.expect("sqlite pool");
let state = codex_proxy_runtime::state::AppState::with_pool_and_secret_box(
test_config(database_url),
pool,
SecretBox::new([9u8; 32]),
);
let coordinator = codex_proxy_runtime::tasks::coordinator::start_background_tasks(&state).await;
let task_names = coordinator.task_names();
assert_eq!(
task_names,
[
"cookie_cleanup",
"session_cleanup",
"session_affinity_cleanup",
"model_refresh",
"token_refresh",
"quota_refresh",
"fingerprint_update"
]
);
coordinator.shutdown().await;
}
-39
View File
@@ -1,39 +0,0 @@
[package]
name = "codex-proxy-server"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
axum.workspace = true
codex-proxy-assets = { path = "../assets" }
codex-proxy-core = { path = "../core" }
codex-proxy-platform = { path = "../platform" }
codex-proxy-runtime = { path = "../runtime" }
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tower-http.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
codex-proxy-adapters = { path = "../adapters" }
futures.workspace = true
secrecy.workspace = true
serde_yml.workspace = true
sqlx.workspace = true
tempfile = "3.27.0"
tokio-tungstenite.workspace = true
tower.workspace = true
tracing-subscriber.workspace = true
wiremock = "0.6.5"
[lints]
workspace = true
@@ -1,155 +0,0 @@
//! 账号 Cookie 处理器。
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use codex_proxy_runtime::state::AppState;
use crate::{
admin_api::{
accounts::{account_error, account_not_found},
require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 设置账号 Cookie 请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetAccountCookiesRequest {
/// Cookie 请求头字符串,或 name/value 对象。
pub cookies: Value,
}
/// 账号 Cookie 响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountCookiesData {
/// 标准 HTTP Cookie 请求头。
pub cookies: Option<String>,
}
/// 删除账号 Cookie 响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteAccountCookiesData {
/// 是否完成删除。
pub deleted: bool,
}
/// `GET /api/admin/accounts/{account_id}/cookies`
pub async fn get_account_cookies(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.admin_accounts.cookies(&account_id).await {
Ok(cookies) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountCookiesData { cookies }, request_id),
)),
Err(codex_proxy_runtime::services::AdminAccountError::NotFound) => {
Err(account_not_found(request_id))
}
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/{account_id}/cookies`
pub async fn set_account_cookies(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
Json(payload): Json<SetAccountCookiesRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
let cookie_header = admin_cookie_header(&payload.cookies).map_err(|message| {
AdminError::new(StatusCode::BAD_REQUEST, 40001, message, request_id.clone())
})?;
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.set_cookies(&account_id, &cookie_header)
.await
{
Ok(cookies) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountCookiesData { cookies }, request_id),
)),
Err(codex_proxy_runtime::services::AdminAccountError::NotFound) => {
Err(account_not_found(request_id))
}
Err(error) => Err(account_error(error, request_id)),
}
}
/// `DELETE /api/admin/accounts/{account_id}/cookies`
pub async fn delete_account_cookies(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.delete_cookies(&account_id)
.await
{
Ok(()) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DeleteAccountCookiesData { deleted: true }, request_id),
)),
Err(codex_proxy_runtime::services::AdminAccountError::NotFound) => {
Err(account_not_found(request_id))
}
Err(error) => Err(account_error(error, request_id)),
}
}
fn admin_cookie_header(value: &Value) -> Result<String, &'static str> {
if let Some(cookies) = value.as_str() {
let cookies = cookies.trim();
if cookies.is_empty() {
return Err("cookies field is required");
}
return Ok(cookies.to_string());
}
let Some(object) = value.as_object() else {
return Err("cookies must be a string or object");
};
if object.is_empty() {
return Err("cookies field is required");
}
let pairs = object
.iter()
.filter_map(|(name, value)| {
let value = value.as_str()?.trim();
(!name.trim().is_empty() && !value.is_empty())
.then(|| format!("{}={value}", name.trim()))
})
.collect::<Vec<_>>();
if pairs.is_empty() {
Err("No valid cookies found")
} else {
Ok(pairs.join("; "))
}
}
@@ -1,52 +0,0 @@
//! 账号创建处理器。
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use codex_proxy_runtime::state::AppState;
use serde::Deserialize;
use crate::{
admin_api::{
accounts::{account_error, AdminAccountData},
require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 手动创建账号请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountRequest {
/// access token,允许带 Bearer 前缀。
pub token: Option<String>,
/// refresh token。
pub refresh_token: Option<String>,
}
/// `POST /api/admin/accounts`
pub async fn create_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<CreateAccountRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.create(payload.token, payload.refresh_token)
.await
{
Ok(account) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminAccountData::from(account), request_id),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
@@ -1,119 +0,0 @@
//! 账号导出处理器。
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use codex_proxy_runtime::{services::AdminStoredAccount, state::AppState};
use serde::{Deserialize, Serialize};
use crate::{
admin_api::{
accounts::{account_error, account_status_value},
require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 账号导出查询。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountExportQuery {
/// 逗号分隔的账号 ID 列表;空表示导出全部。
pub ids: Option<String>,
/// 导出格式;当前仅支持 `native`。
pub format: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountExportData {
source_format: &'static str,
accounts: Vec<AccountExportEntry>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountExportEntry {
id: String,
email: Option<String>,
account_id: Option<String>,
user_id: Option<String>,
label: Option<String>,
plan_type: Option<String>,
token: String,
refresh_token: Option<String>,
status: String,
access_token_expires_at: Option<String>,
added_at: String,
updated_at: String,
}
/// `GET /api/admin/accounts/export`
pub async fn export_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
validate_account_export_format(query.format.as_deref()).map_err(|message| {
AdminError::new(StatusCode::BAD_REQUEST, 40001, message, request_id.clone())
})?;
let ids = account_export_ids(query.ids.as_deref());
match state.services.admin_accounts.export(ids).await {
Ok(accounts) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AccountExportData {
source_format: "native",
accounts: accounts.into_iter().map(AccountExportEntry::from).collect(),
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
fn validate_account_export_format(value: Option<&str>) -> Result<(), &'static str> {
match value.unwrap_or("native").trim() {
"" | "native" => Ok(()),
_ => Err("Unsupported account export format"),
}
}
fn account_export_ids(value: Option<&str>) -> Vec<String> {
value
.into_iter()
.flat_map(|ids| ids.split(','))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect()
}
impl From<AdminStoredAccount> for AccountExportEntry {
fn from(account: AdminStoredAccount) -> Self {
Self {
id: account.id,
email: account.email,
account_id: account.account_id,
user_id: account.user_id,
label: account.label,
plan_type: account.plan_type,
token: account.access_token,
refresh_token: account.refresh_token,
status: account_status_value(account.status).to_string(),
access_token_expires_at: account
.access_token_expires_at
.map(|value| value.to_rfc3339()),
added_at: account.added_at.to_rfc3339(),
updated_at: account.updated_at.to_rfc3339(),
}
}
}
@@ -1,202 +0,0 @@
//! 账号健康检查处理器。
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use codex_proxy_runtime::{
services::{AdminAccountProbeOutcome, AdminAccountProbeResult},
state::AppState,
};
use serde::{Deserialize, Serialize};
use crate::{
admin_api::{
accounts::{account_error, account_status_value},
require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 健康检查请求。
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HealthCheckRequest {
/// 指定账号 ID;为空时检查全部账号。
pub ids: Option<Vec<String>>,
/// 每个探测启动间隔毫秒。
pub stagger_ms: Option<u64>,
/// 并发数量。
pub concurrency: Option<u8>,
}
/// 健康检查响应。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckData {
/// 汇总。
pub summary: HealthCheckSummary,
/// 逐账号结果。
pub results: Vec<AccountProbeData>,
}
/// 健康检查汇总。
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthCheckSummary {
/// 总数。
pub total: usize,
/// 可用数。
pub alive: usize,
/// 不可用数。
pub dead: usize,
/// 跳过数。
pub skipped: usize,
}
/// 单账号探测结果。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProbeData {
/// 账号 ID。
pub id: String,
/// 邮箱。
pub email: Option<String>,
/// 探测前状态。
pub previous_status: String,
/// 探测结果。
pub result: String,
/// 探测后状态。
pub status: Option<String>,
/// 错误信息。
pub error: Option<String>,
/// 耗时毫秒。
pub duration_ms: Option<u128>,
}
/// `POST /api/admin/accounts/health-check`
pub async fn health_check_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
let payload = parse_health_check_request(&body, &request_id)?;
validate_health_check_request(&payload, &request_id)?;
require_admin_session(&state, &headers, &request_id).await?;
let concurrency = usize::from(payload.concurrency.unwrap_or(2));
let stagger_ms = payload.stagger_ms.unwrap_or(3_000);
match state
.services
.admin_accounts
.health_check_accounts(payload.ids, concurrency, stagger_ms, &request_id)
.await
{
Ok(results) => {
let results = results
.into_iter()
.map(AccountProbeData::from)
.collect::<Vec<_>>();
let summary = HealthCheckSummary {
total: results.len(),
alive: results
.iter()
.filter(|result| result.result == "alive")
.count(),
dead: results
.iter()
.filter(|result| result.result == "dead")
.count(),
skipped: results
.iter()
.filter(|result| result.result == "skipped")
.count(),
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(HealthCheckData { summary, results }, request_id),
))
}
Err(error) => Err(account_error(error, request_id)),
}
}
fn parse_health_check_request(
body: &Bytes,
request_id: &str,
) -> Result<HealthCheckRequest, AdminError> {
if body.is_empty() {
return Ok(HealthCheckRequest::default());
}
serde_json::from_slice(body).map_err(|_| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Invalid health check request",
request_id,
)
})
}
fn validate_health_check_request(
payload: &HealthCheckRequest,
request_id: &str,
) -> Result<(), AdminError> {
if payload.ids.as_ref().is_some_and(Vec::is_empty) {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Account ids must not be empty",
request_id,
));
}
if payload
.stagger_ms
.is_some_and(|value| !(500..=30_000).contains(&value))
{
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"staggerMs must be between 500 and 30000",
request_id,
));
}
if payload
.concurrency
.is_some_and(|value| !(1..=10).contains(&value))
{
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"concurrency must be between 1 and 10",
request_id,
));
}
Ok(())
}
impl From<AdminAccountProbeResult> for AccountProbeData {
fn from(result: AdminAccountProbeResult) -> Self {
Self {
id: result.id,
email: result.email,
previous_status: account_status_value(result.previous_status).to_string(),
result: probe_outcome_value(result.outcome).to_string(),
status: result
.status
.map(account_status_value)
.map(ToString::to_string),
error: result.error,
duration_ms: result.duration_ms,
}
}
}
fn probe_outcome_value(outcome: AdminAccountProbeOutcome) -> &'static str {
outcome.as_str()
}
@@ -1,52 +0,0 @@
//! 账号导入处理器。
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use codex_proxy_runtime::state::AppState;
use serde::Serialize;
use serde_json::Value;
use crate::{
admin_api::{
accounts::account_error, require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountImportData {
imported: u32,
skipped: u32,
source_format: String,
}
/// `POST /api/admin/accounts/import`
pub async fn import_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<Value>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.admin_accounts.import(&payload).await {
Ok(imported) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AccountImportData {
imported: imported.imported,
skipped: imported.skipped,
source_format: imported.source_format.to_string(),
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
@@ -1,75 +0,0 @@
//! Codex CLI 账号导入处理器。
use std::path::{Path, PathBuf};
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use codex_proxy_runtime::{services::AdminAccountError, state::AppState};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
admin_api::{
accounts::account_error, require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportCliRequest {
codex_home: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct AccountImportCliData {
imported: u32,
skipped: u32,
source_format: &'static str,
}
/// `POST /api/admin/accounts/import-cli`
pub async fn import_cli_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<ImportCliRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let auth_path = PathBuf::from(payload.codex_home).join("auth.json");
let auth_payload =
read_auth_json(&auth_path).map_err(|error| account_error(error, request_id.clone()))?;
match state
.services
.admin_accounts
.import_codex_cli_auth(&auth_payload)
.await
{
Ok(_) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
AccountImportCliData {
imported: 1,
skipped: 0,
source_format: "codex_cli",
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
fn read_auth_json(path: &Path) -> Result<Value, AdminAccountError> {
let content =
std::fs::read_to_string(path).map_err(|_| AdminAccountError::NoImportableAccounts)?;
serde_json::from_str(&content).map_err(|_| AdminAccountError::NoImportableAccounts)
}
@@ -1,336 +0,0 @@
//! 账号生命周期处理器。
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use codex_proxy_runtime::{
services::{AdminAccountProbeOutcome, AdminAccountRefresh},
state::AppState,
};
use crate::{
admin_api::{
accounts::{account_error, account_not_found, account_status_value},
require_admin_session, AdminEnvelope, AdminError, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 更新账号标签请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountLabelRequest {
/// 新标签;`null` 表示清空。
pub label: Option<String>,
}
/// 更新账号标签响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountLabelData {
/// 账号 ID。
pub id: String,
/// 新标签。
pub label: Option<String>,
}
/// 更新账号状态请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountStatusRequest {
/// 新状态。
pub status: String,
}
/// 更新账号状态响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountStatusData {
/// 账号 ID。
pub id: String,
/// 新状态。
pub status: String,
}
/// 批量删除账号请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteAccountsRequest {
/// 账号 ID 列表。
pub ids: Vec<String>,
}
/// 批量删除账号响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchDeleteAccountsData {
/// 成功删除数量。
pub deleted: u32,
/// 未找到的账号 ID。
pub not_found: Vec<String>,
}
/// 批量更新账号状态请求。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchUpdateAccountStatusRequest {
/// 账号 ID 列表。
pub ids: Vec<String>,
/// 新状态。
pub status: String,
}
/// 批量更新账号状态响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchUpdateAccountStatusData {
/// 成功更新数量。
pub updated: u32,
/// 未找到的账号 ID。
pub not_found: Vec<String>,
}
/// 删除账号响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteAccountData {
/// 是否已删除。
pub deleted: bool,
}
/// 刷新账号响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshAccountData {
/// 账号 ID。
pub id: String,
/// 刷新结果。
pub result: String,
/// 刷新前状态。
pub previous_status: String,
/// 刷新后状态。
pub status: Option<String>,
/// 错误信息。
pub error: Option<String>,
}
/// 重置账号用量响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResetAccountUsageData {
/// 账号 ID。
pub id: String,
/// 是否已处理。
pub reset: bool,
}
/// `PATCH /api/admin/accounts/{account_id}/label`
pub async fn update_account_label(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
Json(payload): Json<UpdateAccountLabelRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let label = payload.label;
match state
.services
.admin_accounts
.update_label(&account_id, label.clone())
.await
{
Ok(true) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
UpdateAccountLabelData {
id: account_id,
label,
},
request_id,
),
)),
Ok(false) => Err(account_not_found(request_id)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `PATCH /api/admin/accounts/{account_id}/status`
pub async fn update_account_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
Json(payload): Json<UpdateAccountStatusRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.update_status(&account_id, &payload.status)
.await
{
Ok(Some(updated)) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
UpdateAccountStatusData {
id: updated.id,
status: account_status_value(updated.status).to_string(),
},
request_id,
),
)),
Ok(None) => Err(account_not_found(request_id)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `DELETE /api/admin/accounts/{account_id}`
pub async fn delete_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.admin_accounts.delete(&account_id).await {
Ok(true) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(DeleteAccountData { deleted: true }, request_id),
)),
Ok(false) => Err(account_not_found(request_id)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/batch-delete`
pub async fn batch_delete_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<BatchDeleteAccountsRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.batch_delete(payload.ids)
.await
{
Ok(deleted) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
BatchDeleteAccountsData {
deleted: deleted.deleted,
not_found: deleted.not_found,
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/batch-status`
pub async fn batch_update_account_status(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Json(payload): Json<BatchUpdateAccountStatusRequest>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.batch_update_status(payload.ids, &payload.status)
.await
{
Ok(updated) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
BatchUpdateAccountStatusData {
updated: updated.updated,
not_found: updated.not_found,
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/{account_id}/refresh`
pub async fn refresh_account(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state
.services
.admin_accounts
.refresh_account(&account_id)
.await
{
Ok(refreshed) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(RefreshAccountData::from(refreshed), request_id),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
/// `POST /api/admin/accounts/{account_id}/reset-usage`
pub async fn reset_account_usage(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
match state.services.admin_accounts.reset_usage(&account_id).await {
Ok(reset) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(
ResetAccountUsageData {
id: reset.id,
reset: reset.reset,
},
request_id,
),
)),
Err(error) => Err(account_error(error, request_id)),
}
}
impl From<AdminAccountRefresh> for RefreshAccountData {
fn from(refreshed: AdminAccountRefresh) -> Self {
Self {
id: refreshed.id,
result: refresh_outcome_value(refreshed.outcome).to_string(),
previous_status: account_status_value(refreshed.previous_status).to_string(),
status: refreshed
.status
.map(account_status_value)
.map(ToString::to_string),
error: refreshed.error,
}
}
}
fn refresh_outcome_value(outcome: AdminAccountProbeOutcome) -> &'static str {
outcome.as_str()
}
@@ -1,60 +0,0 @@
//! 账号列表处理器。
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use codex_proxy_platform::json::{clamp_limit, Page};
use codex_proxy_runtime::state::AppState;
use serde::Deserialize;
use crate::{
admin_api::{
accounts::{account_error, AdminAccountData},
require_admin_session, AdminError, AdminPageEnvelope, AdminResponse,
},
middleware::request_id::RequestId,
};
/// 账号列表查询。
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountsQuery {
/// 分页游标。
pub cursor: Option<String>,
/// 分页大小。
pub limit: Option<u32>,
}
/// 分页列出账号元数据。
pub async fn accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let limit = clamp_limit(query.limit.unwrap_or(50));
match state
.services
.admin_accounts
.list(query.cursor, limit)
.await
{
Ok(page) => {
let page = Page {
items: page.items.into_iter().map(AdminAccountData::from).collect(),
next_cursor: page.next_cursor,
};
Ok(AdminResponse::new(
StatusCode::OK,
AdminPageEnvelope::ok(page, limit, request_id),
))
}
Err(error) => Err(account_error(error, request_id)),
}
}

Some files were not shown because too many files have changed in this diff Show More