Compare commits

...
7 Commits
Author SHA1 Message Date
ZyyandCodex 641f43b303 chore(release): v3.37.2
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:24 +08:00
ZyyandCodex 6ce723ce93 fix(accounts): show account names in delete confirmation
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:41:16 +08:00
ZyyandCodex 2ff43c73da fix(accounts): bound discovery refreshes and align import settings
Share a four-request limit across CPR discovery, manual refreshes, and
periodic refreshes, and avoid loading runtime settings for initial reads.

Reuse account name and notes fields for import and editing, apply custom
names only to single-account imports, and match API usage bars to quota
progress styling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:35:29 +08:00
ZyyandCodex cb79f4d5a5 fix(accounts): start peer discovery after credential imports
Reuse quota routing for initial peer observation and preserve discovery state across ordinary account edits. Hide pending subscription badges and remove redundant provider quota labels without adding frontend polling.

Co-authored-by: Codex <noreply@openai.com>
2026-11-01 03:10:18 +08:00
ZyyandCodex 1e57805598 chore(release): v3.37.1
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:20:01 +08:00
ZyyandCodex aca75490cb fix(accounts): preserve migrated identities and restore cumulative usage
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 02:19:53 +08:00
ZyyandCodex 6a95869a72 docs: fix README links and add UI playground
Co-authored-by: Codex <noreply@openai.com>
2026-11-01 01:59:55 +08:00
57 changed files with 1004 additions and 142 deletions
+3 -7
View File
@@ -118,18 +118,14 @@ curl http://127.0.0.1:8080/v1/models \
| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) |
| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) |
| 使用插件 | [安装、配置与使用](docs/plugins.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](modules/plugins/README.md) |
| 开发共用组件 | [组件库说明](modules/ui/README.md) · [组件参考](modules/ui/playground/components/index.md) |
| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) · [官方示例](https://github.com/zyycn/codex-proxy-plugins/blob/main/README.md) |
| 开发共用组件 | [组件库说明](https://github.com/zyycn/codex-proxy-ui/blob/main/README.md) · [组件参考](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/components/index.md) · [Playground](https://zyycn.github.io/codex-proxy-ui/) |
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
## 社区
欢迎到 [Discussions 讨论区](https://github.com/zyycn/codex-proxy-rs/discussions)交流:
[使用问答](https://github.com/zyycn/codex-proxy-rs/discussions/categories/使用问答)、
[想法讨论](https://github.com/zyycn/codex-proxy-rs/discussions/categories/想法讨论)、
[实验反馈](https://github.com/zyycn/codex-proxy-rs/discussions/categories/实验反馈)与
[经验分享](https://github.com/zyycn/codex-proxy-rs/discussions/categories/经验分享)
问题反馈与功能建议请使用 [GitHub Issues](https://github.com/zyycn/codex-proxy-rs/issues),填写要求见 [贡献说明](CONTRIBUTING.md#提交-issue)
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台
@@ -30,9 +30,10 @@ pub struct ProviderAccountCapabilities {
pub consume_reset_credit: bool,
}
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义
/// 导入时应用的账号设置;名称只覆盖单账号导入,其余设置统一应用
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
@@ -162,6 +162,12 @@ pub trait AccountStore:
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
/// 只读取账号记录,不加载运行配置、分组或额度展示
async fn load_account_record(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<crate::model::accounts::AccountRecord>>;
async fn load_account_usage(
&self,
range: TimeRange,
@@ -19,30 +19,54 @@ use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::S
use super::{map_store_error, publish_committed};
pub(super) async fn publish_credentials_and_observe_quota(
peer: Option<&dyn crate::ports::peer::PeerUpstreamService>,
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
provider: &std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
accounts: &std::sync::Arc<dyn AccountStore>,
snapshot: &dyn SnapshotControl,
revision: crate::model::Revision,
account_ids: &[ProviderAccountId],
request_id: &str,
) -> Result<(), AdminError> {
notify_account_facts_changed(peer, provider.as_ref(), account_ids).await;
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
publish_committed(snapshot, revision).await?;
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
let provider = provider.clone();
let peer = peer.cloned();
let accounts = accounts.clone();
let account_ids = account_ids.to_vec();
let request_id = request_id.to_owned();
tokio::spawn(async move {
for account_id in account_ids {
if let Err(error) = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
let request = ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
};
let result = if let Some(peer) = peer.as_deref() {
let account = match accounts.load_account_record(&account_id).await {
Ok(Some(account)) => account,
Ok(None) => continue,
Err(error) => {
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
continue;
}
};
if !account.enabled || &account.provider_kind != provider.provider_kind() {
continue;
}
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
super::peer_link::upstream::read_quota(
Some(peer),
provider.as_ref(),
&account,
request,
)
.await
{
} else {
provider.quota(request).await
};
if let Err(error) = result {
tracing::warn!(
request_id,
provider = %provider.provider_kind().as_str(),
@@ -92,8 +92,9 @@ impl ProviderCredentials {
.map_err(|error| map_store_error(error, "provider credential import"))?;
drop(proxy_reservation);
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&self.provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
&result.credential_ids,
@@ -249,8 +250,9 @@ impl super::CredentialsService {
let result = outcome.result;
if outcome.newly_committed {
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -71,6 +71,12 @@ impl PeerInfoService {
id: &ProviderAccountId,
manual: bool,
) -> Result<ProviderQuota, ProviderAdminError> {
// 首查、手动与周期刷新共享容量;排队后再读凭据,避免使用等待期间失效的连接
let _permit = self
.refresh_slots
.acquire()
.await
.map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?;
let loaded = self
.accounts
.load_current_credential(id)
@@ -24,6 +24,9 @@ use std::{
sync::{Arc, Mutex, RwLock},
time::SystemTime,
};
use tokio::sync::Semaphore;
const REFRESH_CONCURRENCY: usize = 4;
pub(super) struct CachedObservation {
pub connection: UpstreamConnection,
@@ -40,6 +43,7 @@ pub(crate) struct PeerInfoService {
pub(super) attempts: Mutex<HashMap<ProviderAccountId, Arc<tokio::sync::Mutex<RefreshState>>>>,
cursors: Mutex<BTreeMap<ProviderKind, usize>>,
pub(super) cache_refresh: tokio::sync::Mutex<()>,
pub(super) refresh_slots: Semaphore,
pub(super) observations: RwLock<HashMap<ProviderAccountId, CachedObservation>>,
}
@@ -56,12 +60,46 @@ impl PeerInfoService {
attempts: Mutex::default(),
cursors: Mutex::default(),
cache_refresh: tokio::sync::Mutex::new(()),
refresh_slots: Semaphore::new(REFRESH_CONCURRENCY),
observations: RwLock::default(),
}
}
pub(crate) async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
self.forget(id);
let has_observation = self
.observations
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
let has_attempt = self
.attempts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains_key(id);
if !has_observation && !has_attempt {
return Ok(());
}
let Some(account) = self
.accounts
.get_account(id)
.await?
.filter(|account| self.supported(account))
else {
self.forget(id);
return Ok(());
};
let connection = UpstreamConnection::for_account(&account);
// 普通信息编辑保留观测与退避,连接变化由刷新状态中的身份比较处理
let mut observations = self
.observations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if observations
.get(id)
.is_some_and(|cached| cached.connection != connection)
{
observations.remove(id);
}
Ok(())
}
@@ -215,7 +253,7 @@ impl PeerInfoService {
if let Err(error) = self.refresh(&connection.account_id, false).await {
tracing::warn!(account_id = %connection.account_id, error = %error, "Upstream quota refresh failed");
}
}).buffer_unordered(4).collect::<Vec<_>>().await;
}).buffer_unordered(REFRESH_CONCURRENCY).collect::<Vec<_>>().await;
}
// 查询失败时保留该轮无法核对的账号,避免错误风暴清空所有节流记录
if failure.is_none() && !context.cancellation().is_cancelled() {
@@ -173,8 +173,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential
.credential_revision;
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&account_id),
@@ -217,8 +218,9 @@ impl PluginAccountAccess for DefaultPluginAccountAccess {
.credential_revision
.ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?;
publish_credentials_and_observe_quota(
self.upstream_info.as_deref(),
self.upstream_info.as_ref(),
&provider,
&self.accounts,
self.snapshot.as_ref(),
result.config_revision,
std::slice::from_ref(&result.account_id),
@@ -279,6 +279,10 @@ impl FakeProviderAdmin {
#[async_trait]
impl ProviderAdmin for FakeProviderAdmin {
fn peer_authentication_kinds(&self) -> &[&str] {
&["api_key"]
}
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
@@ -733,6 +737,20 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn load_account_record(
&self,
account_id: &ProviderAccountId,
) -> AdminStoreResult<Option<AccountRecord>> {
self.record("store.load_account_record");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.find(|account| account.id == account_id.as_str())
.cloned())
}
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
self.record("store.account_exists");
Ok(self
@@ -3724,6 +3742,7 @@ fn unsupported() -> ProviderAdminError {
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
name: None,
notes: Some("团队备用".to_owned()),
model_access: Default::default(),
enabled: false,
@@ -1,4 +1,5 @@
//! 按 Provider 组织凭据导入、授权与提交边界测试
mod openai;
mod peer;
mod xai;
@@ -0,0 +1,184 @@
//! 验证凭据提交后的互联首查、原生额度分派与异步失败边界
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use gateway_admin::{
model::provider_credentials::{ImportCredentials, ProviderQuota, ProviderQuotaReadRequest},
ports::{
peer::PeerUpstreamService,
provider::{ProviderAdminError, ProviderAdminErrorKind},
},
};
use gateway_core::{
account::{ProviderAccount, ProviderAccountId, UpstreamInfoSource, UpstreamSchedulingSignal},
error::StoreError,
routing::ProviderKind,
};
use super::super::{
AdminHarness,
accounts::{FakeAccountStore, FakeProviderAdmin, account_record, context, document, events},
};
#[tokio::test]
async fn imported_peer_accounts_should_start_observation_without_waiting_for_network() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_first", "acct_second"]);
let mut first = account_record("openai");
first.id = "acct_first".into();
first.authentication_kind = "api_key".into();
let mut second = first.clone();
second.id = "acct_second".into();
let store = FakeAccountStore::with_account(first.clone(), events);
store.set_accounts(vec![first, second]);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
let result = tokio::time::timeout(
Duration::from_secs(1),
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-peer"),
document: document(),
}),
)
.await
.expect("import must not wait for peer response")
.expect("import committed");
peer.wait_for_requests(1).await;
assert_eq!(peer.changed.lock().unwrap().len(), 2);
assert_eq!(result.credential_ids.len(), 2);
assert!(provider.quota_requests().is_empty());
peer.gate.add_permits(2);
peer.wait_for_requests(2).await;
assert_eq!(
*peer.requests.lock().unwrap(),
["acct_first", "acct_second"]
);
assert!(provider.quota_requests().is_empty());
}
#[tokio::test]
async fn native_import_should_use_provider_quota_when_peer_service_is_available() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider.set_import_account_ids(&["acct_test"]);
let store = FakeAccountStore::new("openai", events);
let peer = Arc::new(PendingPeer::default());
let services = AdminHarness::new()
.provider(provider.clone())
.accounts(store)
.upstream_info(peer.clone())
.build()
.await;
services
.credentials()
.for_provider(&ProviderKind::new("openai").unwrap())
.unwrap()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-native"),
document: document(),
})
.await
.expect("native import");
provider.wait_for_quota_requests(1).await;
assert!(peer.requests.lock().unwrap().is_empty());
assert_eq!(
provider.quota_requests()[0].account_id.as_str(),
"acct_test"
);
}
struct PendingPeer {
changed: Mutex<Vec<String>>,
requests: Mutex<Vec<String>>,
started: tokio::sync::Notify,
gate: tokio::sync::Semaphore,
}
impl Default for PendingPeer {
fn default() -> Self {
Self {
changed: Mutex::default(),
requests: Mutex::default(),
started: tokio::sync::Notify::new(),
gate: tokio::sync::Semaphore::new(0),
}
}
}
impl PendingPeer {
async fn wait_for_requests(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.started.notified();
if self.requests.lock().unwrap().len() >= count {
return;
}
notified.await;
}
})
.await
.expect("initial peer observation started");
}
}
impl UpstreamInfoSource for PendingPeer {
fn signal(
&self,
_: &ProviderAccount,
_: &str,
_: std::time::SystemTime,
) -> Option<UpstreamSchedulingSignal> {
None
}
}
#[async_trait]
impl PeerUpstreamService for PendingPeer {
async fn account_changed(&self, id: &ProviderAccountId) -> Result<(), StoreError> {
self.changed.lock().unwrap().push(id.as_str().into());
Ok(())
}
fn forget(&self, _: &ProviderAccountId) {}
async fn refresh_quota(
&self,
id: &ProviderAccountId,
) -> Result<ProviderQuota, ProviderAdminError> {
self.requests.lock().unwrap().push(id.as_str().into());
self.started.notify_one();
self.gate.acquire().await.unwrap().forget();
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))
}
async fn read_quota(&self, _: &ProviderAccountId) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected cached read")
}
async fn read_summary(
&self,
_: &ProviderAccountId,
) -> Result<Option<ProviderQuota>, StoreError> {
panic!("unexpected summary read")
}
async fn read_quotas(
&self,
_: &[ProviderQuotaReadRequest<'_>],
) -> Vec<Result<ProviderQuota, ProviderAdminError>> {
panic!("unexpected batch read")
}
}
@@ -112,6 +112,7 @@ pub(super) struct AdminHarness {
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
upstream_info: Option<Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>>,
service_middleware: gateway_admin::public_service::PlanSource,
}
@@ -142,6 +143,7 @@ impl AdminHarness {
plugin_store: Arc::new(plugins::TestPluginPorts),
plugin_inspector: Arc::new(plugins::TestPluginPorts),
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
upstream_info: None,
service_middleware: Arc::new(|| None),
}
}
@@ -161,6 +163,14 @@ impl AdminHarness {
self
}
pub(super) fn upstream_info(
mut self,
peer: Arc<dyn gateway_admin::ports::peer::PeerUpstreamService>,
) -> Self {
self.upstream_info = Some(peer);
self
}
pub(super) fn account_runtime(mut self, store: Arc<dyn AccountRuntimeStore>) -> Self {
self.account_runtime = store;
self
@@ -287,7 +297,7 @@ impl AdminHarness {
Arc::new(plugins::TestPluginPorts),
),
gateway_admin::AdminRuntimePorts {
upstream_info: None,
upstream_info: self.upstream_info,
timezone: Default::default(),
service_middleware: self.service_middleware,
plugin_preparation: Arc::new(plugins::TestPluginPorts),
@@ -487,6 +497,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
#[async_trait]
impl AccountStore for UnavailableStore {
async fn load_account_record(
&self,
_: &ProviderAccountId,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountRecord>> {
Err(unavailable("account"))
}
async fn account_exists(&self, _: &str) -> AdminStoreResult<bool> {
Err(unavailable("account"))
}
@@ -19,10 +19,201 @@ use gateway_core::{
};
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
time::SystemTime,
sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
},
time::{Duration, SystemTime},
};
#[tokio::test]
async fn manual_and_periodic_refreshes_share_capacity_and_release_it_after_failure_or_cancellation()
{
let providers = ["openai", "xai"].map(|kind| {
let mut provider = UnavailableProvider::new(kind);
provider.peer_enabled = true;
Arc::new(provider) as Arc<dyn ProviderAdmin>
});
let registry = ProviderAdminRegistry::new(providers).unwrap();
let records = (0..100)
.map(|index| {
account(
&format!("acct_{index}"),
if index % 2 == 0 { "openai" } else { "xai" },
"peer_test",
)
})
.collect::<Vec<_>>();
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(records.clone()),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(GatedTransport::new());
let mut bundle = initialize_peer_upstream(accounts, &registry, transport.clone()).unwrap();
let service = bundle.service();
let mut requests = Vec::new();
for record in &records[..20] {
let service = service.clone();
let id = record.id().clone();
requests.push(tokio::spawn(
async move { service.refresh_quota(&id).await },
));
}
transport.wait_for_requests(4).await;
let WorkerContribution::Registration(worker) = bundle.take_worker_contributions().remove(0)
else {
panic!("scheduled worker")
};
let worker = tokio::spawn(async move {
let WorkerRunnable::Scheduled { task, .. } = worker.runnable else {
panic!("scheduled worker")
};
task.run_cycle(WorkerCycleContext::new(
worker.id,
None,
CancellationToken::new(),
))
.await
});
assert!(
tokio::time::timeout(Duration::from_millis(50), transport.wait_for_requests(5))
.await
.is_err()
);
requests.remove(0).abort();
transport.wait_for_requests(5).await;
transport.gate.add_permits(100);
tokio::time::timeout(Duration::from_secs(5), async {
for (index, request) in requests.into_iter().enumerate() {
let result = request.await.unwrap();
assert_eq!(result.is_err(), index == 0);
}
worker.await.unwrap().unwrap();
})
.await
.expect("all queued refreshes complete");
assert_eq!(transport.peak.load(Ordering::SeqCst), 4);
assert_eq!(transport.active.load(Ordering::SeqCst), 0);
assert_eq!(transport.started_count.load(Ordering::SeqCst), 100);
}
#[tokio::test]
async fn queued_refresh_reads_current_account_after_capacity_becomes_available() {
let mut provider = UnavailableProvider::new("openai");
provider.peer_enabled = true;
let registry =
ProviderAdminRegistry::new([Arc::new(provider) as Arc<dyn ProviderAdmin>]).unwrap();
let records = (0..5)
.map(|index| account(&format!("acct_{index}"), "openai", "peer_test"))
.collect::<Vec<_>>();
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(records.clone()),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(GatedTransport::new());
let bundle = initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let service = bundle.service();
let mut requests = Vec::new();
for record in &records[..4] {
let service = service.clone();
let id = record.id().clone();
requests.push(tokio::spawn(
async move { service.refresh_quota(&id).await },
));
}
transport.wait_for_requests(4).await;
let queued = tokio::spawn(async move { service.refresh_quota(records[4].id()).await });
assert!(
tokio::time::timeout(Duration::from_millis(50), transport.wait_for_requests(5))
.await
.is_err()
);
accounts
.accounts
.lock()
.unwrap()
.retain(|account| account.id().as_str() != "acct_4");
transport.gate.add_permits(4);
for request in requests {
let _result = request.await.unwrap();
}
assert!(
tokio::time::timeout(Duration::from_secs(1), queued)
.await
.unwrap()
.unwrap()
.is_err()
);
assert_eq!(transport.started_count.load(Ordering::SeqCst), 4);
}
struct GatedTransport {
gate: tokio::sync::Semaphore,
started: tokio::sync::Notify,
started_count: AtomicUsize,
active: AtomicUsize,
peak: AtomicUsize,
success: TestTransport,
}
impl GatedTransport {
fn new() -> Self {
Self {
gate: tokio::sync::Semaphore::new(0),
started: tokio::sync::Notify::new(),
started_count: AtomicUsize::new(0),
active: AtomicUsize::new(0),
peak: AtomicUsize::new(0),
success: TestTransport(Mutex::default()),
}
}
async fn wait_for_requests(&self, count: usize) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let notified = self.started.notified();
if self.started_count.load(Ordering::SeqCst) >= count {
return;
}
notified.await;
}
})
.await
.expect("refresh reached transport");
}
}
struct ActiveFetch<'a>(&'a AtomicUsize);
impl Drop for ActiveFetch<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
#[async_trait]
impl PeerTransport for GatedTransport {
async fn fetch(
&self,
account: &ProviderAccount,
connection: &PeerConnection,
) -> Result<Vec<u8>, PeerFetchFailure> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
let _active = ActiveFetch(&self.active);
self.peak.fetch_max(active, Ordering::SeqCst);
self.started_count.fetch_add(1, Ordering::SeqCst);
self.started.notify_one();
self.gate.acquire().await.unwrap().forget();
if account.id().as_str() == "acct_1" {
return Err(UpstreamInfoFailure::Unavailable.into());
}
self.success.fetch(account, connection).await
}
}
#[tokio::test]
async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
for failed_provider in [None, Some("openai")] {
@@ -33,21 +224,22 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
});
let registry = ProviderAdminRegistry::new(providers).unwrap();
let accounts = Arc::new(TestAccounts {
accounts: vec![
accounts: Mutex::new(vec![
account("acct_first", "openai", "peer_test"),
account("acct_second", "xai", "peer_test"),
account("acct_native", "xai", "oauth"),
],
]),
records: Mutex::default(),
failed_provider,
});
let transport = Arc::new(TestTransport(Mutex::new(Vec::new())));
let mut bundle =
initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let initial_accounts = accounts.accounts.lock().unwrap().clone();
let reader = UpstreamInfoReader::default();
assert!(
reader
.signal(&accounts.accounts[0], "model", SystemTime::now())
.signal(&initial_accounts[0], "model", SystemTime::now())
.is_none()
);
let source = bundle.scheduling_source();
@@ -77,7 +269,7 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
vec!["acct_first", "acct_second"]
}
);
for account in &accounts.accounts[..2] {
for account in &initial_accounts[..2] {
if Some(account.provider().as_str()) == failed_provider {
assert!(reader.signal(account, "model", SystemTime::now()).is_none());
continue;
@@ -101,19 +293,65 @@ async fn discovery_uses_provider_capabilities_and_publishes_neutral_signals() {
}
assert!(
reader
.signal(&accounts.accounts[2], "model", SystemTime::now())
.signal(&initial_accounts[2], "model", SystemTime::now())
.is_none()
);
drop(worker);
drop(bundle);
assert!(
reader
.signal(&accounts.accounts[0], "model", SystemTime::now())
.signal(&initial_accounts[0], "model", SystemTime::now())
.is_none()
);
}
}
#[tokio::test]
async fn account_edits_preserve_observation_and_refresh_budget_until_connection_changes() {
let mut provider = UnavailableProvider::new("openai");
provider.peer_enabled = true;
let registry =
ProviderAdminRegistry::new([Arc::new(provider) as Arc<dyn ProviderAdmin>]).unwrap();
let initial = account("acct_peer", "openai", "peer_test");
let accounts = Arc::new(TestAccounts {
accounts: Mutex::new(vec![initial.clone()]),
records: Mutex::default(),
failed_provider: None,
});
let transport = Arc::new(TestTransport(Mutex::new(Vec::new())));
let bundle = initialize_peer_upstream(accounts.clone(), &registry, transport.clone()).unwrap();
let service = bundle.service();
service.refresh_quota(initial.id()).await.unwrap();
let before = service.read_summary(initial.id()).await.unwrap().unwrap();
let edited = initial.clone().with_notes(Some("仅修改备注".into()));
*accounts.accounts.lock().unwrap() = vec![edited.clone()];
service.account_changed(edited.id()).await.unwrap();
assert!(
bundle
.scheduling_source()
.signal(&edited, "model", SystemTime::now())
.is_some()
);
let after = service.refresh_quota(edited.id()).await.unwrap();
assert_eq!(after.upstream, before.upstream);
assert_eq!(transport.0.lock().unwrap().len(), 1);
let reconnected = edited.with_outbound_proxy(Some(
OutboundProxy::parse("http://proxy.invalid:8080").unwrap(),
));
*accounts.accounts.lock().unwrap() = vec![reconnected.clone()];
service.account_changed(reconnected.id()).await.unwrap();
assert!(
bundle
.scheduling_source()
.signal(&reconnected, "model", SystemTime::now())
.is_none()
);
service.refresh_quota(reconnected.id()).await.unwrap();
assert_eq!(transport.0.lock().unwrap().len(), 2);
}
fn account(id: &str, provider: &str, authentication: &str) -> ProviderAccount {
ProviderAccount::new(
ProviderAccountId::new(id).unwrap(),
@@ -165,7 +403,7 @@ impl PeerTransport for TestTransport {
}
struct TestAccounts {
accounts: Vec<ProviderAccount>,
accounts: Mutex<Vec<ProviderAccount>>,
records: Mutex<BTreeMap<ProviderAccountId, QuotaObservation>>,
failed_provider: Option<&'static str>,
}
@@ -188,6 +426,8 @@ impl ProviderAccountStore for TestAccounts {
) -> Result<Option<ProviderAccount>, StoreError> {
Ok(self
.accounts
.lock()
.unwrap()
.iter()
.find(|account| account.id() == account_id)
.cloned())
@@ -204,6 +444,8 @@ impl ProviderAccountStore for TestAccounts {
}
Ok(self
.accounts
.lock()
.unwrap()
.iter()
.filter(|account| account.provider() == provider)
.cloned()
@@ -227,6 +469,8 @@ impl ProviderAccountStore for TestAccounts {
id: &ProviderAccountId,
) -> Result<LoadedCredential, StoreError> {
self.accounts
.lock()
.unwrap()
.iter()
.find(|account| account.id() == id)
.map(loaded)
@@ -18,10 +18,11 @@ fn parse_provider(value: &str) -> Result<ProviderKind, WireValidationError> {
ProviderKind::new(value.trim().to_owned()).map_err(|_| WireValidationError::new("provider"))
}
/// 导入统一设置,复用编辑账号的备注、调度和分组约束
/// 导入设置复用编辑账号的名称、备注、调度和分组约束
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub name: Option<String>,
pub notes: Option<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -33,6 +34,9 @@ pub struct AccountImportSettingsRequest {
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
if let Some(name) = &self.name {
validate_account_name(name.trim())?;
}
validate_account_notes(self.notes.as_deref())?;
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
@@ -44,6 +48,10 @@ impl AccountImportSettingsRequest {
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
name: self
.name
.map(|name| name.trim().to_owned())
.filter(|name| !name.is_empty()),
notes: self.notes,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -117,9 +125,6 @@ impl StartAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
parse_provider(&self.provider)?;
validate_account_name(&self.name)?;
if self.account_id.is_none() && self.name.trim().is_empty() {
return Err(WireValidationError::new("name"));
}
if let Some(account_id) = self.account_id.as_deref() {
require_account_id(account_id, "accountId")?;
}
@@ -141,7 +146,7 @@ impl StartAccountAuthorizationRequest {
provider,
StartAuthorization {
context,
name: self.name,
name: self.name.trim().to_owned(),
reauthorization,
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
@@ -767,6 +767,24 @@ mod actions {
}
}
#[test]
fn authorization_accepts_optional_names_with_the_same_text_limits() {
for name in ["", " ", "My account"] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert!(request.validate().is_ok());
}
for name in ["bad\nname".to_owned(), "x".repeat(513)] {
let request: StartAccountAuthorizationRequest = serde_json::from_value(json!({
"provider": "openai", "name": name
}))
.expect("decode authorization");
assert_eq!(request.validate().unwrap_err().field(), "name");
}
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -1037,6 +1055,9 @@ mod import_settings {
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
("name", json!("名".repeat(171))),
("name", json!("名\n称")),
("name", json!("名称\u{0000}")),
("notes", json!("备".repeat(501))),
("notes", json!("备注\u{0000}")),
("notes", json!("备注\u{001b}")),
@@ -1159,6 +1159,13 @@ impl ClientKeyVerifier for UnusedClientKeyVerifier {
#[async_trait]
impl AccountStore for UnusedStore {
async fn load_account_record(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountRecord>> {
Err(unavailable("account"))
}
async fn account_exists(&self, id: &str) -> AdminStoreResult<bool> {
Ok(self
.account
@@ -230,6 +230,18 @@ impl PgAdminAccountStore {
#[async_trait]
impl AccountStore for PgAdminAccountStore {
async fn load_account_record(
&self,
account_id: &CoreProviderAccountId,
) -> AdminStoreResult<Option<AccountRecord>> {
self.accounts
.load_provider_account(account_id.as_str())
.await
.map_err(|error| admin_store_error(ENTITY, error))?
.map(|record| admin_account_record(record.summary))
.transpose()
}
async fn load_accounts_in_enabled_groups(
&self,
account_ids: &[CoreProviderAccountId],
@@ -902,6 +914,14 @@ pub(super) fn prepare_import(
if settings.notes.is_some() {
changed_fields.push("notes".to_owned());
}
if accounts.len() == 1
&& settings
.name
.as_deref()
.is_some_and(|name| !name.trim().is_empty())
{
changed_fields.push("name".to_owned());
}
}
Ok(ImportProviderAccounts {
settings,
@@ -1118,6 +1118,15 @@ pub(super) async fn import_provider_accounts_in_transaction(
if let Some(notes) = settings.notes.as_deref() {
update_provider_account_notes_in_transaction(transaction, &unique_ids, notes).await?;
}
if command.accounts.len() == 1
&& let Some(name) = settings
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
{
update_provider_account_name_in_transaction(transaction, &unique_ids, name).await?;
}
}
append_admin_audit_event_in_transaction(transaction, command.audit, revision).await?;
Ok(ProviderAccountAdminImport {
@@ -146,7 +146,6 @@ impl NewProviderAccount {
pub fn validate(&self) -> StoreResult<()> {
require_nonempty(ENTITY, "id", &self.id)?;
require_nonempty(ENTITY, "provider_kind", &self.provider_kind)?;
require_nonempty(ENTITY, "name", &self.name)?;
if let Some(upstream_user_id) = &self.upstream_user_id {
require_nonempty(ENTITY, "upstream_user_id", upstream_user_id)?;
}
@@ -2,6 +2,71 @@
use super::*;
#[tokio::test]
async fn import_name_only_overrides_a_single_account_and_keeps_batch_names() {
use gateway_admin::model::accounts::AccountImportSettings;
let Some(database) = TestDatabase::create("import_account_name").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
for (index, (name, expected)) in [
(Some(" 自定义账号 "), vec!["自定义账号"]),
(None, vec!["导入名称 0"]),
(Some(" "), vec!["导入名称 0"]),
(Some("批量不改名"), vec!["导入名称 0", "导入名称 1"]),
]
.into_iter()
.enumerate()
{
let records = (0..expected.len())
.map(|item| {
let id = format!("acct_name_{index}_{item}");
let mut record = account(&id, &id);
record.name = format!("导入名称 {item}");
record
})
.collect::<Vec<_>>();
let result = repository
.import_provider_accounts(ImportProviderAccounts {
outbound_proxy: None,
settings: Some(AccountImportSettings {
name: name.map(str::to_owned),
notes: None,
model_access: Default::default(),
enabled: true,
concurrency_limit: None,
weight: gateway_core::account::AccountWeight::DEFAULT,
group_ids: vec![],
}),
scope: ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
},
accounts: records,
audit: audit(
&format!("audit_name_{index}"),
"import",
"provider_accounts",
),
})
.await
.unwrap();
for (id, name) in result.account_ids.iter().zip(expected) {
assert_eq!(
repository
.load_provider_account(id)
.await
.unwrap()
.unwrap()
.summary
.name,
name
);
}
}
database.close().await;
}
#[tokio::test]
async fn scoped_account_reads_follow_current_enabled_groups_without_losing_admin_details() {
let Some(database) = TestDatabase::create("account_group_scope").await else {
@@ -112,7 +177,7 @@ async fn scoped_account_reads_follow_current_enabled_groups_without_losing_admin
}
#[tokio::test]
async fn account_existence_does_not_require_capacity_settings() {
async fn lightweight_account_reads_do_not_require_capacity_settings() {
let Some(database) = TestDatabase::create("account_existence").await else {
return;
};
@@ -128,6 +193,22 @@ async fn account_existence_does_not_require_capacity_settings() {
let store = admin_account_store(&database.pool);
assert!(store.account_exists("acct_exists").await.unwrap());
assert!(!store.account_exists("acct_missing").await.unwrap());
assert_eq!(
store
.load_account_record(&ProviderAccountId::new("acct_exists").unwrap())
.await
.unwrap()
.unwrap()
.id,
"acct_exists"
);
assert!(
store
.load_account_record(&ProviderAccountId::new("acct_missing").unwrap())
.await
.unwrap()
.is_none()
);
database.close().await;
}
@@ -9,6 +9,36 @@ use super::{
*,
};
#[tokio::test]
async fn authorization_persists_an_unnamed_account_with_its_email() {
let Some(database) = TestDatabase::create("authorization_unnamed").await else {
return;
};
let store = admin_account_store(&database.pool);
let mut prepared = credential("acct_unnamed", "unnamed-user");
prepared.name.clear();
prepared.email = Some("identity@example.com".into());
let (authorization, context) = command(prepared);
let result = store
.commit_authorization(authorization, &context)
.await
.unwrap();
let account = store
.load_account(
result.result.account_id.as_str(),
AccountRuntimeSnapshot::default(),
)
.await
.unwrap()
.unwrap();
assert_eq!(account.account.name, "");
assert_eq!(
account.account.email.as_deref(),
Some("identity@example.com")
);
database.close().await;
}
#[tokio::test]
async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() {
let Some(database) = TestDatabase::create("authorization_receipt_race").await else {
@@ -1557,6 +1557,7 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() {
},
accounts: vec![account("acct_notes", "notes-user")],
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
name: None,
notes: None,
enabled: true,
concurrency_limit: None,
@@ -2120,6 +2121,7 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
)
.unwrap(),
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
name: None,
notes: Some(" OAuth 新建备注 ".to_owned()),
model_access: Default::default(),
enabled: false,
@@ -3538,6 +3540,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
.await
.expect("seed group");
let settings = AccountImportSettings {
name: None,
notes: Some(" 批量新建\n团队备用 ".to_owned()),
model_access: Default::default(),
enabled: false,
@@ -602,9 +602,6 @@ impl CodexCredentialAdmin {
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let provider = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
if input.name.trim().is_empty() {
return Err(CodexCredentialAdminError::InvalidInput);
}
let revision =
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let CodexOAuthMetadata {
@@ -184,8 +184,7 @@ impl CodexPendingAuthorization {
if !valid_text(&pending.flow_id)
|| !valid_text(&pending.owner_ref)
|| !valid_text(&pending.started_request_ref)
|| !(valid_text(&pending.name)
|| pending.name.is_empty() && pending.reauthorization.is_some())
|| !(valid_text(&pending.name) || pending.name.is_empty())
|| pending.expires_at <= Utc::now()
|| !valid_secret(pending.state.expose_secret())
|| !valid_secret(pending.nonce.expose_secret())
@@ -590,7 +589,7 @@ impl CodexOAuthAdminService {
reauthorization: Option<ProviderAccountId>,
installation_id: String,
) -> Result<CodexOAuthAuthorizationStarted, CodexOAuthAdminError> {
if !(valid_text(&name) || name.is_empty() && reauthorization.is_some()) {
if !(valid_text(&name) || name.is_empty()) {
return Err(CodexOAuthAdminError::InvalidInput);
}
let expires_at = Utc::now()
@@ -156,6 +156,13 @@ fn id_token(payload: serde_json::Value) -> String {
async fn complete(
id_token: String,
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
complete_with_name(id_token, "new OAuth account").await
}
async fn complete_with_name(
id_token: String,
name: &str,
) -> Result<(CompletedCodexOAuthCredential, String, String), CodexOAuthAdminError> {
let pending_store = Arc::new(PendingStore::default());
let service = CodexOAuthAdminService::new(
@@ -167,7 +174,13 @@ async fn complete(
);
let started = service
.start_authorization(StartCodexOAuthAuthorization {
mutation: mutation(),
mutation: PendingAuthorizationMutation::new(
ProviderKind::new("openai").expect("provider"),
AuthorizationMutationTarget::Create {
name: name.to_owned(),
},
mutation().owner_binding().clone(),
),
})
.await
.expect("start OAuth authorization");
@@ -463,6 +476,23 @@ async fn repeated_reauthorization_preserves_surface_id_with_a_cleared_name() {
assert_eq!(surface_ids[0], surface_ids[1]);
}
#[tokio::test]
async fn first_exchange_preserves_optional_local_name_and_upstream_email() {
for name in ["", "My account"] {
let (credential, _, _) = complete_with_name(
id_token(serde_json::json!({ "email": "identity@example.com" })),
name,
)
.await
.expect("complete authorization with optional name");
let CompletedCodexOAuthCredential::Create(account) = credential else {
panic!("expected account creation");
};
assert_eq!(account.account.name(), name);
assert_eq!(account.account.email(), Some("identity@example.com"));
}
}
#[tokio::test]
async fn first_exchange_persists_the_installation_id_without_reusing_it_as_the_surface_id() {
let (credential, pending_installation_id, surface_stable_id) = complete(id_token(
@@ -843,7 +843,7 @@ fn validate_scope(scope: &str) -> Result<(), GrokCredentialRepositoryError> {
}
fn validate_name(value: &str) -> Result<(), GrokCredentialRepositoryError> {
if value.trim().is_empty()
if (!value.is_empty() && value.trim().is_empty())
|| value.len() > MAX_NAME_BYTES
|| value.chars().any(char::is_control)
{
@@ -18,6 +18,18 @@ fn repository_rejects_identity_that_cannot_be_sent_as_official_header() {
));
}
#[tokio::test]
async fn create_should_allow_an_empty_local_name() {
let store = MemoryProviderAccountStore::shared();
let mut input = create_input("unnamed", "subject-unnamed");
input.name.clear();
seed_input(&store, &input)
.await
.expect("seed unnamed account");
let account = store.get_account(&input.account_id).await.unwrap().unwrap();
assert_eq!(account.name(), "");
}
#[tokio::test]
async fn lifecycle_projection_should_return_expiry_without_oauth_secrets() {
let store = MemoryProviderAccountStore::shared();
+1
View File
@@ -33,3 +33,4 @@ a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and
592b80972af3bd8b3527b42da3703ca1106ecaf8fd5273c676189ca28fcbd067 0033_proxy_and_reset_credits.sql
9c15a6b6c1f9eab8ad478772f98f8fc4a997b825d58d12cffe1a15579c5785b3 0034_account_error_protection.sql
bd73379456839e8a12d7591ad2a839901af0f5ba27871248569abdfa26bc83ef 0035_cpr_link_and_concurrency_defaults.sql
d05442ee4cb85db8131e015dbed9c6ad988f93306abcda34647317674857ee44 0036_account_display_names.sql
@@ -0,0 +1,27 @@
-- 默认授权名称不代表用户命名,清空后沿用邮箱展示
-- 已有改名审计或没有邮箱的账号保留原值
with migrated as (
update provider_accounts as account
set name = '', updated_at = greatest(now(), account.updated_at)
where account.authentication_kind = 'oauth'
and nullif(btrim(account.email), '') is not null
and (
(account.provider_kind = 'openai' and account.name in (
'openai OAuth', 'OpenAI 账号', 'OpenAI 賬號', 'OpenAI account',
'Codex 账号', 'Codex 賬號', 'Codex account'
))
or (account.provider_kind = 'xai' and account.name in (
'xai OAuth', 'xAI 账号', 'xAI 賬號', 'xAI account'
))
)
and not exists (
select 1 from admin_audit_events as audit
where audit.entity_kind = 'provider_account'
and audit.entity_ref = account.id
and 'name' = any(audit.changed_fields)
)
returning account.id
)
update runtime_settings
set config_revision = config_revision + 1, updated_at = greatest(now(), updated_at)
where exists (select 1 from migrated);
+6
View File
@@ -665,6 +665,12 @@ CPR_BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
docker compose -f deploy/compose.yaml build codex-proxy-rs
```
### 账号名称迁移
OAuth 账号仍使用默认授权名称(如「Codex 账号」「OpenAI 账号」「xAI 账号」)且保存了邮箱时,
升级会清空默认名称,账号列表按邮箱前缀和完整邮箱分两行展示。
自定义名称、已有改名审计记录的账号、无邮箱账号及 API Key 名称保留原值
### 错误保护配置升级
使用 `accountAutoFreeze*` 运行参数的部署升级时,数据库迁移删除全部旧保护设置,不转换规则或继承参数。
+7 -1
View File
@@ -1349,13 +1349,16 @@ OAuth 账号仍按实际周/月额度窗口统计
RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess? }`。
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds, name?, notes?, modelAccess? }`。
提供 `settings` 时前四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数;
`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号,
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
已有账号保留原有分组、权重与并发设置。可选 `notes` 与编辑备注使用相同的校验和清空语义,省略或 `null` 保留已有备注;
管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,凭据刷新保留账号设置
可选 `name` 与编辑名称使用相同的长度和控制字符约束,仅在单份导入数据解析出一个账号时覆盖名称。
省略、`null`、留空或多账号导入均保留导入数据或服务商提供的名称;管理端批量导入不提交 `name`
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。OpenAI 与 xAI 的 CPR 账号条目均包含
@@ -1403,6 +1406,9 @@ OAuth start 使用:
客户端不得提交 `credentialRevision`、旧 token 身份或其他并发控制字段。complete 请求也不重复提交
`accountId`,后端通过 `flowId` 中保存的目标绑定完成授权
`name` 可为空字符串,表示不设置本地名称;管理端新建授权默认留空,列表使用上游邮箱展示。
提供名称时去除首尾空白,长度与字符限制沿用账号名称规则
流程只允许发起它的管理员身份继续;尚未完成的流程过期后需重新发起。
账号提交成功后,`complete` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId`
重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用
+6 -5
View File
@@ -1012,7 +1012,9 @@ Provider 声明可连接的认证种类,并从本轮凭据中提取连接材
Host 执行有界 HTTP 请求,复用组合根注入的证书策略和账号出口;Store 通过通用账号额度端口保存不透明观测,不解释互联或 Provider 协议。
互联与原生额度共用 `provider_quota_json`、`quota_observed_at`、凭据版本和额度访问事实,每个账号仅保存当前来源的快照。
OAuth 额度解析属于 Provider,CPR 发现、解析与投影属于 Admin;账号用例按能力分派读写与刷新,不将两种协议合并为同一解析服务。
互联刷新沿用运行期排程和 Host Worker 租约,分批轮转账号,同一账号的并发刷新通过运行期锁合并;重启后根据已有快照恢复查询,不持久化领取或请求代次。
凭据新建、导入或授权提交后,后台首查复用同一额度分派,不等待周期同步或阻塞保存请求。
互联首查、手动刷新和周期刷新共用服务级并发上限,排队后读取当前凭据;失败或取消释放名额。
互联周期刷新沿用运行期排程和 Host Worker 租约,分批轮转账号,同一账号的并发刷新通过运行期锁合并;重启后根据已有快照恢复查询,不持久化领取或请求代次。
额度时间戳支持轻量缓存校验,普通账号和凭据查询不加载额度正文,完整观测分批读取
共享端按 Core 的预算规则从本地账本生成明确的访问状态,与本地准入共用口径;金额字段只供接入端展示。
@@ -1023,7 +1025,8 @@ OAuth 额度解析属于 Provider,CPR 发现、解析与投影属于 Admin;
Admin 按连接身份及额度观察时间缓存管理摘要与调度投影,解析和投影在锁外完成。
Core 定义中立调度信号与只读端口,组合根绑定观测来源;Provider 选号不持有控制面服务,弱引用避免互相保活。
账号和凭据提交后的共用通知同时失效原生 Provider 与互联派生状态,插件账号写入复用同一流程。
账号和凭据提交后的共用通知更新原生 Provider 与互联派生状态,插件账号写入复用同一流程;
互联按当前连接身份失效观测,普通信息编辑保留已有摘要与刷新退避,停用或删除时释放运行态。
账号列表、概览与代理关联账号核对观测版本后复用预算与池摘要,详情读取逐节点窗口;手动刷新通过共用账号额度接口等待查询完成并返回结果。
界面复用现有额度窗口,保留上游原始观测时间;Key 金额、原生账号百分比和本机 Token 用量分别解释。
请求与字段合同见 [CPR 互联 API](api.md#cpr-互联只读协议)
@@ -1140,9 +1143,7 @@ Worker 由各 Bundle 贡献、由 Host 统一监督:
- Provider:credential refresh、quota/catalog 健康、账号预热和官方版本/etag 检查
互联同步使用账号出口的受管 HTTP 客户端,不跟随跳转;网络等待不持有数据库事务。
开启、连接变化、周期扫描和手动刷新共用持久计划,单轮领取和并发均有界,失败遵守退避及上游 `Retry-After`。
Store 按连接身份与领取代次比较后写入;凭据、地址或有效代理变化使旧结果失效,取消留下的租约到期后可重新领取。
同一连接的临时失败保留原始观测,授权拒绝清除公开数据;成功同步不会刷新历史额度的观测时间
运行期排程、并发预算、连接失效和观测提交见 [CPR 互联观测](#cpr-互联观测),失败遵守退避及上游 `Retry-After`
Daemon 每次运行持有独立的取消与成功进度上下文,实际工作或依赖检查成功后由 owner 上报。
Host 据此清除连续失败与当前错误、重置重启退避,并保持 `Running`;仅重启不会恢复健康,
+1
View File
@@ -474,6 +474,7 @@ interface AccountDeleteParams {
}
export interface AccountImportSettings {
name?: string
notes?: string
enabled: boolean
concurrencyLimit: number | null
@@ -2,6 +2,7 @@
import type { AccountUpstream } from '@/api'
import { computed } from 'vue'
import { accountUpstreamPending } from '@/utils/account-upstream'
import { stableVisualIndex } from '@/utils/color'
import AccountUpstreamBadge from './AccountUpstreamBadge.vue'
@@ -62,7 +63,7 @@ const paletteClass = computed(() => {
<template>
<AccountUpstreamBadge v-if="upstream?.discovered && upstream.observedAt" :size="size" />
<span
v-else
v-else-if="!accountUpstreamPending(upstream)"
class="inline-flex min-w-0 max-w-full items-center justify-center whitespace-nowrap leading-none shadow-z-tertiary"
:class="[sizeClass, paletteClass]"
:title="displayText || undefined"
+1 -2
View File
@@ -25,7 +25,6 @@
"reset": "Reset:",
"remainingQuota": "Remaining {label} quota",
"accountLimit": "Account limit",
"quota": "{provider}",
"lastRefreshed": "Last refreshed: {refreshedAtDisplay}",
"viewPersonalInformation": "View personal information",
"forecastWeeklyMonthlyQuota": "Forecast weekly/monthly quota",
@@ -315,6 +314,7 @@
"selectAccountPlatform": "Select account platform",
"accountPlatform": "Account platform",
"maximumWordsOptional": "Maximum 500 words, optional",
"namePlaceholder": "Optional, applies only when importing one account",
"copyAccount": "Copy account",
"accountCopied": "Account copied",
"howToAddAccount": "How to add an account",
@@ -355,7 +355,6 @@
"importTasksCreated": "Import tasks created: {submitted}",
"importTaskCreated": "Import task created",
"partialSubmission": "Some import tasks were created, retry with the same input to submit the rest",
"accountName": "{provider} account",
"failedToStartAuthorization": "Failed to start authorization"
},
"importTasks": {
@@ -25,7 +25,6 @@
"reset": "重置:",
"remainingQuota": "{label}剩余额度",
"accountLimit": "账号额度",
"quota": "{provider} 额度",
"lastRefreshed": "最近刷新: {refreshedAtDisplay}",
"viewPersonalInformation": "查看个人信息",
"forecastWeeklyMonthlyQuota": "预测周/月额度",
@@ -315,6 +314,7 @@
"selectAccountPlatform": "选择账号平台",
"accountPlatform": "账号平台",
"maximumWordsOptional": "最多 500 字,可不填",
"namePlaceholder": "留空保留原名称,仅单账号导入生效",
"copyAccount": "复制账号",
"accountCopied": "账号已复制",
"howToAddAccount": "账号添加方式",
@@ -355,7 +355,6 @@
"importTasksCreated": "已创建 {submitted} 个导入任务",
"importTaskCreated": "导入任务已创建",
"partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交",
"accountName": "{provider} 账号",
"failedToStartAuthorization": "启动授权失败"
},
"importTasks": {
@@ -25,7 +25,6 @@
"reset": "重置:",
"remainingQuota": "{label}剩餘額度",
"accountLimit": "賬號額度",
"quota": "{provider} 額度",
"lastRefreshed": "最近刷新: {refreshedAtDisplay}",
"viewPersonalInformation": "查看個人信息",
"forecastWeeklyMonthlyQuota": "預測周/月額度",
@@ -315,6 +314,7 @@
"selectAccountPlatform": "選擇賬號平臺",
"accountPlatform": "賬號平臺",
"maximumWordsOptional": "最多 500 字,可不填",
"namePlaceholder": "留空保留原名稱,僅單賬號導入生效",
"copyAccount": "複製賬號",
"accountCopied": "賬號已複製",
"howToAddAccount": "賬號添加方式",
@@ -355,7 +355,6 @@
"importTasksCreated": "已創建 {submitted} 個導入任務",
"importTaskCreated": "導入任務已創建",
"partialSubmission": "部分導入任務已創建,保持輸入不變重試可繼續提交",
"accountName": "{provider} 賬號",
"failedToStartAuthorization": "啓動授權失敗"
},
"importTasks": {
+4
View File
@@ -1,6 +1,10 @@
import type { AccountUpstream } from '@/api'
import { t } from '@/i18n'
export function accountUpstreamPending(upstream: AccountUpstream | null | undefined): boolean {
return Boolean(upstream && !upstream.discovered && !upstream.lastError)
}
export function accountUpstreamErrorMessage(reason: AccountUpstream['lastError']): string {
if (!reason)
return ''
@@ -17,9 +17,6 @@ const transportOptions = [
</script>
<template>
<ZFormItem v-if="!editing" prop="apiKey.name" :show-message="false" :label="t('accounts.credentials.accountName')" required>
<ZInput v-model="form.name" :placeholder="t('accounts.credentials.enterUpstreamName')" :disabled="disabled" />
</ZFormItem>
<ZFormItem prop="apiKey.base_url" :show-message="Boolean(form.base_url.trim())" :label="t('common.fields.baseUrl')" required>
<template #extra>
<span class="text-z-xs text-z-text-quaternary" :title="t('accounts.credentials.baseUrlHelp')">{{ t('accounts.credentials.apiPrefix') }}</span>
@@ -39,7 +39,9 @@ const form = computed(() => ({
proxyMode: proxyMode.value,
proxyId: proxyId.value,
}))
const rules = useAccountSettingsFormRules(() => form.value, () => props.editedFields)
const rules = useAccountSettingsFormRules(() => form.value, {
editedFields: () => props.editedFields,
})
watch(open, () => formRef.value?.clearValidate())
@@ -4,7 +4,7 @@ import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import type { Account, AccountGroupRef, AccountModelAccess } from '@/api'
import { ZButton, ZDialog, ZForm, ZFormItem, ZInput, ZSegmented, ZTextarea } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZForm, ZFormItem, ZSegmented } from '@codex-proxy/ui'
import { computed, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -12,7 +12,6 @@ import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountApiKeyFormRules } from '../composables/useAccountApiKeyFormRules'
import { useAccountSettingsFormRules } from '../composables/useAccountSettingsFormRules'
import { accountNameUpdate } from '../utils/accountName'
import { isOpenAiApiKeyAccount, isOpenAiOAuthAccount } from '../utils/upstreamApiKey'
import AccountApiKeyFields from './AccountApiKeyFields.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
@@ -59,20 +58,13 @@ const form = computed(() => ({
proxyMode: proxyMode.value,
proxyId: proxyId.value,
}))
const settingsRules = useAccountSettingsFormRules(() => form.value)
const settingsRules = useAccountSettingsFormRules(() => form.value, {
savedName: () => props.account?.name ?? '',
})
const apiKeyRules = useAccountApiKeyFormRules(true)
const rules = computed<FormRules>(() => ({
...settingsRules.value,
...apiKeyRules.value,
name: {
trigger: ['blur', 'change'],
validator: () => {
const updatedName = accountNameUpdate(name.value, props.account?.name ?? '')
return updatedName === undefined
|| (new TextEncoder().encode(updatedName).length <= 512 && !/\p{Cc}/u.test(updatedName))
},
message: t('accounts.editor.nameInvalid'),
},
}))
const saveDisabled = computed(() => !props.account || props.groupsLoading
|| (isOpenAiApiKeyAccount(props.account) && !props.configurationReady))
@@ -120,6 +112,8 @@ async function requestSave() {
<AccountApiKeyFields v-if="isOpenAiApiKeyAccount(account)" v-model="apiKey" editing :disabled="saving || !configurationReady" />
<AccountSettingsFields
v-model:name="name"
v-model:notes="notes"
v-model:enabled="enabled"
v-model:concurrency-limit="concurrencyLimit"
v-model:weight="weight"
@@ -135,25 +129,6 @@ async function requestSave() {
:endpoint="account.outboundProxyEndpoint"
:account-id="account.id"
/>
<ZFormItem prop="name" :label="t('accounts.credentials.accountName')">
<ZInput
v-model="name"
:aria-label="t('accounts.credentials.accountName')"
:placeholder="t('accounts.editor.namePlaceholder')"
:disabled="saving"
/>
</ZFormItem>
<ZFormItem :label="t('common.fields.notes')">
<ZTextarea
v-model="notes"
:rows="3"
:maxlength="500"
:placeholder="t('accounts.editor.notesHelp')"
:disabled="saving"
/>
</ZFormItem>
</ZForm>
<template #footer>
@@ -29,17 +29,31 @@ const props = withDefaults(
},
)
const displayTitle = computed(() => accountIdentityLabel(props.account))
const emailText = computed(() => props.account.email?.trim())
const displayTitle = computed(() => {
const label = accountIdentityLabel(props.account)
return props.account.authenticationKind !== 'api_key' && label === emailText.value
? label.split('@')[0]
: label
})
const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined)
const identityTitle = computed(() => {
const email = props.account.email?.trim()
const email = emailText.value
return email && email !== displayTitle.value
? `${displayTitle.value}\n${email}`
: displayTitle.value
})
const secondaryText = computed(() => props.account.authenticationKind !== 'api_key' && emailText.value !== displayTitle.value
? emailText.value
: undefined)
const secondaryClass = computed(() => props.size === 'lg'
? 'mt-1 text-z-sm text-z-text-secondary'
: 'mt-0.5 font-mono text-z-xs text-z-text-quaternary')
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const avatarSizeClass = computed(() =>
@@ -89,6 +103,9 @@ const avatarToneClass = computed(() => {
<AccountPlanBadge v-if="showPlan" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</div>
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-0.5" />
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
{{ secondaryText }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { ZFormItem, ZInput } from '@codex-proxy/ui'
import { useI18n } from 'vue-i18n'
defineProps<{ disabled?: boolean, required?: boolean, placeholder?: string }>()
const name = defineModel<string>({ required: true })
const { t } = useI18n({ useScope: 'global' })
</script>
<template>
<ZFormItem prop="name" :label="t('accounts.credentials.accountName')" :required="required">
<ZInput
v-model="name"
:aria-label="t('accounts.credentials.accountName')"
:placeholder="placeholder ?? t('accounts.editor.namePlaceholder')"
:disabled="disabled"
/>
</ZFormItem>
</template>
@@ -1,10 +1,11 @@
<script setup lang="ts">
import type { AccountGroupRef, AccountModelAccess } from '@/api'
import { ZFormItem, ZInput, ZSwitch } from '@codex-proxy/ui'
import { ZFormItem, ZInput, ZSwitch, ZTextarea } from '@codex-proxy/ui'
import { useI18n } from 'vue-i18n'
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import AccountModelAccessField from './AccountModelAccessField.vue'
import AccountNameField from './AccountNameField.vue'
import AccountProxyField from './AccountProxyField.vue'
withDefaults(defineProps<{
@@ -17,10 +18,14 @@ withDefaults(defineProps<{
preserveProxy?: boolean
preserveModelAccess?: boolean
showScheduling?: boolean
namePlaceholder?: string
notesPlaceholder?: string
}>(), { preserveProxy: true, showScheduling: true })
const { t } = useI18n({ useScope: 'global' })
const name = defineModel<string>('name')
const notes = defineModel<string>('notes')
const modelAccess = defineModel<AccountModelAccess | undefined>('modelAccess', { required: true })
const enabled = defineModel<boolean>('enabled')
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
@@ -78,5 +83,9 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
/>
</ZFormItem>
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" form-prop="proxyId" :preserve="preserveProxy" :current-proxy-id="currentProxyId" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
<AccountNameField v-if="name !== undefined" v-model="name" :disabled="disabled" :placeholder="namePlaceholder" />
<ZFormItem v-if="notes !== undefined" :label="t('common.fields.notes')">
<ZTextarea v-model="notes" :rows="3" :maxlength="500" :placeholder="notesPlaceholder ?? t('accounts.editor.notesHelp')" :disabled="disabled" />
</ZFormItem>
</div>
</template>
@@ -2,7 +2,7 @@
import type { AccountCreateForm } from '../../utils/accountCreate'
import type { AccountGroupRef } from '@/api'
import { ZFormItem, ZTextarea } from '@codex-proxy/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountSettingsFields from '../AccountSettingsFields.vue'
import AccountProviderChooser from './AccountProviderChooser.vue'
@@ -16,6 +16,15 @@ defineProps<{
const { t } = useI18n({ useScope: 'global' })
const form = defineModel<AccountCreateForm>({ required: true })
const name = computed({
get: () => form.value.source?.kind === 'provider'
? form.value.name
: undefined,
set: (value: string | undefined) => {
if (value !== undefined)
form.value.name = value
},
})
</script>
<template>
@@ -27,6 +36,8 @@ const form = defineModel<AccountCreateForm>({ required: true })
<AccountProviderChooser v-model="form.source" :disabled="disabled" />
</fieldset>
<AccountSettingsFields
v-model:name="name"
v-model:notes="form.notes"
v-model:enabled="form.enabled"
v-model:concurrency-limit="form.concurrencyLimit"
v-model:weight="form.weight"
@@ -40,15 +51,8 @@ const form = defineModel<AccountCreateForm>({ required: true })
:groups-loading="groupsLoading"
:preserve-proxy="false"
:disabled="disabled"
:name-placeholder="t('accounts.onboarding.namePlaceholder')"
:notes-placeholder="t('accounts.onboarding.maximumWordsOptional')"
/>
<ZFormItem :label="t('common.fields.notes')">
<ZTextarea
v-model="form.notes"
:rows="3"
:maxlength="500"
:placeholder="t('accounts.onboarding.maximumWordsOptional')"
:disabled="disabled"
/>
</ZFormItem>
</div>
</template>
@@ -17,6 +17,7 @@ import { useAccountSettingsFormRules } from '../../composables/useAccountSetting
import { accountCreateProvider } from '../../utils/accountCreate'
import AccountApiKeyFields from '../AccountApiKeyFields.vue'
import AccountIdentityCell from '../AccountIdentityCell.vue'
import AccountNameField from '../AccountNameField.vue'
import AccountImportFields from './AccountImportFields.vue'
import AccountOAuthFields from './AccountOAuthFields.vue'
import AccountSetupFields from './AccountSetupFields.vue'
@@ -54,7 +55,12 @@ const busy = computed(() => props.saving
|| props.authorization.status === 'completing')
const canSelect = computed(() => form.value.source?.kind === 'bundle' || Boolean(accountCreateProvider(form.value)))
const formRef = useTemplateRef<InstanceType<typeof ZForm>>('formElement')
const settingsRules = useAccountSettingsFormRules(() => form.value)
const settingsRules = useAccountSettingsFormRules(() => ({
...form.value,
name: form.value.source?.kind === 'provider'
? form.value.name
: undefined,
}))
const apiKeyRules = useAccountApiKeyFormRules()
const view = computed(() => resolveAccountCreatePresentation({
form: form.value,
@@ -180,6 +186,7 @@ async function requestCreate() {
@regenerate="emit('generateOauth')"
/>
<ZForm v-else-if="mode === 'api_key'" ref="formElement" :model="form" :rules="apiKeyRules" scroll-to-error focus-on-error novalidate @submit="requestCreate">
<AccountNameField v-model="form.name" required :disabled="busy" :placeholder="t('accounts.credentials.enterUpstreamName')" />
<AccountApiKeyFields v-model="form.apiKey" :disabled="busy" />
</ZForm>
<AccountImportFields
@@ -9,7 +9,7 @@ import { computed, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue'
import AccountUpstreamStatus from '@/components/account/AccountUpstreamStatus.vue'
import { formatProviderLabel } from '@/utils/providers'
import { accountUpstreamPending } from '@/utils/account-upstream'
import { formatRelativeTime } from '@/utils/relative-time'
import { usePoolQuotaWindows } from '../../composables/usePoolQuotaWindows'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
@@ -43,6 +43,8 @@ const quotaEntries = computed(() => groupedAccountQuotaWindows(
))
const profileOpen = shallowRef(false)
const forecastOpen = shallowRef(false)
const hasPlanBadge = computed(() => !accountUpstreamPending(props.account.upstream)
&& (Boolean(props.account.planType) || props.account.authenticationKind === 'api_key'))
const hasForecast = computed(() => props.account.authenticationKind !== 'api_key')
const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription)
const canRefresh = computed(() => props.account.capabilities.quotaRefresh || props.account.upstream?.discovered)
@@ -74,13 +76,9 @@ watch(hasPersonalInfo, (available) => {
v-if="account.capabilities.quota || quotaEntries.length > 0 || account.upstream?.discovered"
class="col-span-2 row-start-2 m-0 flex min-w-0 items-center gap-1.5 text-z-xs font-emphasis text-z-text-secondary"
>
<span class="shrink-0 whitespace-nowrap">{{ t('accounts.quota.quota', { provider: formatProviderLabel(account.provider) }) }}</span>
<template v-if="account.planType || account.authenticationKind === 'api_key'">
<span class="shrink-0">·</span>
<AccountPlanBadge :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" class="shrink-0" />
</template>
<AccountPlanBadge v-if="hasPlanBadge" :upstream="account.upstream" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" class="shrink-0" />
<template v-if="showUpstreamStatus || lastRefreshed">
<span class="shrink-0">·</span>
<span v-if="hasPlanBadge" class="shrink-0">·</span>
<AccountUpstreamStatus v-if="showUpstreamStatus && account.upstream" :upstream="account.upstream" />
<span v-else-if="lastRefreshed" class="min-w-0 truncate" :title="lastRefreshed">{{ lastRefreshed }}</span>
</template>
@@ -18,6 +18,8 @@ const emit = defineEmits<{ showConcurrency: [] }>()
const { t } = useI18n({ useScope: 'global' })
const localUsageOnly = computed(() => props.account.authenticationKind === 'api_key' && !props.account.upstream?.discovered)
// 互联列表只展示此 Key 的消费限额,池内各账号的百分比不能代表整个上游
const quotaWindows = computed(() => props.account.upstream?.discovered
? props.account.quota.windows.filter(window => window.amount)
@@ -57,11 +59,11 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
<template>
<div class="box-border grid min-h-16.5 w-full min-w-0 content-center gap-1.5 py-1.5">
<div
v-if="hasTokenUsage || currentUsageWindow"
v-if="localUsageOnly || hasTokenUsage || currentUsageWindow"
class="flex min-w-0 items-baseline justify-between gap-2 leading-none"
>
<span
v-if="hasTokenUsage"
v-if="localUsageOnly || hasTokenUsage"
class="flex min-w-0 items-baseline gap-1 font-mono tabular-nums"
:title="summaryValue.title"
>
@@ -84,7 +86,14 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
</span>
</div>
<div class="flex min-w-0 items-end gap-2">
<div v-if="localUsageOnly" class="grid min-w-0 gap-1.5">
<div class="flex min-w-0 items-start justify-between gap-2 leading-3">
<span class="truncate text-[10px] font-bold text-z-text-quaternary">{{ usageWindowLabels[account.usage.windowKind] }}</span>
<AccountCapacityIndicator :capacity="account.capacity" @show-details="emit('showConcurrency')" />
</div>
<div class="h-1 w-full rounded-full bg-z-success" aria-hidden="true" />
</div>
<div v-else class="flex min-w-0 items-end gap-2">
<!-- 共用列轨道为容量留位,进度条仍铺满整行,容量标记独立于额度弹层 -->
<div class="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] gap-x-2">
<AccountQuotaSummaryEntry
@@ -2,16 +2,28 @@ import type { FormRules } from '@codex-proxy/ui'
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { accountNameValid } from '../utils/accountName'
export function useAccountApiKeyFormRules(editing = false) {
const { t } = useI18n({ useScope: 'global' })
return computed<FormRules<{ apiKey: ApiKeyAccountForm }>>(() => ({
'apiKey.name': {
required: !editing,
whitespace: true,
trigger: ['blur', 'change'],
message: t('accounts.credentials.nameRequired'),
},
return computed<FormRules<{ apiKey: ApiKeyAccountForm, name: string }>>(() => ({
...(!editing
? {
name: [
{
required: true,
whitespace: true,
trigger: ['blur', 'change'],
message: t('accounts.credentials.nameRequired'),
},
{
validator: (_rule: unknown, value: string) => accountNameValid(value),
trigger: ['blur', 'change'],
message: t('accounts.editor.nameInvalid'),
},
],
}
: {}),
'apiKey.transport': {
required: true,
trigger: 'change',
@@ -5,7 +5,7 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { t } from '@/i18n'
import { notification as ZNotification } from '@/utils/notification'
import { errorMessage, generateRequestId } from '@/utils/operation'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { isSupportedProvider } from '@/utils/providers'
import { accountCreateProvider, accountCreateSourceKey, accountImportModes, accountImportSettings, emptyAccountCreateForm } from '../utils/accountCreate'
import { accountImportBatches, accountImportDocuments, mixedImportDocuments } from '../utils/accountImport'
import { emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
@@ -76,7 +76,7 @@ export function useAccountOnboarding(options: {
data: {
provider: 'openai',
authentication_kind: 'api_key',
name: form.apiKey.name.trim(),
name: form.name.trim(),
base_url: form.apiKey.base_url.trim(),
api_key: form.apiKey.apiKey,
transport: form.apiKey.transport,
@@ -93,7 +93,9 @@ export function useAccountOnboarding(options: {
const batches = accountImportBatches(documents.map(entry => ({
provider: entry.provider,
data: entry.document,
settings,
settings: documents.length === 1
? settings
: { ...settings, name: undefined },
outboundProxyId,
})))
// 同一输入重试时复用每批标识,恢复丢失响应或部分提交,不重复执行 RT 交换
@@ -134,9 +136,7 @@ export function useAccountOnboarding(options: {
await authorization.start({
start: {
provider,
name: account?.name || account?.email || t('accounts.onboarding.accountName', {
provider: formatProviderLabel(provider),
}),
name: account?.name ?? form.name.trim(),
accountId: account?.id,
outboundProxyId: account
? undefined
@@ -2,9 +2,11 @@ import type { FormRules } from '@codex-proxy/ui'
import type { AccountModelAccess } from '@/api'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { accountNameValid } from '../utils/accountName'
import { accountModelAccessError } from '../utils/modelAccess'
interface AccountSettingsFormValue {
name?: string
concurrencyLimit: string
weight: string
modelAccess?: AccountModelAccess
@@ -14,10 +16,16 @@ interface AccountSettingsFormValue {
export function useAccountSettingsFormRules(
form: () => AccountSettingsFormValue,
editedFields?: () => ReadonlySet<string>,
options: { editedFields?: () => ReadonlySet<string>, savedName?: () => string } = {},
) {
const { editedFields, savedName } = options
const { t } = useI18n({ useScope: 'global' })
return computed<FormRules<AccountSettingsFormValue>>(() => ({
name: {
trigger: ['blur', 'change'],
validator: () => accountNameValid(form().name ?? '', savedName?.()),
message: t('accounts.editor.nameInvalid'),
},
concurrencyLimit: {
type: 'integer',
trigger: ['blur', 'change'],
+2 -4
View File
@@ -33,6 +33,7 @@ import { useAccountMutations } from './composables/useAccountMutations'
import { useAccountsQuery } from './composables/useAccountsQuery'
import { useAccountsTable } from './composables/useAccountsTable'
import { accountColumns } from './constants'
import { accountIdentityLabel } from './utils/accountIdentity'
const { t } = useI18n({ useScope: 'global' })
@@ -500,10 +501,7 @@ const {
@confirm="handleDelete"
>
<p class="m-0">
{{ t('accounts.page.delete', { value: pendingDeleteAccount?.email
|| pendingDeleteAccount?.accountId
|| pendingDeleteAccount?.id
|| t('accounts.page.thisAccount') }) }}
{{ t('accounts.page.delete', { value: pendingDeleteAccount ? accountIdentityLabel(pendingDeleteAccount) : t('accounts.page.thisAccount') }) }}
</p>
</ZConfirmDialog>
</div>
@@ -9,6 +9,7 @@ export type AccountImportMode = 'oauth' | 'api_key' | 'access_token' | 'refresh_
type AccountImportInputMode = 'access_token' | 'refresh_token' | 'json'
export interface AccountCreateForm {
name: string
source: AccountCreateSource | null
apiKey: ApiKeyAccountForm
notes: string
@@ -26,6 +27,7 @@ export interface AccountCreateForm {
export function emptyAccountCreateForm(): AccountCreateForm {
return {
name: '',
source: null,
apiKey: emptyApiKeyAccountForm(),
notes: '',
@@ -56,6 +58,9 @@ export function accountCreateSourceKey(form: AccountCreateForm) {
export function accountImportSettings(form: AccountCreateForm) {
const scheduling = accountSchedulingValues(form.concurrencyLimit, form.weight)
return {
name: form.source?.kind === 'provider'
? form.name.trim() || undefined
: undefined,
modelAccess: form.modelAccess
? {
...form.modelAccess,
@@ -4,3 +4,10 @@ export function accountNameUpdate(name: string, savedName: string): string | und
return undefined
return name.trim()
}
export function accountNameValid(name: string, savedName?: string): boolean {
const value = savedName === undefined
? name.trim()
: accountNameUpdate(name, savedName)
return value === undefined || (new TextEncoder().encode(value).length <= 512 && !/\p{Cc}/u.test(value))
}
@@ -9,14 +9,12 @@ export function isOpenAiOAuthAccount(account: Pick<Account, 'provider' | 'authen
}
export interface ApiKeyAccountForm extends Omit<ApiKeyConfiguration, 'transport'> {
name: string
apiKey: string
transport: ApiKeyConfiguration['transport'] | ''
}
export function emptyApiKeyAccountForm(): ApiKeyAccountForm {
return {
name: '',
base_url: '',
apiKey: '',
transport: 'http',
+1 -1
View File
@@ -1,4 +1,4 @@
# v3.37.0
# v3.37.2
## CPR 互联
+1 -1
View File
@@ -1 +1 @@
version: "3.37.0"
version: "3.37.2"