From 325466d9b42de4256392b37b704ccf3ec8be9a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 16:13:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20AGC=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9B=AE=E5=BD=95=E6=96=B0=E5=A2=9E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=E6=8C=89=E6=A1=A3=E8=BF=87=E6=BB=A4=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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:显式越权不静默回退,默认模型优先登记项、不可用时回退目录顺序首个可用项 - 覆盖缺省解析、档位过滤、越权与未知模型区分、无可用模型等纯函数测试 --- .../module-runtime/src/agc_model_access.rs | 290 ++++++++++++++++++ .../crates/module-runtime/src/agc_models.rs | 7 + server-rs/crates/module-runtime/src/lib.rs | 2 + 3 files changed, 299 insertions(+) create mode 100644 server-rs/crates/module-runtime/src/agc_model_access.rs diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs new file mode 100644 index 000000000..76559aaed --- /dev/null +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -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 { + 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 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 { + 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::>(); + 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" + ); + } +} diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index a6d406afc..05e115db8 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -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, + /// 该模型要求的会员档位;缺省 `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(), } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index fb0cb083d..116e51d10 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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::*;