Compare commits

..
6 Commits
Author SHA1 Message Date
zyycnandCodex 28f2879a05 chore: bump version to 2.5.2
Co-authored-by: Codex <noreply@openai.com>
2026-07-19 20:40:47 +08:00
zyycnandCodex 2474259c22 fix(auth): synchronize refreshed tokens with account pool
Co-authored-by: Codex <noreply@openai.com>
2026-07-19 20:30:43 +08:00
ZyyandCodex 79c2c5948f fix(backend): skip drain for self-restart
Route in-process update restarts through an explicit shutdown action so the runtime releases the listener and logging guard before exec, without waiting for the single instance's graceful drain.

Co-authored-by: Codex <noreply@openai.com>
2026-07-17 18:54:33 +08:00
ZyyandCodex 3c4bd8757f chore: bump version to 2.5.1
Co-authored-by: Codex <noreply@openai.com>
2026-07-17 17:38:38 +08:00
ZyyandCodex 06f3443e56 docs(architecture): define multi-provider gateway target
Document the provider-neutral gateway boundary and its terminal PostgreSQL and Redis data model, including configuration, credentials, routing, attempts, continuation, telemetry, retention, and migration contracts.

Co-authored-by: Codex <noreply@openai.com>
2026-07-17 17:38:11 +08:00
Zyy 88fd21f169 fix(frontend): restore table scroll and loading states 2026-07-17 16:23:35 +08:00
14 changed files with 1902 additions and 92 deletions
+52 -42
View File
@@ -321,7 +321,8 @@ impl Services {
refresh_policy.clone(),
token_client.clone(),
)
.with_refresh_lease_store(stores.refresh_leases.clone()),
.with_refresh_lease_store(stores.refresh_leases.clone())
.with_account_pool_sync(account_pool.clone()),
);
let upstream_client: Arc<dyn ModelCatalogSource> = codex.clone();
let store: Arc<dyn ModelSnapshotStore> = Arc::new(stores.model_snapshots.clone());
@@ -546,6 +547,7 @@ pub async fn run(config: BootstrapConfig) -> Result<(), Box<dyn Error + Send + S
let listener = tokio::net::TcpListener::bind((host.as_str(), port)).await?;
let (router, task_coordinator, connection_drain) = build_router(config).await?;
let mut task_coordinator = Some(task_coordinator);
let mut restart_path = None;
let serve_result = {
let (graceful_shutdown_tx, graceful_shutdown_rx) = tokio::sync::oneshot::channel();
@@ -560,46 +562,63 @@ pub async fn run(config: BootstrapConfig) -> Result<(), Box<dyn Error + Send + S
tokio::pin!(serve);
tokio::select! {
result = &mut serve => result,
() = crate::bootstrap::shutdown::shutdown_signal() => {
let _ = graceful_shutdown_tx.send(());
let active_websocket_connections = connection_drain.begin_shutdown();
tracing::info!(
active_websocket_connections,
"正在排空入站连接并关闭运行时任务"
);
let coordinator = task_coordinator.take();
let connection_drain = connection_drain.clone();
let runtime_shutdown = async move {
let shutdown_tasks = async move {
if let Some(coordinator) = coordinator {
coordinator.shutdown().await;
}
};
tokio::join!(shutdown_tasks, connection_drain.wait());
};
let drain_runtime = async {
let (serve_result, ()) = tokio::join!(&mut serve, runtime_shutdown);
serve_result
};
tokio::pin!(drain_runtime);
tokio::select! {
result = &mut drain_runtime => result,
() = tokio::time::sleep(HTTP_DRAIN_TIMEOUT) => {
tracing::warn!(
timeout_secs = HTTP_DRAIN_TIMEOUT.as_secs(),
"HTTP graceful shutdown timed out; cancelling remaining connections"
);
action = crate::bootstrap::shutdown::shutdown_signal() => {
match action {
crate::bootstrap::shutdown::ShutdownAction::Restart(executable_path) => {
restart_path = Some(executable_path);
Ok(())
}
() = crate::bootstrap::shutdown::shutdown_signal() => {
tracing::warn!("Received a second shutdown signal; cancelling remaining connections");
Ok(())
crate::bootstrap::shutdown::ShutdownAction::Graceful => {
let _ = graceful_shutdown_tx.send(());
let active_websocket_connections = connection_drain.begin_shutdown();
tracing::info!(
active_websocket_connections,
"正在排空入站连接并关闭运行时任务"
);
let coordinator = task_coordinator.take();
let connection_drain = connection_drain.clone();
let runtime_shutdown = async move {
let shutdown_tasks = async move {
if let Some(coordinator) = coordinator {
coordinator.shutdown().await;
}
};
tokio::join!(shutdown_tasks, connection_drain.wait());
};
let drain_runtime = async {
let (serve_result, ()) = tokio::join!(&mut serve, runtime_shutdown);
serve_result
};
tokio::pin!(drain_runtime);
tokio::select! {
result = &mut drain_runtime => result,
() = tokio::time::sleep(HTTP_DRAIN_TIMEOUT) => {
tracing::warn!(
timeout_secs = HTTP_DRAIN_TIMEOUT.as_secs(),
"HTTP graceful shutdown timed out; cancelling remaining connections"
);
Ok(())
}
_ = crate::bootstrap::shutdown::shutdown_signal() => {
tracing::warn!("Received a second shutdown signal; cancelling remaining connections");
Ok(())
}
}
}
}
}
}
};
if let Some(executable_path) = restart_path {
tracing::info!(
executable = %executable_path.display(),
"正在以更新后的二进制替换当前进程"
);
drop(log_guard);
return exec_replacement_process(executable_path);
}
if let Some(coordinator) = task_coordinator.take() {
let active_websocket_connections = connection_drain.begin_shutdown();
tracing::info!(
@@ -610,15 +629,6 @@ pub async fn run(config: BootstrapConfig) -> Result<(), Box<dyn Error + Send + S
}
serve_result?;
if let Some(executable_path) = crate::bootstrap::shutdown::restart_executable_path() {
tracing::info!(
executable = %executable_path.display(),
"正在以更新后的二进制替换当前进程"
);
drop(log_guard);
exec_replacement_process(executable_path)?;
}
Ok(())
}
+22 -32
View File
@@ -1,19 +1,17 @@
//! 优雅关闭信号处理。
//! 进程关闭与重启信号处理。
use std::{
path::PathBuf,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
use std::{path::PathBuf, sync::OnceLock};
use tokio::signal;
use tokio::sync::broadcast;
static SHUTDOWN_REQUESTS: OnceLock<broadcast::Sender<()>> = OnceLock::new();
static RESTART_REQUESTED: AtomicBool = AtomicBool::new(false);
static RESTART_EXECUTABLE_PATH: OnceLock<PathBuf> = OnceLock::new();
static SHUTDOWN_REQUESTS: OnceLock<broadcast::Sender<ShutdownAction>> = OnceLock::new();
#[derive(Clone)]
pub(crate) enum ShutdownAction {
Graceful,
Restart(PathBuf),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RuntimeProcessControl;
@@ -24,12 +22,12 @@ impl crate::api::router::ProcessControl for RuntimeProcessControl {
}
fn request_restart(&self, executable_path: PathBuf) {
request_process_restart(executable_path);
request_immediate_restart(executable_path);
}
}
/// 等待进程关闭信号(Ctrl+C 或 SIGTERM)。
pub async fn shutdown_signal() {
/// 等待进程关闭或重启信号。
pub(crate) async fn shutdown_signal() -> ShutdownAction {
let mut requested = shutdown_sender().subscribe();
let ctrl_c = async {
signal::ctrl_c()
@@ -49,33 +47,25 @@ pub async fn shutdown_signal() {
let terminate = std::future::pending::<()>();
tokio::select! {
_ = requested.recv() => {},
() = ctrl_c => {},
() = terminate => {},
action = requested.recv() => match action {
Ok(action) => action,
Err(_) => ShutdownAction::Graceful,
},
() = ctrl_c => ShutdownAction::Graceful,
() = terminate => ShutdownAction::Graceful,
}
}
/// 请求进程按优雅关闭路径退出。
pub fn request_shutdown() {
let _ = shutdown_sender().send(());
let _ = shutdown_sender().send(ShutdownAction::Graceful);
}
/// 请求进程完成优雅关闭后以新二进制替换当前进程。
pub fn request_process_restart(executable_path: PathBuf) {
let _ = RESTART_EXECUTABLE_PATH.set(executable_path);
RESTART_REQUESTED.store(true, Ordering::SeqCst);
request_shutdown();
fn request_immediate_restart(executable_path: PathBuf) {
let _ = shutdown_sender().send(ShutdownAction::Restart(executable_path));
}
/// 获取待 exec 的重启目标。
pub fn restart_executable_path() -> Option<PathBuf> {
RESTART_REQUESTED
.load(Ordering::SeqCst)
.then(|| RESTART_EXECUTABLE_PATH.get().cloned())
.flatten()
}
fn shutdown_sender() -> &'static broadcast::Sender<()> {
fn shutdown_sender() -> &'static broadcast::Sender<ShutdownAction> {
SHUTDOWN_REQUESTS.get_or_init(|| {
let (sender, _receiver) = broadcast::channel(16);
sender
+26
View File
@@ -22,6 +22,7 @@ use uuid::Uuid;
use super::TokenRefresher;
use crate::fleet::{
account::{Account, AccountStatus},
pool::AccountPoolService,
store::{
AccountClaimsUpdate, AccountStore, PgAccountStore, PgAccountStoreError, StoredAccount,
},
@@ -48,6 +49,7 @@ where
C: TokenRefresher,
{
store: PgAccountStore,
account_pool: Option<Arc<AccountPoolService>>,
scheduler: Arc<RefreshScheduler<C>>,
policy: RuntimeRefreshPolicy,
refresh_leases: Option<RedisRefreshLeaseStore>,
@@ -193,6 +195,7 @@ where
fn clone(&self) -> Self {
Self {
store: self.store.clone(),
account_pool: self.account_pool.clone(),
scheduler: self.scheduler.clone(),
policy: self.policy.clone(),
refresh_leases: self.refresh_leases.clone(),
@@ -215,6 +218,7 @@ where
let policy = policy.into();
Self {
store,
account_pool: None,
scheduler: Arc::new(RefreshScheduler::new(policy.clone(), client)),
policy,
refresh_leases: None,
@@ -233,6 +237,12 @@ where
self
}
/// 刷新后同步持久化账号到运行时池,避免旧 token 快照覆盖新状态。
pub fn with_account_pool_sync(mut self, account_pool: Arc<AccountPoolService>) -> Self {
self.account_pool = Some(account_pool);
self
}
/// 更新刷新策略。
pub fn update_policy(&self, policy: RefreshPolicy) {
self.policy.update(policy);
@@ -587,6 +597,7 @@ where
if attempt.refresh_token.is_none() {
persist_status(&self.store, &attempt.id, AccountStatus::Expired).await?;
persist_next_refresh_at(&self.store, &attempt.id, None).await?;
self.sync_account_pool_after_update(&attempt.id).await;
return Ok(TokenRefreshOutcome::StatusUpdated);
}
@@ -601,6 +612,7 @@ where
persist_status(&self.store, &attempt.id, updated.status).await?;
persist_next_refresh_at(&self.store, &attempt.id, None).await?;
self.sync_account_pool_after_update(&attempt.id).await;
return Ok(TokenRefreshOutcome::StatusUpdated);
}
Ok(mut updated) => {
@@ -609,6 +621,7 @@ where
updated.next_refresh_at = self.computed_refresh_at(&updated);
let refresh_token_rotated = updated.refresh_token != attempt.refresh_token;
persist_token_update(&self.store, &updated).await?;
self.sync_account_pool_after_update(&updated.id).await;
info!(
account_id = %updated.id,
attempt = attempt_index + 1,
@@ -643,6 +656,19 @@ where
Ok(TokenRefreshOutcome::Skipped)
}
async fn sync_account_pool_after_update(&self, account_id: &str) {
let Some(account_pool) = self.account_pool.as_ref() else {
return;
};
if let Err(error) = account_pool.sync_account_from_store(account_id).await {
warn!(
account_id,
error = %error,
"Failed to sync runtime account pool after token refresh"
);
}
}
async fn prepare_refresh_attempt(
&self,
account: &Account,
+1
View File
@@ -174,6 +174,7 @@ update accounts
set
status = case
when status in ('disabled', 'banned') then status
when $4 = 'expired' and access_token_expires_at > $2 then status
when (
case
when not $1 and quota_cooldown_until is not null and quota_cooldown_until > $2 then quota_limit_reached
+6 -1
View File
@@ -9,12 +9,17 @@ use std::{
use async_trait::async_trait;
use chrono::{Duration, TimeZone, Utc};
use codex_proxy_rs::fleet::account::AccountStatus;
use codex_proxy_rs::fleet::refresh::{RedisRefreshLeaseStore, RefreshPolicy};
use codex_proxy_rs::fleet::refresh::{RefreshFailure, TokenPair, TokenRefresher};
use codex_proxy_rs::fleet::store::{
AccountClaimsUpdate, NewAccount, PgAccountStore, StoredAccount,
};
use codex_proxy_rs::fleet::{
account::AccountStatus,
pool::{AccountPoolOptions, AccountPoolService},
store::AccountStore,
};
use codex_proxy_rs::telemetry::account_usage::store::PgAccountUsageStore;
use secrecy::{ExposeSecret, SecretString};
use serde_json::json;
use tokio::{
+80
View File
@@ -1,5 +1,85 @@
use super::*;
#[tokio::test]
async fn token_refresh_task_should_sync_refreshed_token_into_runtime_account_pool() {
let (pool, _guard) = init_test_db("token-refresh-runtime-pool-sync").await;
let store = PgAccountStore::new(pool.clone());
let now = Utc.with_ymd_and_hms(2026, 6, 18, 7, 0, 0).unwrap();
let new_expires_at = now + Duration::hours(1);
let new_access_token = test_jwt(new_expires_at.timestamp());
store
.insert(NewAccount {
id: "acct-refresh-runtime-pool".to_string(),
email: Some("runtime-pool@example.com".to_string()),
account_id: Some("chatgpt-runtime-pool".to_string()),
user_id: Some("user-runtime-pool".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(
test_jwt((now + Duration::seconds(30)).timestamp()).into(),
),
refresh_token: Some(SecretString::new("refresh-runtime-pool".to_string().into())),
added_at: None,
access_token_expires_at: Some(now + Duration::seconds(30)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
let account_pool = Arc::new(AccountPoolService::new(
Arc::new(store.clone()) as Arc<dyn AccountStore>,
Arc::new(PgAccountUsageStore::new(pool)),
AccountPoolOptions {
max_concurrent_per_account: 1,
..AccountPoolOptions::default()
},
0,
));
account_pool
.restore_from_store()
.await
.expect("runtime account pool should restore");
let refresher = StaticTokenRefresher {
response: Arc::new(Mutex::new(Ok(TokenPair {
access_token: new_access_token.clone(),
refresh_token: None,
}))),
};
let task = codex_proxy_rs::fleet::refresh::TokenRefreshService::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher,
)
.with_account_pool_sync(Arc::clone(&account_pool));
task.schedule_account_timers_once_at(now)
.await
.expect("refresh timer should be scheduled");
let stored = wait_for_account(&store, "acct-refresh-runtime-pool", |account| {
account.access_token.expose_secret() == new_access_token.as_str()
})
.await;
let capacity = timeout(StdDuration::from_secs(2), async {
loop {
let capacity = account_pool
.capacity_summary(now + Duration::minutes(1))
.await;
if capacity.available_slots == 1 {
return capacity;
}
sleep(StdDuration::from_millis(20)).await;
}
})
.await
.expect("refreshed account should remain available in the runtime pool");
task.shutdown().await;
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(capacity.available_slots, 1);
}
#[tokio::test]
async fn token_refresh_task_should_persist_refreshed_access_token_and_keep_refresh_token() {
let (pool, _guard) = init_test_db("token-refresh").await;
+60
View File
@@ -793,6 +793,66 @@ async fn account_store_runtime_state_sync_should_not_clear_newer_future_quota_co
assert_eq!(stored.3, Some(future_cooldown));
}
#[tokio::test]
async fn account_store_runtime_state_sync_should_not_expire_a_newer_refreshed_token() {
let (pool, _dir) = account_store_parts("accounts", 49).await;
let repo = PgAccountStore::new(pool.clone());
let stale_expires_at = pg_timestamp(Utc::now() - Duration::minutes(1));
repo.insert(NewAccount {
id: "acct_a".to_string(),
email: Some("user@example.com".to_string()),
account_id: Some("chatgpt-account".to_string()),
user_id: Some("chatgpt-user".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new("stale-access-token".to_string().into()),
refresh_token: Some(SecretString::new("refresh-token".to_string().into())),
access_token_expires_at: Some(stale_expires_at),
status: AccountStatus::Active,
added_at: None,
})
.await
.unwrap();
let mut stale_account = AccountStore::list_pool_accounts(&repo)
.await
.unwrap()
.into_iter()
.find(|account| account.id == "acct_a")
.unwrap();
let refreshed_expires_at = pg_timestamp(Utc::now() + Duration::hours(1));
repo.update_from_claims(
"acct_a",
AccountClaimsUpdate {
email: Some("user@example.com".to_string()),
account_id: Some("chatgpt-account".to_string()),
user_id: Some("chatgpt-user".to_string()),
plan_type: Some("plus".to_string()),
access_token: SecretString::new("refreshed-access-token".to_string().into()),
refresh_token: None,
access_token_expires_at: Some(refreshed_expires_at),
next_refresh_at: Some(refreshed_expires_at - Duration::minutes(5)),
status: AccountStatus::Active,
},
)
.await
.unwrap();
stale_account.status = AccountStatus::Expired;
repo.sync_runtime_account_state(&stale_account)
.await
.unwrap();
let stored = repo.get("acct_a").await.unwrap().unwrap();
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
(
stored.access_token.expose_secret(),
stored.access_token_expires_at
),
("refreshed-access-token", Some(refreshed_expires_at))
);
}
#[tokio::test]
async fn account_store_runtime_state_sync_should_not_regress_newer_usage_window() {
let (pool, _dir) = account_store_parts("accounts", 43).await;
+343
View File
@@ -0,0 +1,343 @@
# 多平台统一 AI 网关目标架构
本文定义 Codex Proxy RS 向多平台统一 AI 网关演进的目标边界。它是目标设计,不表示仓库当前已经具备这些能力;当前生效结构仍以 [architecture.md](architecture.md) 为准,终态数据模型见 [multi-provider-database.md](multi-provider-database.md)。
目标实现保持一个产品和一个默认部署单元。Cargo workspace 用于约束业务依赖,Provider adapter 使用静态链接,不把不同平台拆成微服务,也不引入动态 Rust 插件 ABI。
## 目标
网关最终允许客户端使用 OpenAI、Anthropic 或网关原生协议,通过同一执行链访问 OpenAI、Anthropic、Google、xAI、Azure OpenAI、Bedrock、Codex、本地模型及其他兼容平台。
架构必须满足:
1. 客户端协议、上游平台、部署端点、凭据、模型和路由是独立概念。
2. API handler 不识别具体 Provider,Provider adapter 不拥有调用方预算和跨平台路由规则。
3. 新增 Provider 不修改核心 dispatch、已有客户端协议或通用存储结构。
4. 新增客户端协议不修改任何 Provider。
5. 只类型化网关需要理解的稳定语义,不建立包含所有厂商字段的万能请求结构。
6. 流式与非流式请求共享同一执行和结算路径。
7. 一个规则只有一个 owner;retry、commit、fallback 和 accounting 不在 adapter 中重复实现。
不以动态加载第三方二进制插件、跨进程 Provider 调用或将每个平台拆成独立服务为默认目标。只有出现独立扩缩容、故障隔离或组织所有权需求时,才评估进程拆分。
## 核心概念
| 概念 | 责任 | 示例 |
| --- | --- | --- |
| Client protocol | 客户端请求和响应的 wire contract | OpenAI Responses、Anthropic Messages |
| Provider | 一个上游平台的行为 adapter | OpenAI、Anthropic、xAI、Codex |
| Provider instance | 一个可配置的上游部署或端点 | Azure 香港区、公司 OpenRouter |
| Credential | Provider instance 使用的认证资源 | API Key、OAuth 账号、AWS 凭据 |
| Model target | 一个 instance 上的真实模型 | `claude-sonnet-4`、`grok-4` |
| Model route | 暴露给客户端的模型及候选目标 | `smart-code` → Codex/OpenAI/Anthropic |
| Logical request | 客户端发起的一次完整业务请求 | 一次 `/v1/responses` 调用 |
| Attempt | Logical request 的一次真实上游调用 | OpenAI key-1 返回 429 |
这些概念不能通过模型名称前缀或一个通用 `account` 结构隐式绑定。Router 选择 Provider instance 和 model target;Provider 在自己的资源池中选择 credential。
## 总体结构
```text
Control Plane
Admin API / Catalog / Routes / Credentials
|
RuntimeSnapshot
|
v
Client protocol -> Protocol adapter -> Gateway Engine -> Route Planner
|
Attempt Coordinator
|
Provider Registry
/ | \
OpenAI Anthropic Codex ...
\ | /
Canonical Events
|
Client response <- Protocol encoder <--------+
```
控制面管理配置和后台状态,数据面处理请求热路径。它们默认运行在同一进程,但通过不可变运行时快照隔离,避免每次请求临时查询和拼装数据库配置。
## 数据面
每个请求按固定顺序执行:
1. 协议 adapter 解析外部请求并生成内部 operation。
2. 验证下游 API Key,建立调用方、request ID、deadline 和 cancellation context。
3. 从 operation 推导模型能力要求。
4. Model Router 冻结本请求的 Route Plan。
5. 预留调用方速率、并发和预算。
6. Attempt Coordinator 依次执行允许的目标、凭据轮换和重试。
7. Provider 将上游响应规范化为 canonical event stream。
8. 协议 adapter 将事件编码为目标客户端协议;非流式响应由相同事件流聚合得到。
9. 终结请求结果、用量、目录价格估算和每次 attempt 事实。
只有协议 adapter 处理 OpenAI、Anthropic 等 wire casing 和事件名称。Gateway Engine 不接触 Axum body、SSE 原始字节、WebSocket frame 或 Provider SDK 类型。
### Operation 边界
内部按业务能力定义 operation,而不是建立全平台字段并集:
```rust
enum Operation {
Generate(GenerateRequest),
Embed(EmbedRequest),
Rerank(RerankRequest),
GenerateImage(ImageRequest),
Speech(SpeechRequest),
}
```
只类型化网关确实需要解释、路由或结算的稳定字段,例如内容、工具、输出格式、推理要求、token 限制和 usage。平台专属参数通过按 Provider 命名的 `provider_options` 传递,并由对应 adapter 校验。
已知但不支持的语义必须返回稳定的 unsupported 错误,不能静默删除字段。Realtime 和异步 batch 具有不同生命周期,只有实际实现时才增加各自边界,不提前塞入普通 request/response trait。
### Canonical event
所有上游输出转换为少量稳定事件:
```rust
enum GatewayEvent {
Started(ResponseMeta),
ContentAdded(ContentItem),
TextDelta(TextDelta),
ReasoningDelta(ReasoningDelta),
ToolCallDelta(ToolCallDelta),
Usage(Usage),
Completed(ResponseMeta),
}
```
OpenAI SSE、Anthropic SSE、Gemini stream 和 Codex WebSocket 都只解码一次。协议 encoder 从 canonical events 生成 OpenAI SSE、Anthropic SSE 或普通 JSON,避免按 Provider 和客户端协议形成 N × M 条转换路径。
### Provider 执行边界
Provider 热路径只有一个执行接口:
```rust
type EventStream =
Pin<Box<dyn Stream<Item = Result<GatewayEvent, ProviderError>> + Send>>;
#[async_trait]
trait Provider: Send + Sync {
fn name(&self) -> &'static str;
async fn execute(
&self,
request: ProviderRequest,
context: AttemptContext,
) -> Result<ProviderStream, ProviderError>;
}
```
`ProviderStream` 同时返回 Provider instance、真实模型、匿名 resource ID、上游 request ID 和 transport。凭据租约与并发 permit 由 stream 持有,结束、取消或 drop 时释放。
Provider Registry 是唯一需要 `Arc<dyn Provider>` 的异构边界,event stream 也只在该边界 boxed。Provider 内部继续使用具体类型和静态分发,避免泛型与 trait object 扩散。
模型同步、OAuth 刷新、额度抓取和健康探测不是所有平台共享的热路径能力,不进入主 Provider trait。它们由 Provider crate 内的后台任务实现并在 bootstrap 注册。
## 模型路由与资源选择
### Route Plan
客户端模型先解析为 route group:
```text
smart-code
|- codex-prod / gpt-5-codex
|- anthropic-prod / claude-sonnet
`- openai-prod / gpt-5
```
Router 禁止通过 `starts_with("grok")` 一类字符串启发式选择平台。每个 model target 维护真实 operation、feature、context、输出、地区和价格能力。
Router 依次执行:
1. 调用方 allowlist、数据驻留和协议策略过滤。
2. operation、tools、vision、reasoning、JSON Schema、continuation 等能力过滤。
3. context window、输出限制和预算过滤。
4. Provider instance 健康、熔断和可用容量过滤。
5. 按固定优先级、权重、延迟、成本或 sticky 策略排序。
结果是请求级不可变 Route Plan。配置更新不改变在途请求的候选集合。
### Credential 归 Provider 管理
Credential 不是统一 API Key:
```text
Codex -> OAuth account / Cookie / quota / WebSocket session
OpenAI/xAI -> API key
Azure -> endpoint / deployment / API key
Bedrock -> AWS credential / region / SigV4
Gemini -> API key or service account
Ollama -> endpoint, optionally no credential
```
Core 只传递 Provider instance、model target 和本次排除的 resource ID。Provider 自己选择凭据并返回匿名 attempt metadata。Secret 明文只存在于 Provider 内部,不能进入日志、错误、Debug 输出或 RuntimeSnapshot 的公开部分。
## Retry、commit 与 fallback
Attempt Coordinator 是 retry 和跨目标 fallback 的唯一 owner:
- 每次可能到达上游的 payload 都对应一个独立 attempt;Provider adapter 不得隐藏 credential retry。
- HTTP/SDK transport 的自动业务 retry 和携带 payload 的自动 redirect 默认关闭,不能让底层产生 telemetry 看不见的第二次调用。
- Logical request 与 attempt 必须先持久化,之后才能发送可能产生费用或副作用的上游 payload。
- Upstream `not_sent/sent/ambiguous` 与 downstream commit 是两个独立状态;ambiguous upstream send 默认禁止自动重放。
- Request 和每个 attempt 都冻结并持久化 deadline;进程重启后的 recovery 不依赖已经变化的运行配置推断超时。
- 首个可交付事件之前,可以切换同平台凭据或 Route Plan 中的下一个目标。
- 响应一旦向客户端 commit,禁止重试或切换 Provider。
- 请求语义错误和 capability mismatch 不重试。
- 认证失败禁用或冷却当前 credential。
- 429 按 `Retry-After` 和 Provider policy 冷却。
- 网络错误和 5xx 只允许在 commit 前重试。
- 非幂等 operation 默认不做跨平台 fallback。
- 跨平台 fallback 必须由 model route 显式声明,系统不能自行猜测模型等价性。
Logical request 和 attempt 分开记录:
```text
Logical request
|- Attempt 1: OpenAI / key-1 / 429
|- Attempt 2: OpenAI / key-2 / 503
`- Attempt 3: Anthropic / key-7 / success
```
Provider error 先映射为稳定的 `ProviderError`,Gateway Engine 再映射为 `GatewayError`,最后由客户端协议 adapter 编码。Provider 原始 status、code、request ID 和安全诊断可以保留,但不能直接决定客户端 contract。
## 对话延续
网关明确支持两种不同模式:
### Native continuation
使用上游 `previous_response_id`、conversation ID 或 connection-local state。Continuation binding 必须固定 Provider、Provider instance 和必要的 credential scope,不能把一个平台的资源 ID 发送给另一个平台。
Provider 必须声明 native state 是否可安全并发重用;“可重复但只能串行”的平台要提供自己的持久状态边界,不能伪装成通用 binding。单次消费 handle 的 claim/consume/ambiguous 状态由 PostgreSQL CAS 持久化,Redis 只协助短期串行化;Redis 丢失不能让已经可能到达上游的 handle 被再次发送。
### Portable continuation
网关保存加密 canonical transcript,每次重新构建完整上下文。该模式允许跨 Provider 路由,但成本、上下文限制和部分平台专属语义损失必须显式可见。
Transcript 以不可变 snapshot 链保存:每次响应绑定一个新叶子,分支不会读到彼此未来内容;过深时可以 materialize,但不得延长正文 retention。
失败时不能把 native continuation 暗中降级成 portable continuation。
## 控制面
控制面负责:
- Provider instance 与 endpoint 配置。
- 上游 credential 配置、secret revision 与独立的高频运行状态。
- Provider model catalog 与能力覆盖。
- 对外模型、route group 和 route target。
- 调用方 allowlist、速率、并发和预算策略。
- 价格版本、健康状态和 Provider cooldown。
- OAuth 刷新、额度同步、模型同步和健康探测任务。
管理修改携带调用方读到的 expected revision,先在同一事务中持久化、完成 Provider 专属校验并推进单调 config revision,再从一致性读取编译为不可变 RuntimeSnapshot,避免并发编辑静默覆盖。OAuth token 刷新和 credential availability 使用各自 revision 更新,不触发全量配置重载。无效配置不能进入热路径。单进程可以使用进程内 snapshot;多实例部署用 Redis 通知缩短传播时间,同时周期性从 PostgreSQL 对账 revision,因此丢失通知不会永久保留旧配置。
## Cargo workspace 终态
```text
backend/
|- Cargo.toml
|- apps/
| `- gateway/
| `- src/
| |- main.rs
| |- bootstrap.rs
| `- workers.rs
|- crates/
| |- gateway-core/
| | `- src/
| | |- operation/
| | |- engine/
| | |- routing/
| | |- policy/
| | |- accounting/
| | |- event.rs
| | `- error.rs
| |- gateway-api/
| | `- src/
| | |- openai/
| | |- anthropic/
| | |- native/
| | `- admin/
| |- gateway-protocol/
| | `- src/
| | |- openai/
| | |- anthropic/
| | `- gemini/
| |- gateway-store/
| | `- src/
| | |- postgres/
| | `- redis/
| `- providers/
| |- codex/
| |- openai/
| |- anthropic/
| |- google/
| |- xai/
| |- azure-openai/
| |- bedrock/
| |- openrouter/
| `- ollama/
`- migrations/
```
依赖方向固定为:
```text
gateway binary
|- gateway-api --------> gateway-core
|- gateway-store ------> gateway-core
`- provider-* ---------> gateway-core
gateway-core 不依赖 Axum、SQLx、reqwest、Redis、具体 Provider 或客户端协议。
```
Provider crate 是业务编译边界,不是独立进程或准备发布的通用 SDK。Provider 之间禁止相互依赖;相同 wire contract 放在 `gateway-protocol` 复用,平台认证、错误、额度和 transport quirks 仍由各自 adapter 管理。
对于真正只需要 base URL、认证 header 和少量兼容开关的平台,可以提供配置驱动的 OpenAI-compatible adapter。Codex、Azure、Bedrock 等行为明显不同的平台不能因为 JSON 相似而塞入同一个 OpenAI client。
## 数据、安全与遥测边界
PostgreSQL 保存 Provider instance、credential metadata、模型路由、调用方策略、逻辑请求、attempt、usage、价格和 continuation binding 等持久事实,具体表结构见 [multi-provider-database.md](multi-provider-database.md)。Redis 只保存多实例限流、并发租约、短期 cooldown、affinity 和配置通知等可恢复状态。
下游 client API key 与上游 credential 必须使用不同存储和权限边界。Credential secret 使用环境变量、外部 secret manager 或独立加密密钥保护;身份伪名或 HMAC 密钥不能兼作 secret encryption key。
遥测以 logical request 和 attempt 为两个层级:
- Logical request 记录客户端协议、公开模型、调用方结果和最终 usage。
- Attempt 记录 Provider、instance、真实模型、匿名 resource、transport、status、延迟、commit 和 retry reason。
- 标准化 usage 用于跨平台统计;token 使用普通列,image、audio 等非 token metric 使用版本化 registry 中的名称和固定单位,不能把 Provider 原始 usage body 当作通用 schema。
- 价格按 Provider instance、模型、service tier 和生效时间保存不可变版本,每个版本原子包含完整 metric rates;目录价格估算区分 known、partial 和 unknown,不能把缺失费率按零费用处理或跨货币直接相加,也不能冒充 Provider invoice。
## 当前代码的目标归位
| 当前边界 | 目标边界 |
| --- | --- |
| `api/client/responses` | `gateway-api/openai/responses` |
| `dispatch` | 去除 Codex 类型后的 `gateway-core/engine` |
| `fleet` 账号池 | `providers/codex/account_pool` |
| `upstream/openai` | Codex 专属部分进入 `providers/codex`,通用 wire codec 进入 `gateway-protocol/openai` |
| `models` | 控制面 model catalog 与 routing snapshot |
| `telemetry/usage` | 通用 logical request、attempt 和 usage 事实 |
| `bootstrap` | Provider 注册、store 实现和后台任务的唯一 composition root |
迁移期间不保留两套生产执行路径。每个阶段先用当前 Codex Provider 包住既有行为,再逐步把协议、调度和遥测边界中残留的 Codex 类型移回 adapter。
## 验收标准
目标边界完成后应满足:
1. 新增 Anthropic 等 Provider 时,只新增 Provider crate、配置校验和 bootstrap 注册;核心 engine、OpenAI handler 和通用数据库结构不变。
2. 新增 Anthropic 等客户端协议时,只新增 protocol adapter;Provider 不变。
3. 新增普通 OpenAI-compatible instance 时只增加运行时配置,不增加重复业务 service。
4. API、routing、accounting 和 telemetry 中没有 `if provider == ...` 的平台业务分支。
5. Provider contract suite 覆盖非流式、流式事件顺序、工具、usage、错误、取消、commit 前后 retry 和 secret redaction。
6. 跨 Provider continuation、fallback 和未知 capability 不会发生隐式语义降级。
如果新增一个 Provider 仍需要修改 dispatch match、给统一请求增加大量可选字段、复制一套 `create` 流程或改变通用存储结构,说明边界尚未达到本文目标。
File diff suppressed because it is too large Load Diff
@@ -70,7 +70,7 @@ const rootClasses = computed(() => [
props.maxHeight || props.height ? undefined : 'h-full',
])
const wrapClasses = computed(() => [
'base-scrollbar-wrap min-h-0 max-h-[inherit] overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(--cp-info-border)',
'base-scrollbar-wrap min-h-0 max-h-[inherit] overflow-auto outline-none',
props.maxHeight ? undefined : 'h-full',
])
const verticalTrackClass = computed(() =>
@@ -3,7 +3,7 @@ import type { BaseTableColumn, BaseTableSort, ResolvedTableColumn } from './colu
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
import { Triangle } from '@lucide/vue'
import { computed, shallowRef, useSlots, watch } from 'vue'
import { computed, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
import BaseEmpty from '../BaseEmpty.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import BaseTablePagination from './BaseTablePagination.vue'
@@ -89,9 +89,15 @@ const displayRows = computed(() =>
props.loading && props.rows.length === 0 ? retainedRows.value : props.rows,
)
const hasRows = computed(() => displayRows.value.length > 0)
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const { horizontalScrolled, horizontalCanScrollRight, handleTableScroll }
= useHorizontalStickyShadow({
hasRows,
headerWrapRef,
bodyScrollbarRef,
tableViewRef,
watchSources: () => [displayRows.value.length, props.minWidth],
})
@@ -216,11 +222,11 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
<template>
<div class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
<div
v-loading="{ loading, preserveContent: hasRows }"
v-loading="loading"
class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3"
>
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
<div class="max-w-full overflow-hidden">
<div ref="headerWrap" class="max-w-full overflow-hidden">
<table
class="w-full shrink-0 table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-0.5' : 'border-spacing-y-1'"
@@ -300,6 +306,7 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
<BaseScrollbar
v-if="hasRows"
ref="bodyScrollbar"
class="min-h-0 flex-1"
horizontal
:max-height="maxHeight"
@@ -307,6 +314,7 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
@scroll="handleTableScroll"
>
<table
ref="tableView"
class="w-full table-fixed border-separate text-left"
:class="compact ? 'border-spacing-y-1' : 'border-spacing-y-2'"
:style="tableViewStyle"
@@ -2,17 +2,17 @@ import type { Ref } from 'vue'
import type BaseScrollbar from '../BaseScrollbar.vue'
import { useResizeObserver } from '@vueuse/core'
import { nextTick, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { nextTick, onMounted, shallowRef, watch } from 'vue'
interface UseHorizontalStickyShadowOptions {
hasRows: Ref<boolean>
headerWrapRef: Readonly<Ref<HTMLDivElement | null>>
bodyScrollbarRef: Readonly<Ref<InstanceType<typeof BaseScrollbar> | null>>
tableViewRef: Readonly<Ref<HTMLTableElement | null>>
watchSources: () => unknown[]
}
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
const horizontalScrolled = shallowRef(false)
const horizontalCanScrollRight = shallowRef(false)
@@ -21,7 +21,7 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
}
function scrollWrap() {
return bodyScrollbarRef.value?.wrapRef ?? null
return options.bodyScrollbarRef.value?.wrapRef ?? null
}
function resetHorizontalScrollbar() {
@@ -48,8 +48,8 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
function handleTableScroll() {
const wrap = scrollWrap()
if (wrap && headerWrapRef.value) {
headerWrapRef.value.scrollLeft = wrap.scrollLeft
if (wrap && options.headerWrapRef.value) {
options.headerWrapRef.value.scrollLeft = wrap.scrollLeft
}
updateHorizontalScrollbar()
}
@@ -61,7 +61,7 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
useResizeObserver(
() =>
[scrollWrap(), tableViewRef.value].filter(
[scrollWrap(), options.tableViewRef.value].filter(
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
),
updateHorizontalScrollbar,
@@ -73,9 +73,6 @@ export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOpti
})
return {
headerWrapRef,
bodyScrollbarRef,
tableViewRef,
horizontalScrolled,
horizontalCanScrollRight,
handleTableScroll,
+1 -1
View File
@@ -22,7 +22,7 @@ type LoadingElement = HTMLElement & {
const OVERLAY_BASE_CLASS = [
'absolute',
'inset-0',
'z-20',
'z-50',
'grid',
'min-h-18',
'place-items-center',
+1 -1
View File
@@ -1 +1 @@
version: "2.5.0"
version: "2.5.2"