mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
641f43b303 | ||
|
|
6ce723ce93 | ||
|
|
2ff43c73da | ||
|
|
cb79f4d5a5 | ||
|
|
1e57805598 | ||
|
|
aca75490cb | ||
|
|
6a95869a72 |
@@ -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, ®istry, 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(), ®istry, 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(), ®istry, 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(), ®istry, 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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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` 不会再次应用
|
||||
|
||||
@@ -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`;仅重启不会恢复健康,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-10
@@ -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'],
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
# v3.37.0
|
||||
# v3.37.2
|
||||
|
||||
## CPR 互联
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.37.0"
|
||||
version: "3.37.2"
|
||||
|
||||
Reference in New Issue
Block a user