mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
refactor: migrate workspace to monolith layout
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Generated
+16
-121
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
#![deny(missing_docs)]
|
||||
|
||||
//! 适配器层,承载具体存储、HTTP、OAuth 和 WebSocket 实现。
|
||||
|
||||
pub mod codex;
|
||||
pub mod oauth;
|
||||
pub mod sqlite;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
//! 前端静态资源层,提供 SPA 回退和静态文件服务辅助。
|
||||
|
||||
pub mod headers;
|
||||
pub mod router;
|
||||
@@ -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}`"
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! 管理员领域用例和端口。
|
||||
|
||||
pub mod auth;
|
||||
pub mod client_keys;
|
||||
pub mod ports;
|
||||
pub mod settings;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 认证领域中的纯逻辑与端口。
|
||||
|
||||
/// OAuth token 刷新策略。
|
||||
pub mod oauth;
|
||||
/// 认证上游端口定义。
|
||||
pub mod ports;
|
||||
/// 管理员会话领域逻辑。
|
||||
pub mod session;
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 事件日志领域。
|
||||
|
||||
/// 事件日志模型。
|
||||
pub mod model;
|
||||
/// 事件日志端口。
|
||||
pub mod ports;
|
||||
/// 事件日志策略服务。
|
||||
pub mod service;
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! 上游网关领域中的纯逻辑辅助。
|
||||
|
||||
/// 会话与缓存键派生。
|
||||
pub mod conversation;
|
||||
/// 上游指纹。
|
||||
pub mod fingerprint;
|
||||
/// 安装 ID 生成。
|
||||
pub mod installation;
|
||||
/// 上游网关端口。
|
||||
pub mod ports;
|
||||
@@ -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;
|
||||
@@ -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(¤t).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())
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! 模型目录领域。
|
||||
|
||||
/// 模型目录聚合与纯解析规则。
|
||||
pub mod catalog;
|
||||
/// 模型目录配置与纯数据类型。
|
||||
pub mod model;
|
||||
/// 模型快照存储端口。
|
||||
pub mod ports;
|
||||
/// 模型目录服务用例。
|
||||
pub mod service;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
//! 协议语义与纯数据类型。
|
||||
|
||||
pub mod codex;
|
||||
pub mod openai;
|
||||
@@ -1,5 +0,0 @@
|
||||
//! OpenAI 协议输入类型与纯转换逻辑。
|
||||
|
||||
pub mod chat;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//! 用量聚合领域。
|
||||
|
||||
/// 用量模型。
|
||||
pub mod model;
|
||||
/// 用量存储端口。
|
||||
pub mod ports;
|
||||
/// 用量聚合服务。
|
||||
pub mod service;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
//! 日志初始化与文件轮转。
|
||||
|
||||
mod rotation;
|
||||
|
||||
pub use rotation::{build_file_appender, init_tracing, LogError, LogGuard, RotationConfig};
|
||||
@@ -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};
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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, "a_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>("a_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, "a).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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user