mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28f2879a05 | ||
|
|
2474259c22 | ||
|
|
79c2c5948f | ||
|
|
3c4bd8757f | ||
|
|
06f3443e56 | ||
|
|
88fd21f169 |
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
version: "2.5.0"
|
||||
version: "2.5.2"
|
||||
|
||||
Reference in New Issue
Block a user