feat(会员): AGC 模型目录新增权限档与按档过滤能力
- 新增 agc_model_access.rs:AgcModelAccess(basic/full,缺省 basic 失败开放)、越权错误码 MODEL_NOT_AVAILABLE_FOR_PLAN、AgcModelResolveError,以及会员档位到模型权限档的映射 - AgcModel 末位追加 #[serde(default)] access,存量目录 JSON 缺字段按 basic 解析 - AgcModelCatalog 新增 available_models_for / default_model_id_for / resolve_requested_for:显式越权不静默回退,默认模型优先登记项、不可用时回退目录顺序首个可用项 - 覆盖缺省解析、档位过滤、越权与未知模型区分、无可用模型等纯函数测试
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
//! AGC 模型权限档与「按会员档位过滤模型目录」的判定(纯函数,无 IO)。
|
||||
//!
|
||||
//! 模型目录是全局的,但每个模型带一个权限档;会员档位(`profile_membership_plan.model_access`)
|
||||
//! 决定账号能看到、能调用哪些模型。这里只做判定与解析,SpacetimeDB 读写留在 `spacetime-module`,
|
||||
//! HTTP 错误映射留在 `api-server`。
|
||||
//!
|
||||
//! 失败开放:模型权限档缺省 [`AgcModelAccess::Basic`],因此存量目录 JSON 缺字段、后台未人工标注
|
||||
//! `Full` 时都不会拦截任何请求;只有明确标了 `Full` 的模型才会对 `Basic` 档账号报越权。
|
||||
//!
|
||||
//! 本文件把「档位可见集合」「档位默认模型」「请求侧解析」收在一处,调用方只接触
|
||||
//! [`AgcModelCatalog::resolve_requested_for`] 这类语义方法,不自己拼过滤条件。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess,
|
||||
};
|
||||
|
||||
/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。
|
||||
pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN";
|
||||
|
||||
/// 单个模型要求的权限档。
|
||||
///
|
||||
/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理,
|
||||
/// 只有被后台明确标成 `Full` 的模型才会对 `Basic` 档账号产生拦截。
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AgcModelAccess {
|
||||
#[default]
|
||||
Basic,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl AgcModelAccess {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Basic => "basic",
|
||||
Self::Full => "full",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"basic" => Some(Self::Basic),
|
||||
"full" => Some(Self::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前账号档位是否覆盖模型要求的档位:`Full` 覆盖全部,`Basic` 只覆盖基础。
|
||||
pub const fn allows(self, required: Self) -> bool {
|
||||
matches!(
|
||||
(self, required),
|
||||
(Self::Full, _) | (Self::Basic, Self::Basic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeProfileMembershipModelAccess> for AgcModelAccess {
|
||||
fn from(value: RuntimeProfileMembershipModelAccess) -> Self {
|
||||
match value {
|
||||
RuntimeProfileMembershipModelAccess::Basic => Self::Basic,
|
||||
RuntimeProfileMembershipModelAccess::Full => Self::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AgcModelResolveError {
|
||||
/// 标识不在目录、模型已停用,或该档位没有任何可用模型。
|
||||
Unavailable,
|
||||
/// 模型存在且启用,但当前会员档位不够(越权)。
|
||||
NotAvailableForPlan,
|
||||
}
|
||||
|
||||
impl AgcModelResolveError {
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unavailable => "MODEL_UNAVAILABLE",
|
||||
Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unavailable => "所选模型不可用,请刷新模型列表",
|
||||
Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgcModel {
|
||||
/// 该模型对给定账号档位是否可见 / 可调用(既要启用,也要档位足够)。
|
||||
pub fn is_available_for(&self, access: AgcModelAccess) -> bool {
|
||||
self.enabled && access.allows(self.access)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgcModelCatalog {
|
||||
/// 该档位可见的模型,保持目录顺序。
|
||||
pub fn available_models_for(&self, access: AgcModelAccess) -> impl Iterator<Item = &AgcModel> {
|
||||
self.models
|
||||
.iter()
|
||||
.filter(move |model| model.is_available_for(access))
|
||||
}
|
||||
|
||||
/// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时
|
||||
/// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。
|
||||
pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> {
|
||||
let configured = self
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.id == self.default_model_id)
|
||||
.filter(|model| model.is_available_for(access));
|
||||
if configured.is_some() {
|
||||
return Some(self.default_model_id.as_str());
|
||||
}
|
||||
self.available_models_for(access)
|
||||
.map(|model| model.id.as_str())
|
||||
.next()
|
||||
}
|
||||
|
||||
/// 请求侧解析(带档位)。
|
||||
///
|
||||
/// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。
|
||||
/// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`],
|
||||
/// **不静默回退**成其它模型。
|
||||
pub fn resolve_requested_for(
|
||||
&self,
|
||||
requested: Option<&str>,
|
||||
access: AgcModelAccess,
|
||||
) -> Result<&str, AgcModelResolveError> {
|
||||
let requested = requested
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
|
||||
match requested {
|
||||
Some(id) => match self.models.iter().find(|model| model.id == id) {
|
||||
Some(model) if !model.enabled => Err(AgcModelResolveError::Unavailable),
|
||||
Some(model) if access.allows(model.access) => Ok(model.model_id.as_str()),
|
||||
Some(_) => Err(AgcModelResolveError::NotAvailableForPlan),
|
||||
None => Err(AgcModelResolveError::Unavailable),
|
||||
},
|
||||
None => {
|
||||
let id = self
|
||||
.default_model_id_for(access)
|
||||
.ok_or(AgcModelResolveError::Unavailable)?;
|
||||
self.models
|
||||
.iter()
|
||||
.find(|model| model.id == id && model.enabled)
|
||||
.map(|model| model.model_id.as_str())
|
||||
.ok_or(AgcModelResolveError::Unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn catalog_from_ids(ids: &[&str], default_index: usize) -> AgcModelCatalog {
|
||||
let mut catalog =
|
||||
AgcModelCatalog::from_upstream_models(ids.iter().map(|id| id.to_string()), 0)
|
||||
.expect("目录应从上游模型名构建");
|
||||
let default_id = catalog.models[default_index].id.clone();
|
||||
catalog.default_model_id = default_id;
|
||||
catalog
|
||||
}
|
||||
|
||||
fn mark_full(catalog: &mut AgcModelCatalog, id: &str) {
|
||||
let model = catalog
|
||||
.models
|
||||
.iter_mut()
|
||||
.find(|model| model.id == id)
|
||||
.expect("测试模型应存在");
|
||||
model.access = AgcModelAccess::Full;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_defaults_to_basic_and_parses_strictly() {
|
||||
assert_eq!(AgcModelAccess::default(), AgcModelAccess::Basic);
|
||||
assert_eq!(AgcModelAccess::parse("basic"), Some(AgcModelAccess::Basic));
|
||||
assert_eq!(AgcModelAccess::parse("full"), Some(AgcModelAccess::Full));
|
||||
assert_eq!(AgcModelAccess::parse("Full"), None);
|
||||
assert_eq!(AgcModelAccess::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_plan_covers_every_model_and_basic_plan_only_basic() {
|
||||
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Basic));
|
||||
assert!(AgcModelAccess::Full.allows(AgcModelAccess::Full));
|
||||
assert!(AgcModelAccess::Basic.allows(AgcModelAccess::Basic));
|
||||
assert!(!AgcModelAccess::Basic.allows(AgcModelAccess::Full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_catalog_json_defaults_missing_access_to_basic() {
|
||||
// 存量目录 JSON 没有 access 字段:按 Basic 解析,失败开放。
|
||||
let catalog: AgcModelCatalog = serde_json::from_str(
|
||||
r#"{"revision":1,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"codex"}]}"#,
|
||||
)
|
||||
.expect("存量目录应可读");
|
||||
assert_eq!(catalog.models[0].access, AgcModelAccess::Basic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_plan_does_not_see_full_models_and_gets_first_basic_default() {
|
||||
let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 1);
|
||||
mark_full(&mut catalog, "model-b");
|
||||
|
||||
let visible = catalog
|
||||
.available_models_for(AgcModelAccess::Basic)
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(visible, vec!["model-a", "model-c"]);
|
||||
|
||||
// 登记的默认项 model-b 是 Full,Basic 档回退到目录顺序里第一个可用项。
|
||||
assert_eq!(
|
||||
catalog.default_model_id_for(AgcModelAccess::Basic),
|
||||
Some("model-a")
|
||||
);
|
||||
// Full 档保留登记的默认项。
|
||||
assert_eq!(
|
||||
catalog.default_model_id_for(AgcModelAccess::Full),
|
||||
Some("model-b")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_requested_for_distinguishes_unknown_from_out_of_plan() {
|
||||
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
|
||||
mark_full(&mut catalog, "model-b");
|
||||
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Basic),
|
||||
Err(AgcModelResolveError::NotAvailableForPlan)
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic),
|
||||
Err(AgcModelResolveError::Unavailable)
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
|
||||
Ok("model-b")
|
||||
);
|
||||
// 未选择时用该档默认模型。
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
|
||||
Ok("model-a")
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(Some(AGC_MODEL_PLATFORM_DEFAULT), AgcModelAccess::Full),
|
||||
Ok("model-a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_model_is_unavailable_even_for_full_plan() {
|
||||
let mut catalog = catalog_from_ids(&["model-a", "model-b"], 0);
|
||||
catalog.models[1].enabled = false;
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(Some("model-b"), AgcModelAccess::Full),
|
||||
Err(AgcModelResolveError::Unavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_with_no_available_model_falls_back_to_unavailable() {
|
||||
let mut catalog = catalog_from_ids(&["model-a"], 0);
|
||||
mark_full(&mut catalog, "model-a");
|
||||
assert_eq!(catalog.default_model_id_for(AgcModelAccess::Basic), None);
|
||||
assert_eq!(
|
||||
catalog.resolve_requested_for(None, AgcModelAccess::Basic),
|
||||
Err(AgcModelResolveError::Unavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_error_codes_are_stable() {
|
||||
assert_eq!(
|
||||
AgcModelResolveError::NotAvailableForPlan.code(),
|
||||
MODEL_NOT_AVAILABLE_FOR_PLAN
|
||||
);
|
||||
assert_eq!(
|
||||
AgcModelResolveError::Unavailable.code(),
|
||||
"MODEL_UNAVAILABLE"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::AgcModelAccess;
|
||||
|
||||
/// 目录 revision 乐观锁冲突。
|
||||
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
|
||||
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
|
||||
@@ -120,6 +122,9 @@ pub struct AgcModel {
|
||||
/// 保存时必须与 `agent_mode` 组成合法组合。
|
||||
#[serde(default)]
|
||||
pub protocol: Option<AgcModelProtocol>,
|
||||
/// 该模型要求的会员档位;缺省 `Basic`(失败开放),由人工在后台标 `Full`。
|
||||
#[serde(default)]
|
||||
pub access: AgcModelAccess,
|
||||
}
|
||||
|
||||
impl AgcModel {
|
||||
@@ -169,6 +174,7 @@ impl AgcModelCatalog {
|
||||
enabled: true,
|
||||
agent_mode: AgcAgentMode::default(),
|
||||
protocol: None,
|
||||
access: AgcModelAccess::default(),
|
||||
});
|
||||
}
|
||||
let default_model_id = entries
|
||||
@@ -316,6 +322,7 @@ mod tests {
|
||||
enabled: true,
|
||||
agent_mode: AgcAgentMode::default(),
|
||||
protocol: None,
|
||||
access: AgcModelAccess::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod agc_analytics;
|
||||
pub mod civil_calendar;
|
||||
pub mod membership;
|
||||
|
||||
mod agc_model_access;
|
||||
mod agc_models;
|
||||
mod payment;
|
||||
pub use payment::*;
|
||||
@@ -12,6 +13,7 @@ mod errors;
|
||||
mod events;
|
||||
mod llm_billing;
|
||||
|
||||
pub use agc_model_access::*;
|
||||
pub use agc_models::*;
|
||||
pub use application::*;
|
||||
pub use commands::*;
|
||||
|
||||
Reference in New Issue
Block a user