From cca89f2f960f3b09946e5ee5816bc79bb41dca31 Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:29:56 +0800 Subject: [PATCH] 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 --- .../gateway-admin/src/model/accounts.rs | 14 +- .../src/model/provider_credentials.rs | 1 + .../gateway-admin/src/use_case/accounts.rs | 2 +- .../gateway-admin/tests/use_case/accounts.rs | 14 +- .../src/admin/accounts/credentials.rs | 4 + .../gateway-api/src/admin/accounts/mod.rs | 4 +- .../src/admin/accounts/presenter.rs | 1 + .../gateway-api/src/admin/accounts/wire.rs | 52 +++-- .../gateway-api/tests/admin/accounts/mod.rs | 68 +++---- .../crates/gateway-api/tests/admin/errors.rs | 1 + .../crates/gateway-core/src/account/mod.rs | 5 + .../crates/gateway-core/src/account/model.rs | 15 ++ .../gateway-core/src/account/model_access.rs | 105 ++++++++++ .../crates/gateway-core/src/account/scope.rs | 30 +++ .../gateway-core/src/engine/execution.rs | 12 ++ .../gateway-core/src/routing/snapshot.rs | 37 +++- .../crates/gateway-core/tests/account/mod.rs | 1 + .../tests/account/model_access.rs | 36 ++++ .../crates/gateway-core/tests/routing/mod.rs | 57 ++++++ .../src/postgres/account_groups.rs | 2 +- .../provider_accounts/admin_adapter.rs | 42 +++- .../provider_accounts/admin_queries.rs | 2 +- .../provider_accounts/core_adapter.rs | 5 + .../src/postgres/provider_accounts/mapping.rs | 2 + .../postgres/provider_accounts/repository.rs | 57 +++--- .../src/postgres/provider_accounts/rows.rs | 23 ++- .../gateway-store/src/postgres/snapshot.rs | 25 ++- .../tests/postgres/provider_accounts/mod.rs | 134 +++++++++++-- .../gateway-store/tests/postgres/proxies.rs | 10 +- backend/crates/providers/openai/src/admin.rs | 3 + .../providers/openai/src/credential/admin.rs | 11 ++ .../providers/openai/src/credential/oauth.rs | 1 + .../openai/src/credential/selector.rs | 13 ++ .../crates/providers/openai/tests/admin.rs | 1 + .../openai/tests/credential/admin.rs | 8 +- .../openai/tests/credential/contract.rs | 182 ++++++++++++++++++ backend/crates/providers/xai/src/admin.rs | 6 + .../providers/xai/src/credential/import.rs | 11 ++ .../xai/src/credential/repository.rs | 2 + .../providers/xai/src/credential/selector.rs | 6 +- backend/crates/providers/xai/tests/admin.rs | 1 + .../xai/tests/credential/contract.rs | 14 +- .../xai/tests/credential/selector.rs | 82 ++++++++ backend/migrations/.frozen-sha256 | 1 + .../migrations/0009_account_model_access.sql | 18 ++ docs/api.md | 37 +++- docs/architecture.md | 11 +- frontend/src/api/modules/accounts.ts | 17 +- .../components/AccountBatchEditModal.vue | 41 ++-- .../AccountCreateModal/AccountSetupFields.vue | 2 + .../components/AccountCreateModal/index.vue | 4 +- .../components/AccountCreateModal/model.ts | 7 + .../accounts/components/AccountEditModal.vue | 7 +- .../components/AccountModelAccessField.vue | 161 ++++++++++++++++ .../components/AccountSettingsFields.vue | 87 +++++---- .../composables/useAccountBatchEditor.ts | 40 +++- .../accounts/composables/useAccountEditor.ts | 13 +- frontend/src/views/accounts/index.vue | 8 + .../src/views/accounts/utils/modelAccess.ts | 17 ++ 59 files changed, 1350 insertions(+), 223 deletions(-) create mode 100644 backend/crates/gateway-core/src/account/model_access.rs create mode 100644 backend/crates/gateway-core/tests/account/model_access.rs create mode 100644 backend/migrations/0009_account_model_access.sql create mode 100644 frontend/src/views/accounts/components/AccountModelAccessField.vue create mode 100644 frontend/src/views/accounts/utils/modelAccess.ts diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index bce7be4a7..751a05612 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -25,6 +25,7 @@ pub struct AccountImportSettings { pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, + pub model_access: Option, pub group_ids: Vec, } @@ -100,6 +101,7 @@ pub struct AccountRecord { pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, + pub model_access: gateway_core::account::AccountModelAccess, pub outbound_proxy: Option, pub credential_state: CredentialState, pub credential_observed_at: DateTime, @@ -224,6 +226,7 @@ pub struct UpdateAccount { pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, + pub model_access: Option, pub group_ids: Vec, pub outbound_proxy: Option, } @@ -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, - pub enabled: bool, - pub concurrency_limit: Option, - pub weight: AccountWeight, - pub group_ids: Vec, + pub enabled: Option, + pub concurrency_limit: Option>, + pub weight: Option, + pub model_access: Option, + pub group_ids: Option>, pub outbound_proxy: Option, } diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 3a3dae531..28d5611b4 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -99,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport { /// Provider 已验证、可由 Store 原子创建的一份 credential。 #[derive(Debug, Clone, PartialEq)] pub struct PreparedCredentialCreate { + pub model_access: Option, pub outbound_proxy: Option, pub account_id: ProviderAccountId, pub provider_kind: ProviderKind, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 350dbf8f1..bf840c5c7 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -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; } diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 00d0c9477..23d2f82c5 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -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"), diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index 02ddd724c..11a8ecd83 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -39,6 +39,7 @@ pub struct AccountImportSettingsRequest { #[serde(deserialize_with = "deserialize_required_nullable")] pub concurrency_limit: Option, pub weight: u64, + pub model_access: Option, pub group_ids: Vec, } @@ -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, pub weight: u64, + pub model_access: Option, pub group_ids: Vec, } @@ -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)?, }) } diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index 3144163e7..27825afaf 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -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; diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 9263992e7..6bc5f5fd3 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -104,6 +104,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> 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() diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 9b287a6aa..ce8f84a71 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest { pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_ids: Vec, - pub enabled: bool, - #[serde(deserialize_with = "deserialize_required_nullable")] - pub concurrency_limit: Option, - pub weight: u64, - pub group_ids: Vec, + pub enabled: Option, + #[serde(default, deserialize_with = "deserialize_optional_nullable_limit")] + pub concurrency_limit: Option>, + pub weight: Option, + pub model_access: Option, + pub group_ids: Option>, } #[derive(Debug, Clone, Serialize)] @@ -91,6 +92,12 @@ impl From for BatchUpdatedAccountsData { } } +fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + Option::::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, pub weight: u16, + pub model_access: gateway_core::account::AccountModelAccess, pub access_token_expires_at: Option, pub access_token_expires_at_display: Option, pub refresh_token_expires_at: Option, diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index a71141a7e..20892cb08 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -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::(json!({ - "accountIds": ["acct_test"], - "concurrencyLimit": null, - "weight": 1, - "groupIds": [] - })) - .is_err() - ); - assert!( - serde_json::from_value::(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::(json!({ - "accountIds": ["acct_test"], - "enabled": true, - "weight": 1, - "groupIds": [] - })) - .is_err() - ); - assert!( - serde_json::from_value::(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] diff --git a/backend/crates/gateway-api/tests/admin/errors.rs b/backend/crates/gateway-api/tests/admin/errors.rs index 64e9fbe57..b84c653e3 100644 --- a/backend/crates/gateway-api/tests/admin/errors.rs +++ b/backend/crates/gateway-api/tests/admin/errors.rs @@ -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(), diff --git a/backend/crates/gateway-core/src/account/mod.rs b/backend/crates/gateway-core/src/account/mod.rs index c6fb4472e..625e571ca 100644 --- a/backend/crates/gateway-core/src/account/mod.rs +++ b/backend/crates/gateway-core/src/account/mod.rs @@ -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; diff --git a/backend/crates/gateway-core/src/account/model.rs b/backend/crates/gateway-core/src/account/model.rs index 5ce2762c3..d05c16da9 100644 --- a/backend/crates/gateway-core/src/account/model.rs +++ b/backend/crates/gateway-core/src/account/model.rs @@ -669,6 +669,7 @@ pub struct ProviderAccount { enabled: bool, concurrency_limit: Option, weight: AccountWeight, + model_access: super::AccountModelAccess, credential_state: CredentialState, quota: QuotaState, last_error_reason: Option, @@ -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) -> Self { self.outbound_proxy = proxy; @@ -1007,6 +1020,8 @@ impl ProviderRefreshQuery { #[derive(Clone, PartialEq)] pub struct NewProviderAccount { pub account: ProviderAccount, + /// 导入时显式提供的政策;省略时保留已有账号设置。 + pub model_access: Option, pub credential: PlaintextCredential, } diff --git a/backend/crates/gateway-core/src/account/model_access.rs b/backend/crates/gateway-core/src/account/model_access.rs new file mode 100644 index 000000000..549ce3141 --- /dev/null +++ b/backend/crates/gateway-core/src/account/model_access.rs @@ -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, +} + +/// 不携带原始输入,避免在错误边界回显配置正文。 +#[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, + ) -> Result { + 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 { + &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, +} + +impl<'de> Deserialize<'de> for AccountModelAccess { + fn deserialize>(deserializer: D) -> Result { + let document = ModelAccessDocument::deserialize(deserializer)?; + Self::new(document.mode, document.models).map_err(serde::de::Error::custom) + } +} diff --git a/backend/crates/gateway-core/src/account/scope.rs b/backend/crates/gateway-core/src/account/scope.rs index dee480f70..3aaf4c197 100644 --- a/backend/crates/gateway-core/src/account/scope.rs +++ b/backend/crates/gateway-core/src/account/scope.rs @@ -47,6 +47,7 @@ impl fmt::Display for AccountGroupId { pub struct RuntimeAccount { provider_kind: ProviderKind, group_ids: Arc>, + 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 { match &self.client_scope { diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index 5af36e9fc..c75152edc 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -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::>(); 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; } diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index fb48399bf..0224ded16 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -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::>() .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] diff --git a/backend/crates/gateway-core/tests/account/mod.rs b/backend/crates/gateway-core/tests/account/mod.rs index f1b775a5e..b2fc1957b 100644 --- a/backend/crates/gateway-core/tests/account/mod.rs +++ b/backend/crates/gateway-core/tests/account/mod.rs @@ -1,3 +1,4 @@ +mod model_access; mod proxy; mod selection; mod selection_diagnostics; diff --git a/backend/crates/gateway-core/tests/account/model_access.rs b/backend/crates/gateway-core/tests/account/model_access.rs new file mode 100644 index 000000000..63716981e --- /dev/null +++ b/backend/crates/gateway-core/tests/account/model_access.rs @@ -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::(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"] + }) + ); +} diff --git a/backend/crates/gateway-core/tests/routing/mod.rs b/backend/crates/gateway-core/tests/routing/mod.rs index 2e737f79e..19ca6aed8 100644 --- a/backend/crates/gateway-core/tests/routing/mod.rs +++ b/backend/crates/gateway-core/tests/routing/mod.rs @@ -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!["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!["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" + ); +} diff --git a/backend/crates/gateway-store/src/postgres/account_groups.rs b/backend/crates/gateway-store/src/postgres/account_groups.rs index 0cf51c203..b27896aff 100644 --- a/backend/crates/gateway-store/src/postgres/account_groups.rs +++ b/backend/crates/gateway-store/src/postgres/account_groups.rs @@ -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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index ee8841064..d8a7602de 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -226,6 +226,15 @@ impl PgAdminAccountStore { .collect::>>() .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( diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 02b40213c..4c8590251 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -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, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs index aa1b56094..f944a57e5 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs @@ -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(), }) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs index af5abb732..af716415a 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs @@ -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, }) diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 220ab4f7f..00072c911 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -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 { 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, - weight: AccountWeight, + enabled: Option, + concurrency_limit: Option>, + weight: Option, + 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"))? diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index a7323b59f..832c3fc07 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -137,6 +137,7 @@ pub struct ProviderAccountSummary { pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, + pub model_access: gateway_core::account::AccountModelAccess, pub credential_state: CredentialState, pub credential_observed_at: DateTime, pub quota: QuotaState, @@ -185,6 +186,7 @@ pub struct NewProviderAccount { pub enabled: bool, pub concurrency_limit: Option, pub weight: AccountWeight, + pub model_access: Option, pub credential_state: CredentialState, pub credential_observed_at: DateTime, } @@ -318,10 +320,11 @@ pub struct BatchUpdateProviderAccountsAdmin { pub outbound_proxy: Option, pub account_ids: Vec, pub notes: Option, - pub enabled: bool, - pub concurrency_limit: Option, - pub weight: AccountWeight, - pub group_ids: Vec, + pub enabled: Option, + pub concurrency_limit: Option>, + pub weight: Option, + pub model_access: Option, + pub group_ids: Option>, 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::>( + &row, + "model_access_json", + )? + .0, credential_state: parse_credential_state(&credential_state)?, credential_observed_at: get(&row, "credential_observed_at")?, quota, diff --git a/backend/crates/gateway-store/src/postgres/snapshot.rs b/backend/crates/gateway-store/src/postgres/snapshot.rs index 76d8ea0ea..410f70471 100644 --- a/backend/crates/gateway-store/src/postgres/snapshot.rs +++ b/backend/crates/gateway-store/src/postgres/snapshot.rs @@ -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::, _>>()?; @@ -315,15 +319,26 @@ async fn load_account_groups( async fn load_provider_accounts( transaction: &mut Transaction<'_, Postgres>, ) -> StoreResult> { - sqlx::query_as::<_, (String, String)>( - "select id, provider_kind from provider_accounts order by id", - ) + sqlx::query_as::< + _, + ( + String, + String, + sqlx::types::Json, + ), + >("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() }) } diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index dccf37138..38b37ac0d 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -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; +} diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index 0792b73c6..34868bd18 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -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() )), diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 85314187b..d18db1d58 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -673,9 +673,11 @@ fn prepared_create( ) -> Result { 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, name: Option, email: Option, 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, account_id: Option, @@ -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 { 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)?, diff --git a/backend/crates/providers/openai/src/credential/oauth.rs b/backend/crates/providers/openai/src/credential/oauth.rs index 23755dd55..3d7dfe782 100644 --- a/backend/crates/providers/openai/src/credential/oauth.rs +++ b/backend/crates/providers/openai/src/credential/oauth.rs @@ -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()), diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index c8feef2ce..f0f8ce8ef 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -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::>(); + 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; } diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index a4a2495e4..d5021f8f6 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -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(), diff --git a/backend/crates/providers/openai/tests/credential/admin.rs b/backend/crates/providers/openai/tests/credential/admin.rs index 2cdd68d05..5963459b5 100644 --- a/backend/crates/providers/openai/tests/credential/admin.rs +++ b/backend/crates/providers/openai/tests/credential/admin.rs @@ -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] diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index 9bb1bf00a..4fb3932b9 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -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, + excluded: BTreeSet, +) -> 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) + ); +} diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index b324526b5..45b5b16d6 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -444,6 +444,7 @@ impl ProviderAdmin for XaiAdminProvider { // 日志得知需要重新授权。 let mut first_failure: Option = 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 { 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, name: String, email: Option, outbound_proxy: Option, @@ -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, diff --git a/backend/crates/providers/xai/src/credential/repository.rs b/backend/crates/providers/xai/src/credential/repository.rs index bef40e7b8..98a033cf9 100644 --- a/backend/crates/providers/xai/src/credential/repository.rs +++ b/backend/crates/providers/xai/src/credential/repository.rs @@ -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, diff --git a/backend/crates/providers/xai/src/credential/selector.rs b/backend/crates/providers/xai/src/credential/selector.rs index ddace3aa4..71c7964af 100644 --- a/backend/crates/providers/xai/src/credential/selector.rs +++ b/backend/crates/providers/xai/src/credential/selector.rs @@ -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 { diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index f363f6b2f..1595a3340 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -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(), diff --git a/backend/crates/providers/xai/tests/credential/contract.rs b/backend/crates/providers/xai/tests/credential/contract.rs index a4342253a..bedf10c23 100644 --- a/backend/crates/providers/xai/tests/credential/contract.rs +++ b/backend/crates/providers/xai/tests/credential/contract.rs @@ -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] diff --git a/backend/crates/providers/xai/tests/credential/selector.rs b/backend/crates/providers/xai/tests/credential/selector.rs index 711e8ea50..6403d9333 100644 --- a/backend/crates/providers/xai/tests/credential/selector.rs +++ b/backend/crates/providers/xai/tests/credential/selector.rs @@ -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) + ); +} diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 883f33e98..1af694aee 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -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 diff --git a/backend/migrations/0009_account_model_access.sql b/backend/migrations/0009_account_model_access.sql new file mode 100644 index 000000000..446f6f952 --- /dev/null +++ b/backend/migrations/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 + ); diff --git a/docs/api.md b/docs/api.md index 3aae2b4bf..0eac785fa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 请求字段为: diff --git a/docs/architecture.md b/docs/architecture.md index c4c4af5da..7566fb4e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 和发送/交付边界约束: diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index bb80edfde..bff4c54ba 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -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[] } diff --git a/frontend/src/views/accounts/components/AccountBatchEditModal.vue b/frontend/src/views/accounts/components/AccountBatchEditModal.vue index 94fca00ae..ca0bc8aae 100644 --- a/frontend/src/views/accounts/components/AccountBatchEditModal.vue +++ b/frontend/src/views/accounts/components/AccountBatchEditModal.vue @@ -1,12 +1,14 @@ diff --git a/frontend/src/views/accounts/components/AccountCreateModal/model.ts b/frontend/src/views/accounts/components/AccountCreateModal/model.ts index 5d78dfe89..ef7ad1899 100644 --- a/frontend/src/views/accounts/components/AccountCreateModal/model.ts +++ b/frontend/src/views/accounts/components/AccountCreateModal/model.ts @@ -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)], diff --git a/frontend/src/views/accounts/components/AccountEditModal.vue b/frontend/src/views/accounts/components/AccountEditModal.vue index 535f3aa9f..4456a86a3 100644 --- a/frontend/src/views/accounts/components/AccountEditModal.vue +++ b/frontend/src/views/accounts/components/AccountEditModal.vue @@ -1,6 +1,6 @@ + + diff --git a/frontend/src/views/accounts/components/AccountSettingsFields.vue b/frontend/src/views/accounts/components/AccountSettingsFields.vue index 3f082198a..fa3eb300f 100644 --- a/frontend/src/views/accounts/components/AccountSettingsFields.vue +++ b/frontend/src/views/accounts/components/AccountSettingsFields.vue @@ -1,9 +1,10 @@