mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(accounts): add per-account model restrictions (#105)
Add per-account model allowlists and denylists to frozen routing, account settings, and CPR import/export. Preserve existing scheduling and account state boundaries. Closes #97 Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -25,6 +25,7 @@ pub struct AccountImportSettings {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
@@ -100,6 +101,7 @@ pub struct AccountRecord {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
@@ -224,6 +226,7 @@ pub struct UpdateAccount {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
@@ -235,14 +238,15 @@ pub struct AccountUpdateResult {
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 一批账号可编辑事实的一次性替换命令。
|
||||
/// 仅修改显式字段的批量账号设置命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
pub weight: Option<AccountWeight>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
|
||||
@@ -570,7 +570,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
if !enabled {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
|
||||
@@ -1334,6 +1334,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
|
||||
&context("update-request"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1372,6 +1373,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
|
||||
&context("update-failure"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1414,12 +1416,13 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(Vec::new()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -2406,6 +2409,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: "acct_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
@@ -2448,6 +2452,7 @@ fn prepared_create_with_id(
|
||||
) -> PreparedCredentialCreate {
|
||||
let now = Utc::now();
|
||||
PreparedCredentialCreate {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
|
||||
provider_kind,
|
||||
@@ -2740,6 +2745,7 @@ fn unsupported() -> ProviderAdminError {
|
||||
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
|
||||
gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: Some("团队备用".to_owned()),
|
||||
model_access: Default::default(),
|
||||
enabled: false,
|
||||
concurrency_limit: Some(
|
||||
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
|
||||
|
||||
@@ -39,6 +39,7 @@ pub struct AccountImportSettingsRequest {
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ impl AccountImportSettingsRequest {
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
@@ -220,6 +222,7 @@ pub struct UpdateAccountRequest {
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -245,6 +248,7 @@ impl UpdateAccountRequest {
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ pub(super) use presenter::quota_window_view;
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
|
||||
provider_document_value, require_account_id, validate_wire_group_ids,
|
||||
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
|
||||
require_account_id, validate_wire_group_ids,
|
||||
};
|
||||
use presenter::*;
|
||||
use wire::BatchUpdatedAccountsData;
|
||||
|
||||
@@ -104,6 +104,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
|
||||
@@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_nullable_limit")]
|
||||
pub concurrency_limit: Option<Option<u64>>,
|
||||
pub weight: Option<u64>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -91,6 +92,12 @@ impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<u64>>, D::Error> {
|
||||
Option::<u64>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
impl BatchUpdateAccountsRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.account_ids.is_empty()
|
||||
@@ -103,9 +110,23 @@ impl BatchUpdateAccountsRequest {
|
||||
{
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
if let Some(group_ids) = &self.group_ids {
|
||||
validate_wire_group_ids(group_ids)?;
|
||||
}
|
||||
self.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
&& self.group_ids.is_none()
|
||||
&& self.model_access.is_none()
|
||||
&& self.outbound_proxy_id.is_none()
|
||||
&& self.outbound_proxy_url.is_none()
|
||||
{
|
||||
return Err(WireValidationError::new("accountSettings"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,9 +136,17 @@ impl BatchUpdateAccountsRequest {
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
concurrency_limit: self
|
||||
.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?,
|
||||
weight: self.weight.map(parse_account_weight).transpose()?,
|
||||
model_access: self.model_access,
|
||||
group_ids: self
|
||||
.group_ids
|
||||
.as_deref()
|
||||
.map(validate_wire_group_ids)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -261,6 +290,7 @@ pub struct AccountView {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<u32>,
|
||||
pub weight: u16,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub access_token_expires_at: Option<String>,
|
||||
pub access_token_expires_at_display: Option<String>,
|
||||
pub refresh_token_expires_at: Option<String>,
|
||||
|
||||
@@ -319,31 +319,29 @@ mod batch_update {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_require_enabled_and_reject_unknown_fields() {
|
||||
fn batch_update_model_access_preserves_omitted_settings_and_rejects_unknown_fields() {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"], "modelAccess": {"mode":"allowlist","models":["test-luna"]}
|
||||
}))
|
||||
.expect("policy-only update");
|
||||
request.validate().expect("valid update");
|
||||
assert!(request.enabled.is_none());
|
||||
assert!(request.weight.is_none());
|
||||
assert!(request.group_ids.is_none());
|
||||
assert!(request.concurrency_limit.is_none());
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": [],
|
||||
"legacy": true
|
||||
"accountIds": ["acct_test"], "enabled": true, "legacy": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let empty: BatchUpdateAccountsRequest =
|
||||
serde_json::from_value(json!({"accountIds":["acct_test"]})).expect("empty mutation");
|
||||
assert!(empty.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_reject_invalid_scheduling_bounds_and_missing_fields() {
|
||||
fn batch_update_should_reject_invalid_scheduling_bounds_and_distinguish_clear_from_preserve() {
|
||||
for (concurrency_limit, weight, field) in [
|
||||
(json!(0), json!(1), "concurrencyLimit"),
|
||||
(json!(4294967296_u64), json!(1), "concurrencyLimit"),
|
||||
@@ -351,36 +349,20 @@ mod batch_update {
|
||||
(json!(null), json!(101), "weight"),
|
||||
] {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": concurrency_limit,
|
||||
"weight": weight,
|
||||
"groupIds": []
|
||||
}))
|
||||
.expect("deserialize invalid scheduling");
|
||||
"accountIds": ["acct_test"], "concurrencyLimit": concurrency_limit, "weight": weight,
|
||||
})).expect("deserialize bounds");
|
||||
assert_eq!(
|
||||
request.validate().expect_err("reject scheduling").field(),
|
||||
request.validate().expect_err("reject bounds").field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let cleared: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"], "concurrencyLimit": null
|
||||
}))
|
||||
.expect("clear concurrency override");
|
||||
cleared.validate().expect("valid clear");
|
||||
assert_eq!(cleared.concurrency_limit, Some(None));
|
||||
assert!(cleared.weight.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -470,6 +470,7 @@ mod provider {
|
||||
AccountPageItem {
|
||||
account: AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
id: "acct_error_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(provider).unwrap(),
|
||||
groups: Vec::new(),
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
mod error;
|
||||
mod model;
|
||||
mod model_access;
|
||||
pub use model_access::{
|
||||
AccountModelAccess, AccountModelAccessMode, InvalidAccountModelAccess,
|
||||
MAX_ACCOUNT_ACCESS_MODELS,
|
||||
};
|
||||
mod proxy;
|
||||
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
|
||||
pub mod scope;
|
||||
|
||||
@@ -669,6 +669,7 @@ pub struct ProviderAccount {
|
||||
enabled: bool,
|
||||
concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
weight: AccountWeight,
|
||||
model_access: super::AccountModelAccess,
|
||||
credential_state: CredentialState,
|
||||
quota: QuotaState,
|
||||
last_error_reason: Option<AccountErrorReason>,
|
||||
@@ -704,6 +705,7 @@ impl ProviderAccount {
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
model_access: super::AccountModelAccess::all(),
|
||||
credential_state: CredentialState::Unknown,
|
||||
quota: QuotaState::unknown(),
|
||||
last_error_reason: None,
|
||||
@@ -728,6 +730,17 @@ impl ProviderAccount {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_model_access(mut self, model_access: super::AccountModelAccess) -> Self {
|
||||
self.model_access = model_access;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_access(&self) -> &super::AccountModelAccess {
|
||||
&self.model_access
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
|
||||
self.outbound_proxy = proxy;
|
||||
@@ -1007,6 +1020,8 @@ impl ProviderRefreshQuery {
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct NewProviderAccount {
|
||||
pub account: ProviderAccount,
|
||||
/// 导入时显式提供的政策;省略时保留已有账号设置。
|
||||
pub model_access: Option<super::AccountModelAccess>,
|
||||
pub credential: PlaintextCredential,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 管理员配置的账号模型政策,与上游模型权限分开保存和判断。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::validation::validate_text;
|
||||
|
||||
/// 一个账号最多配置的精确模型 ID 数量。
|
||||
pub const MAX_ACCOUNT_ACCESS_MODELS: usize = 256;
|
||||
|
||||
/// 账号模型政策的匹配方式。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccountModelAccessMode {
|
||||
#[default]
|
||||
All,
|
||||
Allowlist,
|
||||
Denylist,
|
||||
}
|
||||
|
||||
/// 已校验的账号模型政策;私有字段保证所有边界使用相同的匹配规则。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct AccountModelAccess {
|
||||
mode: AccountModelAccessMode,
|
||||
models: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// 不携带原始输入,避免在错误边界回显配置正文。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[error(
|
||||
"modelAccess requires all with no models, or allowlist/denylist with 1–256 exact model IDs (at most 256 bytes each)"
|
||||
)]
|
||||
pub struct InvalidAccountModelAccess;
|
||||
|
||||
impl Default for AccountModelAccess {
|
||||
fn default() -> Self {
|
||||
Self::all()
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountModelAccess {
|
||||
#[must_use]
|
||||
pub const fn all() -> Self {
|
||||
Self {
|
||||
mode: AccountModelAccessMode::All,
|
||||
models: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
mode: AccountModelAccessMode,
|
||||
models: Vec<String>,
|
||||
) -> Result<Self, InvalidAccountModelAccess> {
|
||||
if models.len() > MAX_ACCOUNT_ACCESS_MODELS
|
||||
|| (mode == AccountModelAccessMode::All) != models.is_empty()
|
||||
|| models.iter().any(|model| {
|
||||
model.trim() != model
|
||||
|| model.trim().is_empty()
|
||||
|| model.contains('*')
|
||||
|| validate_text(model, 256, true, None).is_err()
|
||||
})
|
||||
{
|
||||
return Err(InvalidAccountModelAccess);
|
||||
}
|
||||
Ok(Self {
|
||||
mode,
|
||||
models: models.into_iter().collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn mode(&self) -> AccountModelAccessMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn models(&self) -> &BTreeSet<String> {
|
||||
&self.models
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allows(&self, upstream_model: &str) -> bool {
|
||||
match self.mode {
|
||||
AccountModelAccessMode::All => true,
|
||||
AccountModelAccessMode::Allowlist => self.models.contains(upstream_model),
|
||||
AccountModelAccessMode::Denylist => !self.models.contains(upstream_model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ModelAccessDocument {
|
||||
mode: AccountModelAccessMode,
|
||||
models: Vec<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AccountModelAccess {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let document = ModelAccessDocument::deserialize(deserializer)?;
|
||||
Self::new(document.mode, document.models).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ impl fmt::Display for AccountGroupId {
|
||||
pub struct RuntimeAccount {
|
||||
provider_kind: ProviderKind,
|
||||
group_ids: Arc<BTreeSet<AccountGroupId>>,
|
||||
model_access: super::AccountModelAccess,
|
||||
}
|
||||
|
||||
impl RuntimeAccount {
|
||||
@@ -55,9 +56,21 @@ impl RuntimeAccount {
|
||||
Self {
|
||||
provider_kind,
|
||||
group_ids: Arc::new(group_ids),
|
||||
model_access: super::AccountModelAccess::all(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_model_access(mut self, model_access: super::AccountModelAccess) -> Self {
|
||||
self.model_access = model_access;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn model_access(&self) -> &super::AccountModelAccess {
|
||||
&self.model_access
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
@@ -266,6 +279,23 @@ impl FrozenAccountScope {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allows_model(&self, account_id: &ProviderAccountId, upstream_model: &str) -> bool {
|
||||
self.allows(account_id)
|
||||
&& self
|
||||
.directory
|
||||
.account(account_id)
|
||||
.is_some_and(|account| account.model_access.allows(upstream_model))
|
||||
}
|
||||
|
||||
/// 目录按整个授权账号池过滤,不能只看用于获取元数据的账号政策。
|
||||
#[must_use]
|
||||
pub fn allows_provider_model(&self, provider: &ProviderKind, upstream_model: &str) -> bool {
|
||||
self.directory.accounts.iter().any(|(id, account)| {
|
||||
account.provider_kind() == provider && self.allows_model(id, upstream_model)
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn provider_kinds(&self) -> &BTreeSet<ProviderKind> {
|
||||
match &self.client_scope {
|
||||
|
||||
@@ -1016,6 +1016,12 @@ impl ExecutionService for DefaultExecutionService {
|
||||
.await?
|
||||
else {
|
||||
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
|
||||
if !scope.allows_provider_model(
|
||||
kind,
|
||||
&client.snapshot.mapped_model(profile.model().as_str()),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(profile.model().clone()) {
|
||||
result.push(PublicModelDescriptor::Adapted(profile));
|
||||
}
|
||||
@@ -1029,6 +1035,9 @@ impl ExecutionService for DefaultExecutionService {
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
for entry in &models {
|
||||
let target = client.snapshot.mapped_model(entry.model.as_str());
|
||||
if !scope.allows_provider_model(kind, &target) {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = by_id.get(target.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1050,6 +1059,9 @@ impl ExecutionService for DefaultExecutionService {
|
||||
}
|
||||
for model in client.snapshot.public_models_for_provider(kind) {
|
||||
let target = client.snapshot.mapped_model(model.as_str());
|
||||
if !scope.allows_provider_model(kind, &target) {
|
||||
continue;
|
||||
}
|
||||
if target == model.as_str() || seen.contains(&model) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ impl SnapshotAccountGroupFacts {
|
||||
pub struct SnapshotProviderAccountFacts {
|
||||
account_id: ProviderAccountId,
|
||||
provider_kind: String,
|
||||
model_access: crate::account::AccountModelAccess,
|
||||
}
|
||||
|
||||
impl SnapshotProviderAccountFacts {
|
||||
@@ -130,10 +131,19 @@ impl SnapshotProviderAccountFacts {
|
||||
Self {
|
||||
account_id,
|
||||
provider_kind: provider_kind.into(),
|
||||
model_access: crate::account::AccountModelAccess::all(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotProviderAccountFacts {
|
||||
#[must_use]
|
||||
pub fn with_model_access(mut self, model_access: crate::account::AccountModelAccess) -> Self {
|
||||
self.model_access = model_access;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Store 读取到的一条分组成员关系。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SnapshotAccountGroupMemberFacts {
|
||||
@@ -327,7 +337,8 @@ async fn compile_runtime_snapshot(
|
||||
|| accounts
|
||||
.insert(
|
||||
account.account_id.clone(),
|
||||
RuntimeAccount::new(provider_kind, BTreeSet::new()),
|
||||
RuntimeAccount::new(provider_kind, BTreeSet::new())
|
||||
.with_model_access(account.model_access),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
@@ -352,7 +363,8 @@ async fn compile_runtime_snapshot(
|
||||
let account = accounts
|
||||
.get_mut(&account_id)
|
||||
.ok_or(RuntimeSnapshotCompileError::InvalidData)?;
|
||||
*account = RuntimeAccount::new(account.provider_kind().clone(), group_ids);
|
||||
*account = RuntimeAccount::new(account.provider_kind().clone(), group_ids)
|
||||
.with_model_access(account.model_access().clone());
|
||||
}
|
||||
let account_directory = Arc::new(RuntimeAccountDirectory::new(accounts));
|
||||
|
||||
@@ -691,7 +703,13 @@ impl RuntimeSnapshot {
|
||||
scope
|
||||
.provider_kinds()
|
||||
.iter()
|
||||
.flat_map(|provider| self.public_models_for_provider(provider))
|
||||
.flat_map(|provider| {
|
||||
self.public_models_for_provider(provider)
|
||||
.into_iter()
|
||||
.filter(|model| {
|
||||
scope.allows_provider_model(provider, &self.mapped_model(model.as_str()))
|
||||
})
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
@@ -706,6 +724,11 @@ impl RuntimeSnapshot {
|
||||
let mut profiles = BTreeMap::new();
|
||||
for provider in scope.provider_kinds() {
|
||||
for profile in self.public_model_profiles_for_provider(provider) {
|
||||
if !scope
|
||||
.allows_provider_model(provider, &self.mapped_model(profile.model().as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
profiles
|
||||
.entry(profile.model().clone())
|
||||
.or_insert_with(|| profile.presentation().clone());
|
||||
@@ -740,10 +763,10 @@ impl RuntimeSnapshot {
|
||||
public_model: &PublicModelId,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> bool {
|
||||
scope
|
||||
.provider_kinds()
|
||||
.iter()
|
||||
.any(|provider| self.contains_public_model_for_provider(public_model, provider))
|
||||
scope.provider_kinds().iter().any(|provider| {
|
||||
self.contains_public_model_for_provider(public_model, provider)
|
||||
&& scope.allows_provider_model(provider, &self.mapped_model(public_model.as_str()))
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod model_access;
|
||||
mod proxy;
|
||||
mod selection;
|
||||
mod selection_diagnostics;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
use gateway_core::account::AccountModelAccess;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn account_model_access_document_rejects_invalid_or_unbounded_settings() {
|
||||
for invalid in [
|
||||
json!({"mode":"invalid","models":[]}),
|
||||
json!({"mode":"all","models":["test-model"]}),
|
||||
json!({"mode":"allowlist","models":[]}),
|
||||
json!({"mode":"denylist","models":[]}),
|
||||
json!({"mode":"allowlist","models":["gpt-*"]}),
|
||||
json!({"mode":"allowlist","models":[" test-model"]}),
|
||||
json!({"mode":"allowlist","models":["\n"]}),
|
||||
json!({"mode":"allowlist","models":["__internal"]}),
|
||||
json!({"mode":"allowlist","models":["x".repeat(257)]}),
|
||||
json!({"mode":"allowlist","models":vec!["test-model";257]}),
|
||||
json!({"mode":"all","models":[],"unknown":true}),
|
||||
json!({"mode":"all"}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<AccountModelAccess>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_model_access_document_deduplicates_without_changing_exact_ids() {
|
||||
let policy: AccountModelAccess = serde_json::from_value(json!({
|
||||
"mode":"allowlist","models":["test-luna","test-luna","Test-Luna"]
|
||||
}))
|
||||
.expect("policy");
|
||||
assert_eq!(
|
||||
serde_json::to_value(policy).expect("serialize"),
|
||||
json!({
|
||||
"mode":"allowlist","models":["Test-Luna","test-luna"]
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -654,3 +654,60 @@ fn alias_should_only_be_available_from_a_provider_with_its_mapped_model() {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_model_access_filters_catalog_and_aliases_using_the_whole_frozen_pool() {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let restricted =
|
||||
AccountModelAccess::new(AccountModelAccessMode::Denylist, vec!["gpt-5.5".to_owned()])
|
||||
.expect("policy");
|
||||
let directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
|
||||
ProviderAccountId::new("acct_restricted").expect("id"),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new())
|
||||
.with_model_access(restricted.clone()),
|
||||
)])));
|
||||
let old_scope = FrozenAccountScope::new(directory, ClientRoutingScope::all_accounts());
|
||||
let snapshot = snapshot();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.public_models_for_scope(&old_scope)
|
||||
.iter()
|
||||
.map(PublicModelId::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["grok-latest"]
|
||||
);
|
||||
assert!(!snapshot.contains_public_model_for_scope(
|
||||
&PublicModelId::new("gpt-5.4").expect("alias"),
|
||||
&old_scope
|
||||
));
|
||||
let new_scope = FrozenAccountScope::new(
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
|
||||
(
|
||||
ProviderAccountId::new("acct_restricted").expect("id"),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new())
|
||||
.with_model_access(restricted),
|
||||
),
|
||||
(
|
||||
ProviderAccountId::new("acct_unrestricted").expect("id"),
|
||||
RuntimeAccount::new(provider, BTreeSet::new()),
|
||||
),
|
||||
]))),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.public_models_for_scope(&new_scope)
|
||||
.iter()
|
||||
.map(PublicModelId::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["gpt-5.4", "gpt-5.5", "grok-latest"]
|
||||
);
|
||||
assert!(
|
||||
!snapshot.contains_public_model_for_scope(
|
||||
&PublicModelId::new("gpt-5.5").expect("model"),
|
||||
&old_scope
|
||||
),
|
||||
"a new policy must not alter an existing request scope"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ impl AccountGroupStore for PgAccountGroupRepository {
|
||||
account.authentication_kind, account.credential_revision, account.outbound_proxy_url,
|
||||
account.has_refresh_token, account.access_token_expires_at,
|
||||
account.next_refresh_at, account.enabled, account.concurrency_limit,
|
||||
account.weight, account.credential_state, account.quota_access_state,
|
||||
account.weight, account.model_access_json, account.credential_state, account.quota_access_state,
|
||||
account.quota_evidence, account.quota_access_observed_at,
|
||||
account.quota_reset_at, account.last_error_reason,
|
||||
account.last_error_message, account.credential_observed_at,
|
||||
|
||||
@@ -226,6 +226,15 @@ impl PgAdminAccountStore {
|
||||
.collect::<StoreResult<Vec<_>>>()
|
||||
.map_err(|error| admin_store_error(ENTITY, error))?;
|
||||
let mut changed_fields = vec!["credentials".to_owned()];
|
||||
if settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.model_access.is_some())
|
||||
|| accounts
|
||||
.iter()
|
||||
.any(|account| account.model_access.is_some())
|
||||
{
|
||||
changed_fields.push("model_access".to_owned());
|
||||
}
|
||||
if let Some(settings) = &settings {
|
||||
changed_fields
|
||||
.extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned));
|
||||
@@ -670,6 +679,9 @@ impl AccountStore for PgAdminAccountStore {
|
||||
"weight".to_owned(),
|
||||
"groups".to_owned(),
|
||||
];
|
||||
if command.model_access.is_some() {
|
||||
changed_fields.push("model_access".to_owned());
|
||||
}
|
||||
if command.outbound_proxy.is_some() {
|
||||
changed_fields.push("outbound_proxy".to_owned());
|
||||
}
|
||||
@@ -681,10 +693,11 @@ impl AccountStore for PgAdminAccountStore {
|
||||
.batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin {
|
||||
account_ids: vec![command.account_id.clone()],
|
||||
notes: command.notes,
|
||||
enabled: command.enabled,
|
||||
concurrency_limit: command.concurrency_limit,
|
||||
weight: command.weight,
|
||||
group_ids: command.group_ids,
|
||||
enabled: Some(command.enabled),
|
||||
concurrency_limit: Some(command.concurrency_limit),
|
||||
weight: Some(command.weight),
|
||||
model_access: command.model_access,
|
||||
group_ids: Some(command.group_ids),
|
||||
outbound_proxy: command.outbound_proxy,
|
||||
audit: mutation_audit(
|
||||
context,
|
||||
@@ -761,12 +774,20 @@ impl AccountStore for PgAdminAccountStore {
|
||||
} else {
|
||||
"provider_accounts".to_owned()
|
||||
};
|
||||
let mut changed_fields = vec![
|
||||
"enabled".to_owned(),
|
||||
"concurrency_limit".to_owned(),
|
||||
"weight".to_owned(),
|
||||
"groups".to_owned(),
|
||||
];
|
||||
let mut changed_fields = Vec::new();
|
||||
for (changed, field) in [
|
||||
(command.enabled.is_some(), "enabled"),
|
||||
(command.concurrency_limit.is_some(), "concurrency_limit"),
|
||||
(command.weight.is_some(), "weight"),
|
||||
(command.group_ids.is_some(), "groups"),
|
||||
] {
|
||||
if changed {
|
||||
changed_fields.push(field.to_owned());
|
||||
}
|
||||
}
|
||||
if command.model_access.is_some() {
|
||||
changed_fields.push("model_access".to_owned());
|
||||
}
|
||||
if command.outbound_proxy.is_some() {
|
||||
changed_fields.push("outbound_proxy".to_owned());
|
||||
}
|
||||
@@ -778,6 +799,7 @@ impl AccountStore for PgAdminAccountStore {
|
||||
enabled: command.enabled,
|
||||
concurrency_limit: command.concurrency_limit,
|
||||
weight: command.weight,
|
||||
model_access: command.model_access,
|
||||
group_ids: command.group_ids,
|
||||
outbound_proxy: command.outbound_proxy,
|
||||
audit: mutation_audit(
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(crate) async fn load_admin_account_page(
|
||||
select a.outbound_proxy_url, a.id, a.provider_kind, a.name, a.notes, a.email, a.upstream_user_id,
|
||||
a.upstream_account_id, a.plan_type, a.authentication_kind,
|
||||
a.credential_revision, a.has_refresh_token, a.access_token_expires_at,
|
||||
a.next_refresh_at, a.enabled, a.concurrency_limit, a.weight,
|
||||
a.next_refresh_at, a.enabled, a.concurrency_limit, a.weight, a.model_access_json,
|
||||
a.credential_state, a.quota_access_state, a.quota_evidence,
|
||||
a.quota_access_observed_at, a.quota_reset_at, a.last_error_reason,
|
||||
a.last_error_message, a.credential_observed_at, a.created_at, a.updated_at,
|
||||
|
||||
@@ -43,6 +43,11 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
enabled: account.account.enabled(),
|
||||
concurrency_limit: account.account.concurrency_limit(),
|
||||
weight: account.account.weight(),
|
||||
model_access: Some(
|
||||
account
|
||||
.model_access
|
||||
.unwrap_or_else(|| account.account.model_access().clone()),
|
||||
),
|
||||
credential_state: account.account.credential_state(),
|
||||
credential_observed_at: Utc::now(),
|
||||
})
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) fn admin_account_record(
|
||||
enabled: summary.enabled,
|
||||
concurrency_limit: summary.concurrency_limit,
|
||||
weight: summary.weight,
|
||||
model_access: summary.model_access,
|
||||
credential_state: summary.credential_state,
|
||||
credential_observed_at: summary.credential_observed_at,
|
||||
quota: summary.quota,
|
||||
@@ -60,6 +61,7 @@ pub(crate) fn prepared_account(
|
||||
enabled: credential.enabled,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
model_access: credential.model_access,
|
||||
credential_state: credential.credential_state,
|
||||
credential_observed_at: credential.credential_observed_at,
|
||||
})
|
||||
|
||||
@@ -110,7 +110,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
let rows = sqlx::query(
|
||||
"select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token,
|
||||
access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
|
||||
access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state,
|
||||
credential_observed_at, quota_access_state, quota_evidence,
|
||||
quota_access_observed_at, quota_reset_at,
|
||||
quota_observed_at, last_error_reason, last_error_message, created_at, updated_at
|
||||
@@ -151,11 +151,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
|
||||
concurrency_limit, weight, credential_state, provider_quota_json,
|
||||
concurrency_limit, weight, model_access_json, credential_state, provider_quota_json,
|
||||
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
|
||||
) values (
|
||||
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
$14, $15, coalesce($20, '{\"mode\":\"all\",\"models\":[]}'::jsonb), $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
)",
|
||||
)
|
||||
.bind(account.id)
|
||||
@@ -177,6 +177,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
.bind(account.credential_observed_at)
|
||||
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
|
||||
.bind(proxy_id)
|
||||
.bind(account.model_access.as_ref().map(sqlx::types::Json))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("insert provider account"))?;
|
||||
@@ -492,9 +493,10 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
update_provider_accounts_scheduling_in_transaction(
|
||||
&mut transaction,
|
||||
&unique_ids,
|
||||
settings.enabled,
|
||||
settings.concurrency_limit,
|
||||
settings.weight,
|
||||
Some(settings.enabled),
|
||||
Some(settings.concurrency_limit),
|
||||
Some(settings.weight),
|
||||
settings.model_access.as_ref(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
@@ -576,7 +578,9 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
command: BatchUpdateProviderAccountsAdmin,
|
||||
) -> StoreResult<Revision> {
|
||||
validate_batch_update_account_ids(&command.account_ids)?;
|
||||
validate_batch_update_group_ids(&command.group_ids)?;
|
||||
if let Some(group_ids) = &command.group_ids {
|
||||
validate_batch_update_group_ids(group_ids)?;
|
||||
}
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
@@ -590,15 +594,18 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
command.enabled,
|
||||
command.concurrency_limit,
|
||||
command.weight,
|
||||
command.model_access.as_ref(),
|
||||
command.outbound_proxy.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
replace_account_group_assignments_in_transaction(
|
||||
&mut transaction,
|
||||
&command.account_ids,
|
||||
&command.group_ids,
|
||||
)
|
||||
.await?;
|
||||
if let Some(group_ids) = &command.group_ids {
|
||||
replace_account_group_assignments_in_transaction(
|
||||
&mut transaction,
|
||||
&command.account_ids,
|
||||
group_ids,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
if let Some(notes) = command.notes.as_deref() {
|
||||
update_provider_account_notes_in_transaction(
|
||||
&mut transaction,
|
||||
@@ -778,17 +785,18 @@ pub(crate) async fn upsert_provider_account_in_transaction(
|
||||
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
|
||||
concurrency_limit, weight, credential_state, provider_quota_json,
|
||||
concurrency_limit, weight, model_access_json, credential_state, provider_quota_json,
|
||||
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
|
||||
) values (
|
||||
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
$14, $15, coalesce($20, '{\"mode\":\"all\",\"models\":[]}'::jsonb), $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
)
|
||||
on conflict (
|
||||
provider_kind,
|
||||
upstream_user_id,
|
||||
(coalesce(upstream_account_id, ''))
|
||||
) do update set
|
||||
model_access_json = coalesce($20, provider_accounts.model_access_json),
|
||||
name = excluded.name,
|
||||
email = excluded.email,
|
||||
plan_type = excluded.plan_type,
|
||||
@@ -833,6 +841,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
|
||||
.bind(account.credential_observed_at)
|
||||
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
|
||||
.bind(proxy_id)
|
||||
.bind(account.model_access.as_ref().map(sqlx::types::Json))
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -936,9 +945,10 @@ pub(crate) async fn rotate_provider_account_in_transaction(
|
||||
pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
account_ids: &[String],
|
||||
enabled: bool,
|
||||
concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
weight: AccountWeight,
|
||||
enabled: Option<bool>,
|
||||
concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
weight: Option<AccountWeight>,
|
||||
model_access: Option<&gateway_core::account::AccountModelAccess>,
|
||||
outbound_proxy: Option<&gateway_admin::model::proxies::AccountProxySelection>,
|
||||
) -> StoreResult<()> {
|
||||
let (proxy_id, proxy) = match outbound_proxy {
|
||||
@@ -949,19 +959,22 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
|
||||
};
|
||||
let updated = sqlx::query_scalar::<_, String>(
|
||||
"update provider_accounts
|
||||
set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = greatest(now(), updated_at),
|
||||
set enabled = coalesce($2, enabled), concurrency_limit = case when $9 then $3 else concurrency_limit end, weight = coalesce($4, weight), updated_at = greatest(now(), updated_at),
|
||||
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end,
|
||||
outbound_proxy_id = case when $5 then $7 else outbound_proxy_id end
|
||||
outbound_proxy_id = case when $5 then $7 else outbound_proxy_id end,
|
||||
model_access_json = coalesce($8, model_access_json)
|
||||
where id = any($1::text[])
|
||||
returning id",
|
||||
)
|
||||
.bind(account_ids)
|
||||
.bind(enabled)
|
||||
.bind(concurrency_limit.map(|limit| i64::from(limit.get())))
|
||||
.bind(i16::try_from(weight.get()).map_err(|_| invalid("invalid weight"))?)
|
||||
.bind(concurrency_limit.flatten().map(|limit| i64::from(limit.get())))
|
||||
.bind(weight.map(|weight| i16::try_from(weight.get())).transpose().map_err(|_| invalid("invalid weight"))?)
|
||||
.bind(outbound_proxy.is_some())
|
||||
.bind(proxy.as_ref().map(gateway_core::account::OutboundProxy::expose_url))
|
||||
.bind(proxy_id)
|
||||
.bind(model_access.map(sqlx::types::Json))
|
||||
.bind(concurrency_limit.is_some())
|
||||
.fetch_all(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("set provider accounts state in admin transaction"))?
|
||||
|
||||
@@ -137,6 +137,7 @@ pub struct ProviderAccountSummary {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
@@ -185,6 +186,7 @@ pub struct NewProviderAccount {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -318,10 +320,11 @@ pub struct BatchUpdateProviderAccountsAdmin {
|
||||
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
pub weight: Option<AccountWeight>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<AccountGroupId>>,
|
||||
pub audit: AdminAuditEvent,
|
||||
}
|
||||
|
||||
@@ -384,7 +387,7 @@ impl ProviderAccountStateUpdate {
|
||||
|
||||
pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state,
|
||||
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
|
||||
last_error_reason, last_error_message,
|
||||
credential_observed_at, quota_observed_at, created_at, updated_at
|
||||
@@ -392,7 +395,7 @@ pub(crate) const ACCOUNT_SELECT: &str = "select outbound_proxy_url, id, provider
|
||||
|
||||
pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state,
|
||||
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
|
||||
last_error_reason, last_error_message,
|
||||
credential_observed_at, quota_observed_at, created_at, updated_at
|
||||
@@ -402,7 +405,7 @@ pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select outbound_proxy_url, id, p
|
||||
|
||||
pub(crate) const REFRESH_CANDIDATES_SELECT: &str = "select outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, credential_state,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state,
|
||||
provider_quota_json, quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at,
|
||||
last_error_reason, last_error_message,
|
||||
credential_observed_at, quota_observed_at, created_at, updated_at
|
||||
@@ -475,6 +478,7 @@ pub(crate) fn core_account_from_summary(
|
||||
summary.last_error_message,
|
||||
)
|
||||
.with_scheduling(summary.concurrency_limit, summary.weight)
|
||||
.with_model_access(summary.model_access)
|
||||
.with_outbound_proxy(summary.outbound_proxy)
|
||||
.with_refresh_schedule(
|
||||
summary.has_refresh_token,
|
||||
@@ -556,6 +560,11 @@ pub(crate) fn account_summary_from_row(
|
||||
enabled: get(&row, "enabled")?,
|
||||
concurrency_limit,
|
||||
weight,
|
||||
model_access: get::<sqlx::types::Json<gateway_core::account::AccountModelAccess>>(
|
||||
&row,
|
||||
"model_access_json",
|
||||
)?
|
||||
.0,
|
||||
credential_state: parse_credential_state(&credential_state)?,
|
||||
credential_observed_at: get(&row, "credential_observed_at")?,
|
||||
quota,
|
||||
|
||||
@@ -55,6 +55,7 @@ pub struct SnapshotAccountGroupData {
|
||||
pub struct SnapshotProviderAccountData {
|
||||
pub id: String,
|
||||
pub provider_kind: String,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -178,7 +179,10 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
|
||||
.into_iter()
|
||||
.map(|account| {
|
||||
ProviderAccountId::new(account.id)
|
||||
.map(|id| SnapshotProviderAccountFacts::new(id, account.provider_kind))
|
||||
.map(|id| {
|
||||
SnapshotProviderAccountFacts::new(id, account.provider_kind)
|
||||
.with_model_access(account.model_access)
|
||||
})
|
||||
.map_err(|_| SnapshotStoreError::unavailable())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -315,15 +319,26 @@ async fn load_account_groups(
|
||||
async fn load_provider_accounts(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> StoreResult<Vec<SnapshotProviderAccountData>> {
|
||||
sqlx::query_as::<_, (String, String)>(
|
||||
"select id, provider_kind from provider_accounts order by id",
|
||||
)
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
String,
|
||||
sqlx::types::Json<gateway_core::account::AccountModelAccess>,
|
||||
),
|
||||
>("select id, provider_kind, model_access_json from provider_accounts order by id")
|
||||
.fetch_all(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("load snapshot provider accounts"))
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(|(id, provider_kind)| SnapshotProviderAccountData { id, provider_kind })
|
||||
.map(
|
||||
|(id, provider_kind, model_access)| SnapshotProviderAccountData {
|
||||
id,
|
||||
provider_kind,
|
||||
model_access: model_access.0,
|
||||
},
|
||||
)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1049,6 +1049,7 @@ async fn terminal_admin_mutations_keep_revision_account_and_audit_atomic() {
|
||||
.update_account(
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_terminal_mutation".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1129,6 +1130,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
|
||||
};
|
||||
let command = UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
account_id: "acct_proxy".to_owned(),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
@@ -1206,6 +1208,7 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() {
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
outbound_proxy: None,
|
||||
model_access: None,
|
||||
};
|
||||
store
|
||||
.update_account(command.clone(), &context)
|
||||
@@ -1240,10 +1243,11 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() {
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
account_ids: vec!["acct_notes".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(vec![]),
|
||||
model_access: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
&context,
|
||||
@@ -1281,6 +1285,7 @@ async fn account_notes_round_trip_and_survive_import_and_scheduling_updates() {
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
model_access: None,
|
||||
}),
|
||||
outbound_proxy: None,
|
||||
audit: audit("audit_notes_reimport", "import", "acct_notes"),
|
||||
@@ -1352,6 +1357,7 @@ async fn invalid_account_notes_roll_back_scheduling_revision_and_audit() {
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
outbound_proxy: None,
|
||||
model_access: None,
|
||||
},
|
||||
&MutationContext {
|
||||
actor: MutationActor::System,
|
||||
@@ -1543,12 +1549,13 @@ async fn terminal_batch_update_replaces_state_and_groups_once_or_rolls_back_ever
|
||||
let result = store
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: account_ids.clone(),
|
||||
enabled: false,
|
||||
concurrency_limit: gateway_core::account::AccountConcurrencyLimit::new(7),
|
||||
weight: gateway_core::account::AccountWeight::new(25).expect("weight"),
|
||||
group_ids: vec![AccountGroupId::new(GROUP_ID).expect("group ID")],
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(gateway_core::account::AccountConcurrencyLimit::new(7)),
|
||||
weight: Some(gateway_core::account::AccountWeight::new(25).expect("weight")),
|
||||
group_ids: Some(vec![AccountGroupId::new(GROUP_ID).expect("group ID")]),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
@@ -1579,15 +1586,16 @@ async fn terminal_batch_update_replaces_state_and_groups_once_or_rolls_back_ever
|
||||
store
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: account_ids.clone(),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![
|
||||
enabled: Some(true),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(vec![
|
||||
AccountGroupId::new("grp_00000000000000000000000000000072")
|
||||
.expect("missing group ID"),
|
||||
],
|
||||
]),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
@@ -1775,6 +1783,7 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
|
||||
AuthorizationCommit {
|
||||
settings: Some(gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: Some(" OAuth 新建备注 ".to_owned()),
|
||||
model_access: Default::default(),
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::new(9).expect("weight"),
|
||||
@@ -1788,6 +1797,7 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
|
||||
AuthorizationOwnerBinding::from_context(&context),
|
||||
),
|
||||
credential: AuthorizationCredentialCommit::Create(PreparedCredentialCreate {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: ProviderAccountId::new("acct_authorization_candidate")
|
||||
.expect("candidate account ID"),
|
||||
@@ -1950,6 +1960,7 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
repository
|
||||
.insert_provider_account(NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: "acct_core_refresh".to_owned(),
|
||||
provider_kind: "xai".to_owned(),
|
||||
@@ -2289,12 +2300,13 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
let revision = repository
|
||||
.batch_update_provider_accounts_admin(BatchUpdateProviderAccountsAdmin {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_admin_a".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(Vec::new()),
|
||||
audit: audit("audit_account_disable", "disable", "acct_admin_a"),
|
||||
})
|
||||
.await
|
||||
@@ -2656,6 +2668,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
|
||||
|
||||
pub(super) fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
|
||||
NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: id.to_owned(),
|
||||
provider_kind: "openai".to_owned(),
|
||||
@@ -2856,6 +2869,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() {
|
||||
.update_account(
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
account_id: id.as_str().to_owned(),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
@@ -2913,6 +2927,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
|
||||
.expect("seed group");
|
||||
let settings = AccountImportSettings {
|
||||
notes: Some(" 批量新建\n团队备用 ".to_owned()),
|
||||
model_access: Default::default(),
|
||||
enabled: false,
|
||||
concurrency_limit: Some(AccountConcurrencyLimit::new(3).expect("concurrency")),
|
||||
weight: AccountWeight::new(7).expect("weight"),
|
||||
@@ -3020,3 +3035,88 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_access_only_batch_update_preserves_other_settings_and_survives_reimport() {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
let Some(database) = TestDatabase::create("account_model_access").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let mut input = account("acct_model_access", "user-model-access");
|
||||
input.weight = gateway_core::account::AccountWeight::new(23).expect("weight");
|
||||
input.concurrency_limit = gateway_core::account::AccountConcurrencyLimit::new(7);
|
||||
repository
|
||||
.insert_provider_account(input.clone())
|
||||
.await
|
||||
.expect("insert");
|
||||
let policy = AccountModelAccess::new(
|
||||
AccountModelAccessMode::Allowlist,
|
||||
vec!["test-luna".to_owned()],
|
||||
)
|
||||
.expect("policy");
|
||||
let store = admin_account_store(&database.pool);
|
||||
let command = BatchUpdateAccounts {
|
||||
account_ids: vec![input.id.clone()],
|
||||
enabled: None,
|
||||
concurrency_limit: None,
|
||||
weight: None,
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
model_access: Some(policy.clone()),
|
||||
};
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "request_model_access".to_owned(),
|
||||
};
|
||||
store
|
||||
.batch_update_accounts(command.clone(), &context)
|
||||
.await
|
||||
.expect("update policy only");
|
||||
let loaded = repository
|
||||
.load_provider_account(&input.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("account");
|
||||
assert_eq!(loaded.summary.model_access, policy);
|
||||
assert_eq!(loaded.summary.credential_revision.get(), 1);
|
||||
assert_eq!(loaded.summary.weight, input.weight);
|
||||
assert_eq!(loaded.summary.concurrency_limit, input.concurrency_limit);
|
||||
assert_eq!(loaded.summary.enabled, input.enabled);
|
||||
input.name = "reimported".to_owned();
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: "openai".to_owned(),
|
||||
},
|
||||
accounts: vec![input.clone()],
|
||||
audit: audit("audit_model_access", "import", &input.id),
|
||||
})
|
||||
.await
|
||||
.expect("reimport without policy");
|
||||
let loaded = repository
|
||||
.load_provider_account(&input.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("account");
|
||||
assert_eq!(loaded.summary.model_access, policy);
|
||||
store
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
model_access: Some(AccountModelAccess::all()),
|
||||
..command
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("explicit reset");
|
||||
let loaded = repository
|
||||
.load_provider_account(&input.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("account");
|
||||
assert_eq!(loaded.summary.model_access, AccountModelAccess::all());
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ fn success() -> ProxyTestResult {
|
||||
fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount {
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
account_id: account_id.to_owned(),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
@@ -633,11 +634,12 @@ async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() {
|
||||
admin
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
account_ids: vec!["acct_one".to_owned(), "acct_missing".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(AccountWeight::DEFAULT),
|
||||
group_ids: Some(vec![]),
|
||||
outbound_proxy: Some(AccountProxySelection::Url(
|
||||
OutboundProxy::parse("http://127.0.0.1:9090").unwrap()
|
||||
)),
|
||||
|
||||
@@ -673,9 +673,11 @@ fn prepared_create(
|
||||
) -> Result<PreparedCredentialCreate, ProviderAdminError> {
|
||||
let NewProviderAccount {
|
||||
account,
|
||||
model_access,
|
||||
credential,
|
||||
} = prepared;
|
||||
Ok(PreparedCredentialCreate {
|
||||
model_access,
|
||||
outbound_proxy: account.outbound_proxy().cloned(),
|
||||
account_id: account.id().clone(),
|
||||
provider_kind: account.provider().clone(),
|
||||
@@ -793,6 +795,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
|
||||
account.last_error_reason,
|
||||
account.last_error_message.clone(),
|
||||
)
|
||||
.with_model_access(account.model_access.clone())
|
||||
.with_refresh_schedule(
|
||||
account.has_refresh_token,
|
||||
account.next_refresh_at.map(SystemTime::from),
|
||||
|
||||
@@ -121,6 +121,7 @@ impl fmt::Debug for PreparedCodexAccountImport {
|
||||
}
|
||||
|
||||
struct ParsedCodexImportAccount {
|
||||
model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
authentication: ParsedCodexAuthentication,
|
||||
@@ -220,6 +221,7 @@ impl fmt::Debug for CodexCprExportDocument {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexCprExportCommon {
|
||||
model_access: gateway_core::account::AccountModelAccess,
|
||||
id: String,
|
||||
email: Option<String>,
|
||||
account_id: Option<String>,
|
||||
@@ -425,6 +427,7 @@ impl CodexCredentialAdmin {
|
||||
optional_time(input.next_refresh_at),
|
||||
);
|
||||
Ok(NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account,
|
||||
credential,
|
||||
})
|
||||
@@ -485,6 +488,7 @@ impl CodexCredentialAdmin {
|
||||
optional_time(input.next_refresh_at),
|
||||
);
|
||||
Ok(NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account,
|
||||
credential,
|
||||
})
|
||||
@@ -511,6 +515,7 @@ impl CodexCredentialAdmin {
|
||||
let data = CodexCredentialCodec::decode_complete(&item.current.credential)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
|
||||
let common = CodexCprExportCommon {
|
||||
model_access: account.model_access().clone(),
|
||||
id: account.id().as_str().to_owned(),
|
||||
email: account.email().map(str::to_owned),
|
||||
account_id: account.upstream_account_id().map(str::to_owned),
|
||||
@@ -928,6 +933,7 @@ impl CodexCredentialAdminService {
|
||||
enabled: true,
|
||||
})?;
|
||||
accounts.push(NewProviderAccount {
|
||||
model_access: candidate.model_access,
|
||||
account: prepared
|
||||
.account
|
||||
.with_outbound_proxy(candidate.outbound_proxy),
|
||||
@@ -1102,6 +1108,11 @@ fn parse_oauth_import_account(
|
||||
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
|
||||
let credentials = value.get("credentials").unwrap_or(value);
|
||||
Ok(ParsedCodexImportAccount {
|
||||
model_access: value
|
||||
.get("modelAccess")
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
.transpose()
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?,
|
||||
name: first_string(value, &["name", "label"]),
|
||||
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
|
||||
authentication: parse_oauth_import_tokens(value)?,
|
||||
|
||||
@@ -568,6 +568,7 @@ impl CodexOAuthAdminService {
|
||||
})
|
||||
.map_err(map_admin_error)?;
|
||||
CompletedCodexOAuthCredential::Create(gateway_core::account::NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account: prepared
|
||||
.account
|
||||
.with_outbound_proxy(mutation.outbound_proxy().cloned()),
|
||||
|
||||
@@ -364,6 +364,7 @@ impl CodexCredentialSelector {
|
||||
'capacity: loop {
|
||||
let diagnostic = request.attempt.is_diagnostic_required_account();
|
||||
let accounts = self.repository.list_for_provider().await?;
|
||||
let mut model_access_rejected = 0_usize;
|
||||
let accounts = accounts
|
||||
.into_iter()
|
||||
.filter(|account| {
|
||||
@@ -377,6 +378,12 @@ impl CodexCredentialSelector {
|
||||
|| match model_catalog_eligibility {
|
||||
ModelCatalogEligibility::NotApplicable => true,
|
||||
ModelCatalogEligibility::Required(upstream_model) => {
|
||||
if !request.attempt.account_scope().is_some_and(|scope| {
|
||||
scope.allows_model(account.id(), upstream_model)
|
||||
}) {
|
||||
model_access_rejected += 1;
|
||||
return false;
|
||||
}
|
||||
let observed_support = self
|
||||
.catalog
|
||||
.observed_model_support(account, upstream_model);
|
||||
@@ -385,6 +392,12 @@ impl CodexCredentialSelector {
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if model_access_rejected > 0 && request.attempt.trace().is_enabled() {
|
||||
request.attempt.trace().record(
|
||||
"account.model_access",
|
||||
serde_json::json!({"rejectedCount": model_access_rejected}),
|
||||
);
|
||||
}
|
||||
if !diagnostic {
|
||||
self.quota.prepare_scheduling(&accounts).await;
|
||||
}
|
||||
|
||||
@@ -1304,6 +1304,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: account.id().to_string(),
|
||||
provider_kind: account.provider().clone(),
|
||||
|
||||
@@ -453,12 +453,17 @@ async fn pat_auth_json_import_and_cpr_round_trip_preserve_a_token_without_email(
|
||||
}))
|
||||
.await
|
||||
.expect("official PAT auth.json");
|
||||
let policy = gateway_core::account::AccountModelAccess::new(
|
||||
gateway_core::account::AccountModelAccessMode::Allowlist,
|
||||
vec!["test-luna".to_owned()],
|
||||
)
|
||||
.expect("policy");
|
||||
let prepared = imported.into_accounts().remove(0);
|
||||
assert!(prepared.account.email().is_none());
|
||||
let exported = CodexCredentialAdmin
|
||||
.format_cpr_export(vec![ExportManagedCodexCredential {
|
||||
current: LoadedCredential {
|
||||
account: prepared.account,
|
||||
account: prepared.account.with_model_access(policy.clone()),
|
||||
credential: prepared.credential,
|
||||
},
|
||||
added_at: Utc::now(),
|
||||
@@ -474,6 +479,7 @@ async fn pat_auth_json_import_and_cpr_round_trip_preserve_a_token_without_email(
|
||||
Some("pat-user")
|
||||
);
|
||||
assert!(!imported.accounts()[0].account.has_refresh_token());
|
||||
assert_eq!(imported.accounts()[0].model_access.as_ref(), Some(&policy));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1628,3 +1628,185 @@ fn account_queue_cancellation_releases_wait_capacity_and_timeout_is_local() {
|
||||
*leases.busy.lock().unwrap() = false;
|
||||
assert!(block_on(selector.select(&request)).is_ok());
|
||||
}
|
||||
|
||||
fn model_restricted_attempt(
|
||||
required: Option<ProviderAccountId>,
|
||||
excluded: BTreeSet<ProviderAccountId>,
|
||||
) -> AttemptContext {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let scope = Arc::new(FrozenAccountScope::new(
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
|
||||
(
|
||||
ProviderAccountId::new("acct_primary").expect("plus"),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new()).with_model_access(
|
||||
AccountModelAccess::new(
|
||||
AccountModelAccessMode::Allowlist,
|
||||
vec!["test-luna".to_owned()],
|
||||
)
|
||||
.expect("plus policy"),
|
||||
),
|
||||
),
|
||||
(
|
||||
ProviderAccountId::new("acct_other").expect("pro"),
|
||||
RuntimeAccount::new(provider, BTreeSet::new()).with_model_access(
|
||||
AccountModelAccess::new(
|
||||
AccountModelAccessMode::Denylist,
|
||||
vec!["test-luna".to_owned()],
|
||||
)
|
||||
.expect("pro policy"),
|
||||
),
|
||||
),
|
||||
]))),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
));
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new("req_model_access").expect("request"),
|
||||
ClientApiKeyId::new("key_codex_contract").expect("key"),
|
||||
),
|
||||
NonZeroU32::new(1).expect("attempt"),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(excluded, required, None).with_account_scope(scope),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_access_routes_luna_and_other_models_to_separate_accounts_even_with_unknown_catalog() {
|
||||
for (model, expected) in [("test-luna", "acct_primary"), ("gpt-5.4", "acct_other")] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
create_account(&store, "acct_other", "at-other");
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
let selector = selector(&store, leases);
|
||||
let attempt = model_restricted_attempt(None, BTreeSet::new());
|
||||
let lease = block_on(selector.select(&SelectCodexCredential {
|
||||
upstream_model: model,
|
||||
request_url:
|
||||
&Url::parse("https://chatgpt.com/backend-api/codex/responses").expect("URL"),
|
||||
attempt: &attempt,
|
||||
session_affinity_key: None,
|
||||
}))
|
||||
.expect("eligible account");
|
||||
assert_eq!(lease.account_id().as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_access_never_escapes_to_a_forbidden_account_after_failover_or_required_binding() {
|
||||
for (required, excluded) in [
|
||||
(
|
||||
None,
|
||||
BTreeSet::from([ProviderAccountId::new("acct_other").expect("pro")]),
|
||||
),
|
||||
(
|
||||
Some(ProviderAccountId::new("acct_primary").expect("plus")),
|
||||
BTreeSet::new(),
|
||||
),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
create_account(&store, "acct_other", "at-other");
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
let selector = selector(&store, Arc::clone(&leases));
|
||||
let attempt = model_restricted_attempt(required, excluded);
|
||||
let result = block_on(selector.select(&SelectCodexCredential {
|
||||
upstream_model: "gpt-5.4",
|
||||
request_url:
|
||||
&Url::parse("https://chatgpt.com/backend-api/codex/responses").expect("URL"),
|
||||
attempt: &attempt,
|
||||
session_affinity_key: None,
|
||||
}));
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(CredentialSelectionError::NoEligibleCredential)
|
||||
));
|
||||
assert!(
|
||||
leases.requests.lock().expect("requests").is_empty(),
|
||||
"forbidden accounts must not acquire a lease"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_access_overrides_soft_session_affinity() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
create_account(&store, "acct_other", "at-other");
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let key = ProviderSessionAffinityKey::try_new("model-access-session").expect("key");
|
||||
affinity
|
||||
.bind(
|
||||
&ProviderKind::new("openai").expect("provider"),
|
||||
&key,
|
||||
&ProviderAccountId::new("acct_primary").expect("account"),
|
||||
Duration::from_secs(3600),
|
||||
)
|
||||
.await
|
||||
.expect("bind");
|
||||
let selector =
|
||||
selector_with_affinity(&store, Arc::new(TestLeaseCoordinator::default()), affinity);
|
||||
let attempt = model_restricted_attempt(None, BTreeSet::new());
|
||||
let lease = selector
|
||||
.select(&SelectCodexCredential {
|
||||
upstream_model: "gpt-5.4",
|
||||
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
|
||||
.expect("URL"),
|
||||
attempt: &attempt,
|
||||
session_affinity_key: Some(&key),
|
||||
})
|
||||
.await
|
||||
.expect("select pro");
|
||||
assert_eq!(lease.account_id().as_str(), "acct_other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_access_queue_waits_for_allowed_account_without_using_free_forbidden_account() {
|
||||
use futures::FutureExt;
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_primary", "at-primary");
|
||||
create_account(&store, "acct_other", "at-other");
|
||||
let leases = Arc::new(TestLeaseCoordinator::default());
|
||||
let allowed = ProviderAccountId::new("acct_other").expect("allowed account");
|
||||
leases.busy_accounts.lock().unwrap().insert(allowed.clone());
|
||||
let selector = selector(&store, leases.clone());
|
||||
let restricted = model_restricted_attempt(None, BTreeSet::new());
|
||||
let attempt = AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new("req_model_queue").unwrap(),
|
||||
ClientApiKeyId::new("key_codex_contract").unwrap(),
|
||||
),
|
||||
NonZeroU32::new(1).unwrap(),
|
||||
SystemTime::now() + Duration::from_secs(5),
|
||||
account_policy().with_queue(gateway_core::concurrency::ConcurrencyQueuePolicy {
|
||||
max_waiting: 1,
|
||||
timeout: Duration::from_secs(2),
|
||||
}),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, None)
|
||||
.with_account_scope(restricted.account_scope().unwrap().clone()),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
);
|
||||
let url = Url::parse("https://chatgpt.com/backend-api/codex/responses").unwrap();
|
||||
let request = SelectCodexCredential {
|
||||
upstream_model: "gpt-5.4",
|
||||
request_url: &url,
|
||||
attempt: &attempt,
|
||||
session_affinity_key: None,
|
||||
};
|
||||
let mut pending = Box::pin(selector.select(&request));
|
||||
assert!(pending.as_mut().now_or_never().is_none());
|
||||
leases.busy_accounts.lock().unwrap().clear();
|
||||
assert_eq!(block_on(pending).unwrap().account_id(), &allowed);
|
||||
assert!(
|
||||
leases
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|lease| lease.account_id() == &allowed)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
// 日志得知需要重新授权。
|
||||
let mut first_failure: Option<ProviderAdminError> = None;
|
||||
for entry in document.into_entries() {
|
||||
let model_access = entry.model_access().cloned();
|
||||
let name = entry.name().to_owned();
|
||||
let email = entry.email().map(str::to_owned);
|
||||
let outbound_proxy = entry.outbound_proxy().cloned();
|
||||
@@ -501,6 +502,7 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
}
|
||||
};
|
||||
let prepared = NewProviderAccount {
|
||||
model_access,
|
||||
account: prepared.account.with_outbound_proxy(outbound_proxy),
|
||||
credential: prepared.credential,
|
||||
};
|
||||
@@ -838,6 +840,7 @@ impl XaiAdminProvider {
|
||||
})
|
||||
.map_err(map_repository_error)?;
|
||||
let prepared = NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account: prepared
|
||||
.account
|
||||
.with_outbound_proxy(stored.mutation.outbound_proxy().cloned()),
|
||||
@@ -921,9 +924,11 @@ fn prepared_create(
|
||||
) -> Result<PreparedCredentialCreate, ProviderAdminError> {
|
||||
let NewProviderAccount {
|
||||
account,
|
||||
model_access,
|
||||
credential,
|
||||
} = prepared;
|
||||
Ok(PreparedCredentialCreate {
|
||||
model_access,
|
||||
account_id: account.id().clone(),
|
||||
provider_kind: account.provider().clone(),
|
||||
name: account.name().to_owned(),
|
||||
@@ -1051,6 +1056,7 @@ fn account_from_record(account: &AccountRecord) -> Result<ProviderAccount, Provi
|
||||
account.last_error_reason,
|
||||
account.last_error_message.clone(),
|
||||
)
|
||||
.with_model_access(account.model_access.clone())
|
||||
.with_refresh_schedule(
|
||||
account.has_refresh_token,
|
||||
account.next_refresh_at.map(Into::into),
|
||||
|
||||
@@ -112,6 +112,7 @@ impl GrokOAuthImportMetadata {
|
||||
|
||||
/// OAuth 账号文档中的一个 xAI account。
|
||||
pub struct GrokOAuthImportEntry {
|
||||
model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
@@ -119,6 +120,11 @@ pub struct GrokOAuthImportEntry {
|
||||
}
|
||||
|
||||
impl GrokOAuthImportEntry {
|
||||
#[must_use]
|
||||
pub fn model_access(&self) -> Option<&gateway_core::account::AccountModelAccess> {
|
||||
self.model_access.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy.as_ref()
|
||||
@@ -330,6 +336,11 @@ fn parse_account_entry(
|
||||
Some(_) => return Err(GrokOAuthImportError::InvalidField("outboundProxyUrl")),
|
||||
};
|
||||
Ok(Some(GrokOAuthImportEntry {
|
||||
model_access: account
|
||||
.get("modelAccess")
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
.transpose()
|
||||
.map_err(|_| GrokOAuthImportError::InvalidField("modelAccess"))?,
|
||||
name,
|
||||
email,
|
||||
outbound_proxy,
|
||||
|
||||
@@ -176,6 +176,7 @@ impl GrokCredentialAdmin {
|
||||
)
|
||||
.with_refresh_schedule(true, None);
|
||||
Ok(NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account,
|
||||
credential: encode_secret(&input.secret, &input.account)?,
|
||||
})
|
||||
@@ -276,6 +277,7 @@ impl GrokCredentialAdmin {
|
||||
}
|
||||
let mut exported = serde_json::json!({
|
||||
"name": loaded.account.name(),
|
||||
"modelAccess": loaded.account.model_access(),
|
||||
"platform": "grok",
|
||||
"type": "oauth",
|
||||
"credentials": credentials,
|
||||
|
||||
@@ -110,7 +110,11 @@ impl GrokAccountSessionSelector {
|
||||
} else {
|
||||
accounts
|
||||
.into_iter()
|
||||
.filter(|account| request.account_scope().allows(account.id()))
|
||||
.filter(|account| {
|
||||
request
|
||||
.account_scope()
|
||||
.allows_model(account.id(), request.upstream_model().as_str())
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let catalog_eligible = if diagnostic {
|
||||
|
||||
@@ -613,6 +613,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: account.id().to_string(),
|
||||
provider_kind: account.provider().clone(),
|
||||
|
||||
@@ -285,6 +285,7 @@ async fn repository_rejects_account_owned_by_another_provider() {
|
||||
object.insert("access_token".to_owned(), serde_json::json!("secret"));
|
||||
store
|
||||
.create_account(NewProviderAccount {
|
||||
model_access: Default::default(),
|
||||
account,
|
||||
credential: PlaintextCredential::new(object),
|
||||
})
|
||||
@@ -344,7 +345,7 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
|
||||
input.secret.scope =
|
||||
"openid profile email offline_access grok-cli:access api:access".to_owned();
|
||||
seed_input(&store, &input).await.expect("create account");
|
||||
let loaded = store
|
||||
let mut loaded = store
|
||||
.load_credential(
|
||||
&input.account_id,
|
||||
CredentialRevision::new(1).expect("revision"),
|
||||
@@ -352,6 +353,12 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
|
||||
.await
|
||||
.expect("loaded credential");
|
||||
|
||||
let policy = gateway_core::account::AccountModelAccess::new(
|
||||
gateway_core::account::AccountModelAccessMode::Denylist,
|
||||
vec!["grok-test-model".to_owned()],
|
||||
)
|
||||
.expect("policy");
|
||||
loaded.account = loaded.account.with_model_access(policy.clone());
|
||||
let export = GrokCredentialAdmin
|
||||
.export_oauth_bundle(&[loaded], Utc::now())
|
||||
.expect("export");
|
||||
@@ -406,6 +413,11 @@ async fn oauth_bundle_export_is_provider_owned_canonical_and_debug_redacted() {
|
||||
assert!(value["accounts"][0]["credentials"].get(field).is_none());
|
||||
}
|
||||
assert_eq!(value["proxies"], serde_json::json!([]));
|
||||
let imported = provider_xai::GrokOAuthImportDocument::parse_json(
|
||||
&serde_json::to_vec(&value).expect("JSON"),
|
||||
)
|
||||
.expect("re-import exported account");
|
||||
assert_eq!(imported.into_entries()[0].model_access(), Some(&policy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1193,3 +1193,85 @@ async fn account_queue_cancel_reclaims_capacity_and_does_not_wait_for_upstream_c
|
||||
GrokSessionSelectorError::AccountCoolingDown { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_access_excludes_a_required_xai_account_before_acquiring_a_lease() {
|
||||
use gateway_core::account::{AccountModelAccess, AccountModelAccessMode};
|
||||
let fixture = SelectorFixture::new(&["model-restricted", "model-allowed"]).await;
|
||||
let restricted = account_id("model-restricted");
|
||||
let allowed = account_id("model-allowed");
|
||||
let provider = gateway_core::routing::ProviderKind::new("xai").expect("provider");
|
||||
let scope = Arc::new(FrozenAccountScope::new(
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([
|
||||
(
|
||||
restricted.clone(),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new()).with_model_access(
|
||||
AccountModelAccess::new(
|
||||
AccountModelAccessMode::Denylist,
|
||||
vec!["grok-4.5".to_owned()],
|
||||
)
|
||||
.expect("policy"),
|
||||
),
|
||||
),
|
||||
(
|
||||
allowed.clone(),
|
||||
RuntimeAccount::new(provider, BTreeSet::new()),
|
||||
),
|
||||
]))),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
));
|
||||
for (required, succeeds) in [(Some(restricted), false), (None, true)] {
|
||||
let request = GrokSessionSelection::new(
|
||||
UpstreamModelId::new("grok-4.5").expect("model"),
|
||||
BTreeSet::new(),
|
||||
required,
|
||||
AccountSelectionPolicy::new(
|
||||
RotationStrategy::Smart,
|
||||
std::num::NonZeroU32::new(2).expect("limit"),
|
||||
Duration::ZERO,
|
||||
)
|
||||
.with_queue(gateway_core::concurrency::ConcurrencyQueuePolicy {
|
||||
max_waiting: 1,
|
||||
timeout: Duration::from_secs(2),
|
||||
}),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
Arc::clone(&scope),
|
||||
ClientApiKeyId::new("key_xai_selector").expect("key"),
|
||||
);
|
||||
use futures::FutureExt;
|
||||
if succeeds {
|
||||
fixture
|
||||
.coordinator
|
||||
.denied
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(allowed.clone());
|
||||
}
|
||||
let mut pending = fixture.selector.select(request);
|
||||
if succeeds {
|
||||
assert!(pending.as_mut().now_or_never().is_none());
|
||||
fixture.coordinator.denied.lock().unwrap().clear();
|
||||
}
|
||||
let result = pending.await;
|
||||
if succeeds {
|
||||
assert_eq!(
|
||||
result.expect("select allowed account").account_id(),
|
||||
&allowed
|
||||
);
|
||||
} else {
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(GrokSessionSelectorError::NoEligibleSession)
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
fixture
|
||||
.coordinator
|
||||
.requests
|
||||
.lock()
|
||||
.expect("leases")
|
||||
.iter()
|
||||
.all(|lease| lease.account_id() == &allowed)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
|
||||
393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql
|
||||
18e8c1b71c896a9d576d9e4ef9701506f654f55383af908898195b9eb2980fde 0007_account_notes.sql
|
||||
36b720c9d3e6bf0bdb799b9b6a89b81638c642ac866992a880bd82357012a208 0008_concurrency_queues.sql
|
||||
161df9ae43da34d51b340bbf95d71c61a8c1a2aa142382f693a9d127c834630d 0009_account_model_access.sql
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 账号模型政策独立于凭据与上游模型目录;已有账号保持不限制。
|
||||
alter table provider_accounts
|
||||
add column model_access_json jsonb not null default '{"mode":"all","models":[]}'::jsonb,
|
||||
add constraint provider_accounts_model_access_ck check (
|
||||
jsonb_typeof(model_access_json) = 'object'
|
||||
and model_access_json ?& array['mode', 'models']
|
||||
and model_access_json - 'mode' - 'models' = '{}'::jsonb
|
||||
and octet_length(model_access_json::text) <= 131072
|
||||
and case when jsonb_typeof(model_access_json -> 'models') = 'array' then
|
||||
case model_access_json ->> 'mode'
|
||||
when 'all' then jsonb_array_length(model_access_json -> 'models') = 0
|
||||
when 'allowlist' then jsonb_array_length(model_access_json -> 'models') between 1 and 256
|
||||
when 'denylist' then jsonb_array_length(model_access_json -> 'models') between 1 and 256
|
||||
else false
|
||||
end
|
||||
and not jsonb_path_exists(model_access_json, '$.models[*] ? (@.type() != "string")')
|
||||
else false end
|
||||
);
|
||||
+32
-5
@@ -195,7 +195,7 @@ OpenAI Provider 按客户端传入的 `client_version` 请求上游目录,完
|
||||
`base_instructions`、`model_messages`、`service_tiers`、工具与能力字段,以及未知嵌套字段、显式 `null`
|
||||
和字段缺失的区别。
|
||||
模型别名仅替换 `slug`,不替换上游展示名、提示词、能力或 `priority`;保持原生模型顺序,新增别名附在后面。
|
||||
目录仍按当前路由快照的模型存在性过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
|
||||
目录按当前路由快照的模型存在性及账号模型政策过滤,避免公布已知无法路由的模型;新模型需待后台目录对账后进入列表。
|
||||
xAI 没有 Codex 原生目录,继续使用明确的通用画像适配。
|
||||
|
||||
目录账号只能来自本次 Client Key 冻结的账号范围。OpenAI 在其中按账号 ID 排序,使用首个成功读取的
|
||||
@@ -319,8 +319,8 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
|
||||
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
|
||||
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 |
|
||||
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
|
||||
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 |
|
||||
@@ -360,6 +360,30 @@ OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`;
|
||||
浏览器打开的第三方 OAuth 授权页仍使用浏览器自身网络。
|
||||
账号出口与连接隔离见 [架构说明](architecture.md#账号出站代理)。
|
||||
|
||||
### 账号模型限制
|
||||
|
||||
账号列表和详情返回 `modelAccess: { mode, models }`,模型 ID 区分大小写并精确匹配:
|
||||
|
||||
| `mode` | `models` | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `all` | `[]` | 不额外限制模型,默认值 |
|
||||
| `allowlist` | 非空 ID 数组 | 仅允许指定模型 |
|
||||
| `denylist` | 非空 ID 数组 | 排除指定模型 |
|
||||
|
||||
最多 256 项,每个 ID 最多 256 字节;重复 ID 去重,不接受空白、控制字符、`__` 前缀或 `*` 通配符。
|
||||
允许保存当前上游目录尚未返回的 ID。限制按全局模型映射后的上游 ID 判断,不扩大账号本身的上游权限。
|
||||
例如 Plus 设置 `allowlist` 并选中 luna 的实际 ID,Pro 设置 `denylist` 并选中同一 ID,即可严格分流;
|
||||
Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修改规则。
|
||||
|
||||
单账号更新或批量更新省略 `modelAccess` 时保留原值,显式提交 `{ "mode": "all", "models": [] }` 清除限制。
|
||||
批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。
|
||||
提供 `groupIds` 时替换完整分组集合,提供 `concurrencyLimit: null` 时恢复继承运行参数。
|
||||
|
||||
限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求选号,包括重试、亲和和换号;没有合规账号时沿用
|
||||
无可用账号错误,不会回退到被禁止的账号。已开始请求使用冻结的政策,新请求使用已发布的新配置。
|
||||
Images、独立 Search 及管理员连接测试不受该文本模型限制;连接测试成功只证明指定账号的上游能力。
|
||||
`/v1/models` 和单模型查询按当前 Key 范围内账号政策过滤;原生目录保留已有来源选择和完整模型对象。
|
||||
|
||||
### 独立代理管理 / Managed Proxies
|
||||
|
||||
所有端点要求管理员身份。所有响应只返回去掉认证信息的 `endpoint`,不会返回完整 URL。
|
||||
@@ -459,7 +483,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配
|
||||
|
||||
RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token 写入日志、issue、fixture 或文档。
|
||||
|
||||
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds, notes? }`。
|
||||
账号导入与首次 OAuth complete 可附带 `settings: { enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess? }`。
|
||||
提供 `settings` 时前四项均必填,`concurrencyLimit: null` 继承运行参数,否则为 1–4294967295 的整数;
|
||||
`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号,
|
||||
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
|
||||
@@ -468,7 +492,10 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
|
||||
|
||||
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
|
||||
|
||||
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
|
||||
OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。OpenAI 与 xAI 的 CPR 账号条目均包含
|
||||
`modelAccess`,重新导入时恢复;导入 `settings.modelAccess` 显式提供时覆盖条目中的政策。
|
||||
旧文档不带此字段时,新账号默认 `all`,已有账号保留原值;凭据刷新、重新授权和目录刷新均保留政策。
|
||||
sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限制。
|
||||
|
||||
OpenAI rotation 请求字段为:
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ sequenceDiagram
|
||||
E->>S: enqueue terminal observation and metering
|
||||
```
|
||||
|
||||
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围、模型映射、Codex 客户端最低版本、Provider
|
||||
请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围及账号模型政策、模型映射、Codex 客户端最低版本、Provider
|
||||
候选顺序和调度策略。
|
||||
运行中的请求始终使用该快照,不拼接不同版本的配置,也不在热路径查询分组关系。
|
||||
|
||||
@@ -296,9 +296,14 @@ Client Key 与账号分组形成授权范围:
|
||||
- 分组可以包含多个 Provider,账号也可以属于多个分组。
|
||||
|
||||
账号选择综合启停状态、credential/quota 事实、Redis cooldown、并发上限、权重、请求间隔和会话亲和。
|
||||
账号编辑在一个事务中替换完整调度事实。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store,
|
||||
`account::AccountModelAccess` 拥有管理员模型政策的校验与精确匹配语义,存入账号行的 `model_access_json`,
|
||||
由 `RuntimeAccountDirectory` / `FrozenAccountScope` 随配置快照冻结。Provider 在额度、亲和与租约之前
|
||||
按映射后的上游模型筛选账号;重试和 fallback 使用同一冻结政策。上游目录和凭据不承载或改写该政策。
|
||||
客户端目录的政策过滤判断范围内整个候选池,原生模型对象仍由 Provider 独占解释。
|
||||
|
||||
账号编辑在一个事务中更新调度事实,批量更新只应用显式提供的字段。导入与首次 OAuth 可携带统一账号设置,由 Admin 传递给 Store,
|
||||
与凭据在同一事务中提交;Provider 仍独占凭据解析。未附带设置的导入、重新授权和后台刷新保留已有分组、
|
||||
权重与并发设置。管理端导入和账号编辑共用设置表单,凭据输入独立于设置。
|
||||
权重、并发与模型政策。模型政策变更推进配置 revision,不推进 credential revision。管理端导入和账号编辑共用设置表单,凭据输入独立于设置。
|
||||
|
||||
Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/交付边界约束:
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ export interface AccountUsage {
|
||||
models: AccountModelUsage[]
|
||||
}
|
||||
|
||||
export interface AccountModelAccess {
|
||||
mode: 'all' | 'allowlist' | 'denylist'
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
outboundProxyEndpoint: string | null
|
||||
id: string
|
||||
@@ -132,6 +137,7 @@ export interface Account {
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
weight: number
|
||||
modelAccess: AccountModelAccess
|
||||
accessTokenExpiresAt: string | null
|
||||
accessTokenExpiresAtDisplay: string | null
|
||||
refreshTokenExpiresAt: string | null
|
||||
@@ -349,6 +355,7 @@ interface AccountUpdateParam {
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
weight: number
|
||||
modelAccess?: AccountModelAccess
|
||||
groupIds: string[]
|
||||
}
|
||||
|
||||
@@ -356,10 +363,11 @@ interface AccountBatchUpdateParam {
|
||||
outboundProxyUrl?: string
|
||||
outboundProxyId?: string
|
||||
accountIds: string[]
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
weight: number
|
||||
groupIds: string[]
|
||||
enabled?: boolean
|
||||
concurrencyLimit?: number | null
|
||||
weight?: number
|
||||
modelAccess?: AccountModelAccess
|
||||
groupIds?: string[]
|
||||
}
|
||||
|
||||
interface AccountDeleteParams {
|
||||
@@ -372,6 +380,7 @@ interface AccountImportSettings {
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
weight: number
|
||||
modelAccess?: AccountModelAccess
|
||||
groupIds: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroup, AccountModelAccess } from '@/api'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import AccountSettingsFields from './AccountSettingsFields.vue'
|
||||
|
||||
defineProps<{
|
||||
selectedCount: number
|
||||
catalogAccountId?: string
|
||||
groups: AccountGroup[]
|
||||
groupsLoading: boolean
|
||||
saving: boolean
|
||||
@@ -19,6 +21,8 @@ const emit = defineEmits<{
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const modelAccess = defineModel<AccountModelAccess | undefined>('modelAccess', { required: true })
|
||||
const updateScheduling = defineModel<boolean>('updateScheduling', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
const proxyMode = defineModel<string>('proxyMode', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
@@ -29,21 +33,28 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="批量编辑账号"
|
||||
:description="`统一调整选中的 ${selectedCount} 个账号,保存后将覆盖它们当前的调度参数和分组设置。`"
|
||||
size="md"
|
||||
:description="`编辑 ${selectedCount} 个账号`"
|
||||
size="lg"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<AccountSettingsFields
|
||||
v-model:enabled="enabled"
|
||||
v-model:concurrency-limit="concurrencyLimit"
|
||||
v-model:weight="weight"
|
||||
v-model:selected-group-ids="selectedGroupIds"
|
||||
v-model:proxy-mode="proxyMode"
|
||||
v-model:proxy-id="proxyId"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:disabled="saving"
|
||||
/>
|
||||
<div class="grid gap-5">
|
||||
<BaseCheckbox v-model="updateScheduling" label="同时更新调度、分组和代理设置" show-label :disabled="saving" />
|
||||
<AccountSettingsFields
|
||||
v-model:enabled="enabled"
|
||||
v-model:concurrency-limit="concurrencyLimit"
|
||||
v-model:weight="weight"
|
||||
v-model:model-access="modelAccess"
|
||||
v-model:selected-group-ids="selectedGroupIds"
|
||||
v-model:proxy-mode="proxyMode"
|
||||
v-model:proxy-id="proxyId"
|
||||
preserve-model-access
|
||||
:model-only="!updateScheduling"
|
||||
:account-id="catalogAccountId"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
|
||||
@@ -52,7 +63,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
:loading="saving"
|
||||
:disabled="selectedCount === 0 || groupsLoading"
|
||||
:disabled="selectedCount === 0 || (updateScheduling && groupsLoading) || (!updateScheduling && !modelAccess)"
|
||||
@click="emit('save')"
|
||||
>
|
||||
保存更改
|
||||
|
||||
@@ -27,9 +27,11 @@ const form = defineModel<AccountCreateForm>({ required: true })
|
||||
v-model:enabled="form.enabled"
|
||||
v-model:concurrency-limit="form.concurrencyLimit"
|
||||
v-model:weight="form.weight"
|
||||
v-model:model-access="form.modelAccess"
|
||||
v-model:selected-group-ids="form.groupIds"
|
||||
v-model:proxy-mode="form.proxyMode"
|
||||
v-model:proxy-id="form.proxyId"
|
||||
preserve-model-access
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
:preserve-proxy="false"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { computed } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { accountModelAccessError } from '../../utils/modelAccess'
|
||||
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
|
||||
import AccountImportFields from './AccountImportFields.vue'
|
||||
import AccountOAuthFields from './AccountOAuthFields.vue'
|
||||
@@ -29,6 +30,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
const form = defineModel<AccountCreateForm>('form', { required: true })
|
||||
const busy = computed(() => props.saving || props.oauthLoading)
|
||||
const proxyError = computed(() => accountProxyError(form.value))
|
||||
const modelError = computed(() => accountModelAccessError(form.value.modelAccess))
|
||||
const scheduling = computed(() => parseAccountSchedulingForm(form.value.concurrencyLimit, form.value.weight))
|
||||
const view = computed(() => resolveAccountCreatePresentation({
|
||||
form: form.value,
|
||||
@@ -53,7 +55,7 @@ const importText = computed({
|
||||
})
|
||||
|
||||
function continueToImport() {
|
||||
if (form.value.provider && scheduling.value.valid && !props.groupsLoading && !proxyError.value && !busy.value)
|
||||
if (form.value.provider && !modelError.value && scheduling.value.valid && !props.groupsLoading && !proxyError.value && !busy.value)
|
||||
form.value.step = 'import'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AccountModelAccess } from '@/api'
|
||||
import { accountModelAccessError } from '../../utils/modelAccess'
|
||||
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
|
||||
|
||||
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
|
||||
@@ -10,6 +12,7 @@ export interface AccountCreateForm {
|
||||
enabled: boolean
|
||||
concurrencyLimit: string
|
||||
weight: string
|
||||
modelAccess?: AccountModelAccess
|
||||
groupIds: string[]
|
||||
step: 'settings' | 'import'
|
||||
mode: AccountImportMode
|
||||
@@ -52,7 +55,11 @@ export function accountImportSettings(form: AccountCreateForm) {
|
||||
const scheduling = parseAccountSchedulingForm(form.concurrencyLimit, form.weight)
|
||||
if (!scheduling.valid)
|
||||
throw new Error(scheduling.message)
|
||||
const modelError = accountModelAccessError(form.modelAccess)
|
||||
if (modelError)
|
||||
throw new Error(modelError)
|
||||
return {
|
||||
modelAccess: form.modelAccess,
|
||||
enabled: form.enabled,
|
||||
...scheduling.values,
|
||||
groupIds: [...new Set(form.groupIds)],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountRow } from '../constants'
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroup, AccountModelAccess } from '@/api'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
@@ -26,6 +26,7 @@ const open = defineModel<boolean>({ required: true })
|
||||
const notes = defineModel<string>('notes', { required: true })
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const modelAccess = defineModel<AccountModelAccess | undefined>('modelAccess', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
const proxyMode = defineModel<string>('proxyMode', { required: true })
|
||||
const proxyId = defineModel<string>('proxyId', { required: true })
|
||||
@@ -36,8 +37,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="编辑账号"
|
||||
description="维护账号备注,调整调度与所属分组。"
|
||||
size="md"
|
||||
size="lg"
|
||||
:dismissible="!saving"
|
||||
>
|
||||
<div v-if="account" class="grid gap-5">
|
||||
@@ -62,6 +62,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model:enabled="enabled"
|
||||
v-model:concurrency-limit="concurrencyLimit"
|
||||
v-model:weight="weight"
|
||||
v-model:model-access="modelAccess"
|
||||
v-model:selected-group-ids="selectedGroupIds"
|
||||
v-model:proxy-mode="proxyMode"
|
||||
v-model:proxy-id="proxyId"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountModelAccess } from '@/api'
|
||||
|
||||
import { RefreshCw, Search } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getAccountModels, refreshAccountModels } from '@/api'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { useRequestState } from '@/composables/useRequestState'
|
||||
import { accountModelAccessError, accountModelIdError } from '../utils/modelAccess'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
accountId?: string
|
||||
disabled?: boolean
|
||||
allowPreserve?: boolean
|
||||
}>(), { disabled: false, allowPreserve: false })
|
||||
const model = defineModel<AccountModelAccess | undefined>({ required: true })
|
||||
const catalog = ref<Array<{ id: string, label: string }>>([])
|
||||
const search = ref('')
|
||||
const inputError = ref('')
|
||||
const request = useRequestState()
|
||||
const { loading, error } = request
|
||||
const modes = computed(() => [
|
||||
...(props.allowPreserve ? [{ value: 'preserve', label: '保留' }] : []),
|
||||
{ value: 'all', label: '不限制' },
|
||||
{ value: 'allowlist', label: '白名单' },
|
||||
{ value: 'denylist', label: '黑名单' },
|
||||
])
|
||||
const mode = computed({
|
||||
get: () => model.value?.mode ?? 'preserve',
|
||||
set: (value: string) => {
|
||||
inputError.value = ''
|
||||
if (value === 'preserve') {
|
||||
model.value = undefined
|
||||
return
|
||||
}
|
||||
model.value = { mode: value as AccountModelAccess['mode'], models: value === 'all' ? [] : [...(model.value?.models ?? [])] }
|
||||
},
|
||||
})
|
||||
const restricted = computed(() => mode.value === 'allowlist' || mode.value === 'denylist')
|
||||
const canAdd = computed(() => Boolean(search.value.trim()) && !model.value?.models.includes(search.value.trim()))
|
||||
const models = computed(() => {
|
||||
const entries = new Map(catalog.value.map(item => [item.id, { ...item, unavailable: false }]))
|
||||
for (const id of model.value?.models ?? []) {
|
||||
if (!entries.has(id))
|
||||
entries.set(id, { id, label: id, unavailable: true })
|
||||
}
|
||||
const query = search.value.trim().toLowerCase()
|
||||
return [...entries.values()].filter(item => `${item.id} ${item.label}`.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
function select(id: string, selected: boolean) {
|
||||
if (props.disabled || !model.value || !restricted.value)
|
||||
return
|
||||
const ids = new Set(model.value.models)
|
||||
if (selected)
|
||||
ids.add(id)
|
||||
else ids.delete(id)
|
||||
const next = { ...model.value, models: [...ids] }
|
||||
inputError.value = selected ? accountModelAccessError(next) ?? '' : ''
|
||||
if (!inputError.value)
|
||||
model.value = next
|
||||
}
|
||||
|
||||
function addModel() {
|
||||
if (props.disabled || !restricted.value || !canAdd.value)
|
||||
return
|
||||
const id = search.value.trim()
|
||||
inputError.value = accountModelIdError(id) ?? ''
|
||||
if (inputError.value)
|
||||
return
|
||||
select(id, true)
|
||||
if (!inputError.value)
|
||||
search.value = ''
|
||||
}
|
||||
|
||||
async function load(refresh = false) {
|
||||
const accountId = props.accountId
|
||||
if (!accountId || !restricted.value)
|
||||
return
|
||||
const requestId = request.start()
|
||||
try {
|
||||
const result = await (refresh ? refreshAccountModels : getAccountModels)(
|
||||
{ accountId },
|
||||
{ signal: request.signal, silent: true },
|
||||
)
|
||||
if (request.isCurrent(requestId))
|
||||
catalog.value = result.models
|
||||
}
|
||||
catch (cause) {
|
||||
request.fail(requestId, cause)
|
||||
}
|
||||
finally {
|
||||
request.finish(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.accountId, restricted], () => {
|
||||
request.invalidate()
|
||||
catalog.value = []
|
||||
search.value = ''
|
||||
inputError.value = ''
|
||||
error.value = ''
|
||||
void load()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(search, () => {
|
||||
inputError.value = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-3">
|
||||
<BaseFormItem label="模型限制">
|
||||
<template v-if="restricted && accountId" #extra>
|
||||
<BaseIconButton label="刷新模型" size="sm" :loading="loading" :disabled="disabled" @click="load(true)">
|
||||
<template #loading>
|
||||
<RefreshCw class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</template>
|
||||
<RefreshCw class="size-3.5" />
|
||||
</BaseIconButton>
|
||||
</template>
|
||||
<BaseSegmented v-model="mode" class="w-full" label="模型限制模式" :options="modes" :disabled="disabled" />
|
||||
</BaseFormItem>
|
||||
<template v-if="restricted">
|
||||
<BaseInput v-model="search" aria-label="搜索或添加模型" placeholder="搜索或输入 ID,回车添加" :disabled="disabled" :aria-invalid="Boolean(inputError)" @keydown.enter.prevent="addModel">
|
||||
<template #prefix>
|
||||
<Search class="size-4" aria-hidden="true" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
<p v-if="inputError" class="m-0 text-cp-xs text-cp-error-text" role="alert">
|
||||
{{ inputError }}
|
||||
</p>
|
||||
<div v-if="models.length" class="grid max-h-60 grid-cols-2 gap-2 overflow-y-auto p-1 -m-1 sm:grid-cols-3" role="group" aria-label="选择模型" :aria-busy="loading || undefined">
|
||||
<BaseCheckbox
|
||||
v-for="item in models"
|
||||
:key="item.id"
|
||||
class="min-h-11 min-w-0 rounded-cp px-3 py-2.5 wrap-anywhere transition-colors duration-150 motion-reduce:transition-none"
|
||||
:class="model?.models.includes(item.id) ? 'bg-cp-primary-container text-cp-primary-on-container' : 'bg-cp-fill-quaternary text-cp-text-secondary hover:bg-cp-fill-tertiary hover:text-cp-text'"
|
||||
:model-value="model?.models.includes(item.id) ?? false"
|
||||
:label="item.id"
|
||||
:title="item.unavailable && accountId ? '当前目录未返回' : undefined"
|
||||
show-label
|
||||
:disabled="disabled"
|
||||
@update:model-value="select(item.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else-if="loading" class="m-0 py-4 text-center text-cp-sm text-cp-text-tertiary" role="status">
|
||||
加载模型中…
|
||||
</p>
|
||||
<BaseEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="sm" surface="none" />
|
||||
<p v-if="error" class="m-0 text-cp-xs text-cp-error-text" role="alert">
|
||||
模型列表加载失败,请重试
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccountGroup } from '@/api'
|
||||
import type { AccountGroup, AccountModelAccess } from '@/api'
|
||||
import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
import AccountModelAccessField from './AccountModelAccessField.vue'
|
||||
import AccountProxyField from './AccountProxyField.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
@@ -13,9 +14,12 @@ withDefaults(defineProps<{
|
||||
endpoint?: string | null
|
||||
accountId?: string
|
||||
preserveProxy?: boolean
|
||||
preserveModelAccess?: boolean
|
||||
modelOnly?: boolean
|
||||
proxyError?: string
|
||||
}>(), { preserveProxy: true })
|
||||
|
||||
const modelAccess = defineModel<AccountModelAccess | undefined>('modelAccess', { required: true })
|
||||
const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
|
||||
const weight = defineModel<string>('weight', { required: true })
|
||||
@@ -26,48 +30,51 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
|
||||
<template>
|
||||
<div class="grid gap-5">
|
||||
<div class="flex min-h-6 items-center justify-between gap-3">
|
||||
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
label="切换账号调度"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
<AccountModelAccessField v-model="modelAccess" :account-id="accountId" :disabled="disabled" :allow-preserve="preserveModelAccess" />
|
||||
<template v-if="!modelOnly">
|
||||
<div class="flex min-h-6 items-center justify-between gap-3">
|
||||
<span class="text-cp leading-none font-medium text-cp-text-secondary">调度</span>
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
label="切换账号调度"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<BaseFormItem label="并发限制">
|
||||
<BaseInput
|
||||
v-model="concurrencyLimit"
|
||||
aria-label="账号并发限制"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4294967295"
|
||||
placeholder="留空使用默认值"
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<BaseFormItem label="并发限制">
|
||||
<BaseInput
|
||||
v-model="concurrencyLimit"
|
||||
aria-label="账号并发限制"
|
||||
type="number"
|
||||
min="1"
|
||||
max="4294967295"
|
||||
placeholder="留空使用默认值"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="权重">
|
||||
<BaseInput
|
||||
v-model="weight"
|
||||
aria-label="账号调度权重"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="越高越优先,最大 100"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
|
||||
<BaseFormItem label="所属分组">
|
||||
<AccountGroupCheckboxGrid
|
||||
v-model="selectedGroupIds"
|
||||
:groups="groups"
|
||||
:loading="groupsLoading"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="权重">
|
||||
<BaseInput
|
||||
v-model="weight"
|
||||
aria-label="账号调度权重"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="越高越优先,最大 100"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
|
||||
<BaseFormItem label="所属分组">
|
||||
<AccountGroupCheckboxGrid
|
||||
v-model="selectedGroupIds"
|
||||
:groups="groups"
|
||||
:loading="groupsLoading"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
|
||||
<AccountProxyField v-model:mode="proxyMode" v-model:proxy-id="proxyId" :preserve="preserveProxy" :error="proxyError" :endpoint="endpoint" :account-id="accountId" :disabled="disabled" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { getAccounts } from '@/api'
|
||||
import type { AccountModelAccess, getAccounts } from '@/api'
|
||||
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { batchUpdateAccounts } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { accountModelAccessError } from '../utils/modelAccess'
|
||||
import { concurrencyLimitInput, parseAccountSchedulingForm } from '../utils/schedulingForm'
|
||||
|
||||
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
@@ -20,6 +21,9 @@ export function useAccountBatchEditor(options: {
|
||||
const schedulingEnabled = shallowRef(true)
|
||||
const concurrencyLimit = shallowRef('')
|
||||
const weight = shallowRef('1')
|
||||
const modelAccess = ref<AccountModelAccess | undefined>()
|
||||
const updateScheduling = shallowRef(false)
|
||||
const catalogAccountId = shallowRef<string>()
|
||||
const proxyMode = shallowRef('preserve')
|
||||
const proxyId = shallowRef('')
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
@@ -31,6 +35,9 @@ export function useAccountBatchEditor(options: {
|
||||
if (accounts.length === 0)
|
||||
return
|
||||
|
||||
modelAccess.value = undefined
|
||||
updateScheduling.value = false
|
||||
catalogAccountId.value = accounts[0]?.id
|
||||
schedulingEnabled.value = accounts.every(account => account.enabled)
|
||||
proxyMode.value = 'preserve'
|
||||
proxyId.value = ''
|
||||
@@ -43,8 +50,17 @@ export function useAccountBatchEditor(options: {
|
||||
async function save() {
|
||||
if (saving.value || options.selectedIds.value.size === 0)
|
||||
return
|
||||
const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value)
|
||||
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
|
||||
const modelError = accountModelAccessError(modelAccess.value)
|
||||
if (modelError) {
|
||||
toast.warning(modelError)
|
||||
return
|
||||
}
|
||||
if (!updateScheduling.value && !modelAccess.value) {
|
||||
toast.warning('请选择需要更新的设置')
|
||||
return
|
||||
}
|
||||
const scheduling = parseAccountSchedulingForm(updateScheduling.value ? concurrencyLimit.value : '', updateScheduling.value ? weight.value : '1')
|
||||
if (updateScheduling.value && proxyMode.value === 'proxy' && !proxyId.value.trim()) {
|
||||
toast.warning('请选择已通过测试的代理')
|
||||
return
|
||||
}
|
||||
@@ -57,11 +73,16 @@ export function useAccountBatchEditor(options: {
|
||||
const accountIds = selectedAccounts().map(account => account.id)
|
||||
await batchUpdateAccounts({
|
||||
accountIds,
|
||||
outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(),
|
||||
enabled: schedulingEnabled.value,
|
||||
concurrencyLimit: scheduling.values.concurrencyLimit,
|
||||
weight: scheduling.values.weight,
|
||||
groupIds: [...new Set(selectedGroupIds.value)],
|
||||
modelAccess: modelAccess.value,
|
||||
...(updateScheduling.value
|
||||
? {
|
||||
outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(),
|
||||
enabled: schedulingEnabled.value,
|
||||
concurrencyLimit: scheduling.values.concurrencyLimit,
|
||||
weight: scheduling.values.weight,
|
||||
groupIds: [...new Set(selectedGroupIds.value)],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
showBatchEditModal.value = false
|
||||
options.selectedIds.value = new Set()
|
||||
@@ -110,6 +131,9 @@ export function useAccountBatchEditor(options: {
|
||||
schedulingEnabled,
|
||||
concurrencyLimit,
|
||||
weight,
|
||||
modelAccess,
|
||||
updateScheduling,
|
||||
catalogAccountId,
|
||||
proxyMode,
|
||||
proxyId,
|
||||
selectedGroupIds,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { getAccounts } from '@/api'
|
||||
import type { AccountModelAccess, getAccounts } from '@/api'
|
||||
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import { updateAccount } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
import { accountModelAccessError } from '../utils/modelAccess'
|
||||
import { concurrencyLimitInput, parseAccountSchedulingForm } from '../utils/schedulingForm'
|
||||
|
||||
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
|
||||
@@ -20,6 +21,7 @@ export function useAccountEditor(options: {
|
||||
const schedulingEnabled = shallowRef(true)
|
||||
const concurrencyLimit = shallowRef('')
|
||||
const weight = shallowRef('1')
|
||||
const modelAccess = ref<AccountModelAccess | undefined>()
|
||||
const proxyMode = shallowRef('preserve')
|
||||
const proxyId = shallowRef('')
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
@@ -40,6 +42,7 @@ export function useAccountEditor(options: {
|
||||
schedulingEnabled.value = account.enabled
|
||||
concurrencyLimit.value = concurrencyLimitInput(account.concurrencyLimit)
|
||||
weight.value = String(account.weight)
|
||||
modelAccess.value = { ...account.modelAccess, models: [...account.modelAccess.models] }
|
||||
selectedGroupIds.value = account.groups.map(group => group.id)
|
||||
showEditModal.value = true
|
||||
}
|
||||
@@ -48,6 +51,11 @@ export function useAccountEditor(options: {
|
||||
const accountId = editingAccountId.value
|
||||
if (!accountId || saving.value)
|
||||
return
|
||||
const modelError = accountModelAccessError(modelAccess.value)
|
||||
if (modelError) {
|
||||
toast.warning(modelError)
|
||||
return
|
||||
}
|
||||
const scheduling = parseAccountSchedulingForm(concurrencyLimit.value, weight.value)
|
||||
if (proxyMode.value === 'proxy' && !proxyId.value.trim()) {
|
||||
toast.warning('请选择已通过测试的代理')
|
||||
@@ -66,6 +74,7 @@ export function useAccountEditor(options: {
|
||||
enabled: schedulingEnabled.value,
|
||||
concurrencyLimit: scheduling.values.concurrencyLimit,
|
||||
weight: scheduling.values.weight,
|
||||
modelAccess: modelAccess.value,
|
||||
groupIds: [...new Set(selectedGroupIds.value)],
|
||||
})
|
||||
showEditModal.value = false
|
||||
@@ -84,6 +93,7 @@ export function useAccountEditor(options: {
|
||||
schedulingEnabled.value = true
|
||||
concurrencyLimit.value = ''
|
||||
weight.value = '1'
|
||||
modelAccess.value = undefined
|
||||
selectedGroupIds.value = []
|
||||
})
|
||||
|
||||
@@ -94,6 +104,7 @@ export function useAccountEditor(options: {
|
||||
schedulingEnabled,
|
||||
concurrencyLimit,
|
||||
weight,
|
||||
modelAccess,
|
||||
proxyMode,
|
||||
proxyId,
|
||||
selectedGroupIds,
|
||||
|
||||
@@ -128,6 +128,9 @@ const {
|
||||
schedulingEnabled: batchSchedulingEnabled,
|
||||
concurrencyLimit: batchConcurrencyLimit,
|
||||
weight: batchWeight,
|
||||
modelAccess: batchModelAccess,
|
||||
updateScheduling: batchUpdateScheduling,
|
||||
catalogAccountId: batchCatalogAccountId,
|
||||
proxyMode: batchProxyMode,
|
||||
proxyId: batchProxyId,
|
||||
selectedGroupIds: batchGroupIds,
|
||||
@@ -148,6 +151,7 @@ const {
|
||||
schedulingEnabled,
|
||||
concurrencyLimit: editingConcurrencyLimit,
|
||||
weight: editingWeight,
|
||||
modelAccess: editingModelAccess,
|
||||
proxyMode: editingProxyMode,
|
||||
proxyId: editingProxyId,
|
||||
selectedGroupIds: editingGroupIds,
|
||||
@@ -354,6 +358,7 @@ const {
|
||||
v-model:enabled="schedulingEnabled"
|
||||
v-model:concurrency-limit="editingConcurrencyLimit"
|
||||
v-model:weight="editingWeight"
|
||||
v-model:model-access="editingModelAccess"
|
||||
v-model:proxy-mode="editingProxyMode"
|
||||
v-model:proxy-id="editingProxyId"
|
||||
v-model:selected-group-ids="editingGroupIds"
|
||||
@@ -369,9 +374,12 @@ const {
|
||||
v-model:enabled="batchSchedulingEnabled"
|
||||
v-model:concurrency-limit="batchConcurrencyLimit"
|
||||
v-model:weight="batchWeight"
|
||||
v-model:model-access="batchModelAccess"
|
||||
v-model:update-scheduling="batchUpdateScheduling"
|
||||
v-model:proxy-mode="batchProxyMode"
|
||||
v-model:proxy-id="batchProxyId"
|
||||
v-model:selected-group-ids="batchGroupIds"
|
||||
:catalog-account-id="batchCatalogAccountId"
|
||||
:selected-count="selectedIds.size"
|
||||
:groups="groups"
|
||||
:groups-loading="groupsLoading"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AccountModelAccess } from '@/api'
|
||||
|
||||
export function accountModelAccessError(value: AccountModelAccess | undefined): string | undefined {
|
||||
if (!value || value.mode === 'all')
|
||||
return undefined
|
||||
if (value.models.length === 0)
|
||||
return '请至少选择或添加一个模型'
|
||||
if (value.models.length > 256)
|
||||
return '最多配置 256 个模型'
|
||||
if (value.models.some(id => accountModelIdError(id)))
|
||||
return '请使用有效的精确模型 ID,不支持通配符'
|
||||
}
|
||||
|
||||
export function accountModelIdError(id: string): string | undefined {
|
||||
if (!id || id.trim() !== id || id.startsWith('__') || id.includes('*') || /\p{Cc}/u.test(id) || new TextEncoder().encode(id).length > 256)
|
||||
return '请输入有效的精确模型 ID,最多 256 字节,不支持通配符'
|
||||
}
|
||||
Reference in New Issue
Block a user