From e91c319aa85f54851c95e8948f0731fee9ce698d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sun, 4 Oct 2026 17:41:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(LLM):=20=E3=80=8C=E8=AF=A5=E6=A1=A3?= =?UTF-8?q?=E6=97=A0=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B=E3=80=8D=E8=A1=A5?= =?UTF-8?q?=E4=B8=93=E7=94=A8=E5=8F=98=E4=BD=93=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=BA=20503?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgcModelResolveError 新增 NoModelForTier,与「某模型不可用」/「档位不够」区分;code() 仍复用 MODEL_UNAVAILABLE - 代理路径 resolve_requested_for(None, access) 不再折叠成 Unavailable,agc_model_resolve_error 把 NoModelForTier 映射到 503,与 /api/llm/models 列表端点一致(评审第 8 条) - public_model_catalog 直接复用 agc_model_resolve_error(NoModelForTier),消除手写 503 + 字面量错误码 - 更新 model_access.rs 契约注释与 module-runtime / api-server 测试 --- server-rs/crates/api-server/src/llm/mod.rs | 19 +++++++++----- .../crates/api-server/src/llm/model_access.rs | 3 ++- .../module-runtime/src/agc_model_access.rs | 26 ++++++++++++++----- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index b12bbb318..0ca4eea09 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -127,6 +127,14 @@ mod model_catalog_tests { assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY); } + #[test] + fn tier_without_model_maps_to_service_unavailable_with_model_unavailable_code() { + // 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422。 + let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier); + assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(error.code(), "MODEL_UNAVAILABLE"); + } + #[test] fn access_lookup_failure_maps_to_service_unavailable_with_dedicated_code() { // 读链路失败必须是独立的 503 + 专用错误码,不能与 403 档位不足 / 422 模型不可用混用, @@ -277,13 +285,10 @@ fn public_model_catalog( ) -> Result { let OwnerAgcModelCatalog { catalog, access } = owner_catalog; // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 + // 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 503 + MODEL_UNAVAILABLE。 let default_model_id = catalog .default_model_id_for(access) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) - .with_code(AgcModelResolveError::Unavailable.code()) - .with_message("当前会员档位没有可用模型") - })? + .ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))? .to_string(); let models = catalog .available_models_for(access) @@ -370,11 +375,13 @@ async fn load_owner_llm_catalog( }) } -/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用仍是 422。 +/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用是 422; +/// 「该档没有任何可用模型」是目录 / 档位配置问题,按 503 处理,和 `/api/llm/models` 列表端点一致。 fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError { let status = match error { AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN, AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY, + AgcModelResolveError::NoModelForTier => StatusCode::SERVICE_UNAVAILABLE, }; AppError::from_status(status) .with_code(error.code()) diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs index c5632c36b..3e538ee05 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -5,7 +5,8 @@ //! //! LLM 路由对外错误码(三条互不重叠,客户端据此区分处理): //! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。 -//! - `MODEL_UNAVAILABLE`(422,列表无可用模型时 503):模型不在目录、已停用,或该档在目录里没有任何可用模型。 +//! - `MODEL_UNAVAILABLE`(422;该档在目录里没有任何可用模型时 503):模型不在目录或已停用; +//! 「整个档位没有可用模型」属于目录 / 档位配置问题,列表端点与代理端点统一按 503 上报。 //! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。 //! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。 //! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。 diff --git a/server-rs/crates/module-runtime/src/agc_model_access.rs b/server-rs/crates/module-runtime/src/agc_model_access.rs index a29e5793d..4d4285aa7 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -69,16 +69,18 @@ impl From for AgcModelAccess { /// 模型解析失败原因;调用方据此选择错误码与 HTTP 状态,不靠字符串匹配。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AgcModelResolveError { - /// 标识不在目录、模型已停用,或该档位没有任何可用模型。 + /// 标识不在目录、模型已停用。 Unavailable, /// 模型存在且启用,但当前会员档位不够(越权)。 NotAvailableForPlan, + /// 该会员档位在目录里没有任何可用模型:属于目录 / 档位配置问题,不是某个模型的问题。 + NoModelForTier, } impl AgcModelResolveError { pub const fn code(self) -> &'static str { match self { - Self::Unavailable => "MODEL_UNAVAILABLE", + Self::Unavailable | Self::NoModelForTier => "MODEL_UNAVAILABLE", Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN, } } @@ -87,6 +89,7 @@ impl AgcModelResolveError { match self { Self::Unavailable => "所选模型不可用,请刷新模型列表", Self::NotAvailableForPlan => "当前会员档位不能使用该模型,请升级会员或重新选择模型", + Self::NoModelForTier => "当前会员档位没有可用模型", } } } @@ -146,7 +149,7 @@ impl AgcModelCatalog { /// /// `requested` 为空或占位标识时用该档默认模型;显式标识必须是目录内、启用、且档位允许的模型。 /// 目录外 / 已停用 → [`AgcModelResolveError::Unavailable`];档位不够 → [`AgcModelResolveError::NotAvailableForPlan`], - /// **不静默回退**成其它模型。 + /// **不静默回退**成其它模型;该档没有任何可用模型 → [`AgcModelResolveError::NoModelForTier`]。 pub fn resolve_requested_for( &self, requested: Option<&str>, @@ -165,12 +168,12 @@ impl AgcModelCatalog { None => { let id = self .default_model_id_for(access) - .ok_or(AgcModelResolveError::Unavailable)?; + .ok_or(AgcModelResolveError::NoModelForTier)?; self.models .iter() .find(|model| model.id == id && model.is_available_for(access)) .map(|model| model.model_id.as_str()) - .ok_or(AgcModelResolveError::Unavailable) + .ok_or(AgcModelResolveError::NoModelForTier) } } } @@ -287,12 +290,17 @@ mod tests { } #[test] - fn plan_with_no_available_model_falls_back_to_unavailable() { + fn plan_with_no_available_model_reports_no_model_for_tier() { 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::NoModelForTier) + ); + // 显式点了目录外的模型仍是「该模型不可用」,不能和「整个档位没有模型」混为一谈。 + assert_eq!( + catalog.resolve_requested_for(Some("model-missing"), AgcModelAccess::Basic), Err(AgcModelResolveError::Unavailable) ); } @@ -307,6 +315,12 @@ mod tests { AgcModelResolveError::Unavailable.code(), "MODEL_UNAVAILABLE" ); + // 「该档没有可用模型」沿用 MODEL_UNAVAILABLE 错误码,但调用方按变体把它映射到 503, + // 与列表端点的状态保持一致。 + assert_eq!( + AgcModelResolveError::NoModelForTier.code(), + "MODEL_UNAVAILABLE" + ); } #[test]