feat(accounts): add per-account model restrictions (#105)

Add per-account model allowlists and denylists to frozen routing, account settings, and CPR import/export. Preserve existing scheduling and account state boundaries.

Closes #97

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