fix(LLM): 「该档无可用模型」补专用变体并统一为 503

- 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 测试
This commit is contained in:
2026-10-04 17:41:10 +08:00
parent 55bbf27e8f
commit e91c319aa8
3 changed files with 35 additions and 13 deletions
+13 -6
View File
@@ -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<LlmModelsResponse, AppError> {
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())
@@ -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`。
@@ -69,16 +69,18 @@ impl From<RuntimeProfileMembershipModelAccess> 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]