修复(AGC模型): 档位无可用模型改用独立错误码

- module-runtime 新增 MODEL_UNAVAILABLE / MODEL_UNAVAILABLE_FOR_TIER 常量,NoModelForTier.code() 由共用 MODEL_UNAVAILABLE 改为 MODEL_UNAVAILABLE_FOR_TIER(仍 503),与单个模型不可用的 422 区分
- 更新 resolve_error_codes_are_stable 与 api-server llm 单测;收窄 llm/model_access 模块注释
- 同步设计文档 §11.4 与决策日志
This commit is contained in:
2026-10-05 15:11:06 +08:00
parent c3866b4267
commit c85da3a983
5 changed files with 35 additions and 17 deletions
@@ -9736,3 +9736,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 行为变化:后台邀请码的入参校验失败与 `邀请码已被其他用户占用` 由 502 变为 400 且带机器码;procedure result 新增字段仍要求 module 与 api-server 同版本部署。
- 影响范围:`server-rs/crates/module-runtime/src/domain.rs`、`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`、`server-rs/crates/spacetime-client/src/{active.rs,active/mapper/runtime_profile.rs,module_bindings,module_bindings.rs}`、`server-rs/crates/api-server/src/runtime_profile.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §5.8、本文件。
- 验证:`npm run spacetime:generate`;`cargo check`(module-runtime / spacetime-module / spacetime-client / api-server);`cargo test -p spacetime-client` 32 passed;`cargo test -p api-server runtime_profile::tests` 43 passed;`cargo test -p module-runtime --lib` 114 passed;`cargo test -p spacetime-module` 271 passed。
## 2026-10-03 档位无可用模型改用独立错误码 MODEL_UNAVAILABLE_FOR_TIER
- 背景:`AgcModelResolveError::Unavailable`(422)与 `NoModelForTier`(503)此前共用 `MODEL_UNAVAILABLE`(见本文件 2026-10-03 的列表契约条目),只按 `error.code` 分派的客户端无法分辨「单个模型不可用」与「整个档位无可用模型(目录 / 档位配置问题)」,两者修法不同。
- 决策:`module-runtime` 新增常量 `MODEL_UNAVAILABLE` 与 `MODEL_UNAVAILABLE_FOR_TIER`,`NoModelForTier.code()` 返回后者,`Unavailable` 仍返回 `MODEL_UNAVAILABLE`;HTTP 状态(422 / 503)与端点行为不变,仅把错误码拆细,`resolve_error_codes_are_stable` 同步断言三码两两不同。
- 影响范围:`server-rs/crates/module-runtime/src/agc_model_access.rs`、`server-rs/crates/api-server/src/llm/{mod,model_access}.rs`、`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md` §11.4、本文件。
- 验证:`cargo test -p module-runtime --lib agc_model_access`;`cargo test -p api-server llm::tests`。
@@ -628,11 +628,11 @@ flowchart TD
### 11.4 实现落点(2026-10-03 编码约定)
- **`module-runtime`**:
- 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(`Unavailable` 与 `NoModelForTier` 复用 `MODEL_UNAVAILABLE` 码,分别映射 422 / 503),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。
- 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN` / `MODEL_UNAVAILABLE` / `MODEL_UNAVAILABLE_FOR_TIER`、解析错误 `AgcModelResolveError::{Unavailable, NoModelForTier, NotAvailableForPlan}`(分别对应 422 `MODEL_UNAVAILABLE`、503 `MODEL_UNAVAILABLE_FOR_TIER`、403 `MODEL_NOT_AVAILABLE_FOR_PLAN`),以及 `RuntimeProfileMembershipModelAccess → AgcModelAccess` 的映射。
- `AgcModel` 末位追加 `#[serde(default)] access: AgcModelAccess`(存量目录 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 新增 `available_models_for(access)`、`default_model_id_for(access)`、`resolve_requested_for(requested, access)`。
- **默认模型语义**:优先取目录登记的 `default_model_id`;当它不在该档可用集合里时,回退到目录顺序里第一个可用项;该档一个可用模型都没有时返回 `Unavailable`。
- **`spacetime-module`**:`external_generation_job` 追加 btree 索引 `(owner_user_id, status)`(`by_external_generation_job_owner_status`);`claim_external_generation_jobs_tx` 按账号解析 `concurrent_job_limit` 并在认领时过滤;上限解析走 `profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`,缺会员行 / 缺目录行失败关闭到 `Normal`(=1)。
- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,该档无可用模型 / 所选模型不可用分别返回 503 / 422 + `MODEL_UNAVAILABLE`。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。
- **`api-server`**:新增 `llm/model_access.rs`,调专用只读 procedure `get_profile_agc_model_access_and_return`(只读会员账期投影 + 档位目录 `model_access`,不刷新、不写库)并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`,所选模型不可用返回 422 + `MODEL_UNAVAILABLE`,该档无可用模型返回 503 + `MODEL_UNAVAILABLE_FOR_TIER`(与单个模型不可用区分,客户端只按 code 也能分辨)。`resolve_llm_router_client` 返回 `Result<_, AppError>`,会员目录 `503 + MODEL_ACCESS_UNAVAILABLE` 与上述模型解析错误都通过 typed `AppError` 透传,不再拍平成中文。
- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;`AdminAgcModel.access` 与 `AdminUpsertProfileMembershipPlanRequest.model_access` 一样用 `ProfileMembershipModelAccessToken`,未知 token 反序列化即拒;**不做**自动播种或代码内清单。
- **性能说明(2026-10-03 已落地)**:账号档位走专用只读 procedure `get_profile_agc_model_access_and_return`(`9e397fe9c`),不再触发 `get_profile_recharge_center` 的账期 / 免费点刷新;`api-server` 于 `b0dc94b46` 切到该读。认领按账号折叠并发查询见 `c3c3fc5a2`。缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`。
- **列表契约(2026-10-03 修订)**:`shared-contracts` 新增 `llm_catalog.rs` 承载 `LlmModelsResponse` / `LlmModelSummary` / `LlmUnavailableModel` / `LlmModelUnavailableReason`(`plan_required` / `disabled` / `unknown`);`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts` 并由 `module-runtime` re-export;整个目录 DTO 与枚举用 `#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))]` 导出到 `packages/shared/src/contracts/generated/`,`revision` 标 `#[ts(as = "f64")]`,AGC 前端从 `@genarrative/shared` 根导入,删掉手写的 `ClientLlmModel`。
+6 -4
View File
@@ -128,11 +128,12 @@ mod model_catalog_tests {
}
#[test]
fn tier_without_model_maps_to_service_unavailable_with_model_unavailable_code() {
// 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422。
fn tier_without_model_maps_to_service_unavailable_with_dedicated_code() {
// 与 `/api/llm/models` 列表端点同一条件、同一状态码:代理路径不能再落到 422;
// 码也不再与「单个模型不可用」共用 MODEL_UNAVAILABLE。
let error = agc_model_resolve_error(module_runtime::AgcModelResolveError::NoModelForTier);
assert_eq!(error.status_code(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(error.code(), "MODEL_UNAVAILABLE");
assert_eq!(error.code(), module_runtime::MODEL_UNAVAILABLE_FOR_TIER);
}
#[test]
@@ -280,7 +281,8 @@ fn public_model_catalog(
) -> Result<LlmModelsResponse, AppError> {
let OwnerAgcModelCatalog { catalog, access } = owner_catalog;
// 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。
// 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是 503 + MODEL_UNAVAILABLE。
// 与代理路径共用 `agc_model_resolve_error`,保证同一条件在两个端点都是
// 503 + MODEL_UNAVAILABLE_FOR_TIER(与单个模型不可用的 422 + MODEL_UNAVAILABLE 区分开)。
let default_model_id = catalog
.default_model_id_for(access)
.ok_or_else(|| agc_model_resolve_error(AgcModelResolveError::NoModelForTier))?
@@ -3,10 +3,11 @@
//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成
//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。
//!
//! LLM 路由对外错误码(三条互不重叠,客户端据此区分处理):
//! LLM 路由对外错误码(四条互不重叠,客户端据此区分处理):
//! - `MODEL_NOT_AVAILABLE_FOR_PLAN`(403):模型存在且启用,但当前会员档位不够(`module-runtime` 解析)。
//! - `MODEL_UNAVAILABLE`(422;该档在目录里没有任何可用模型时 503):模型不在目录或已停用;
//! 「整个档位没有可用模型」属于目录 / 档位配置问题,列表端点与代理端点统一按 503 上报。
//! - `MODEL_UNAVAILABLE`(422):模型不在目录或已停用。
//! - `MODEL_UNAVAILABLE_FOR_TIER`(503):该档在目录里没有任何可用模型,属目录 / 档位配置问题,
//! 列表端点与代理端点统一按 503 上报,与单个模型不可用(422)区分。
//! - `MODEL_ACCESS_UNAVAILABLE`(503):读取账号权限档本身失败(SpacetimeDB 传输 / procedure / 鉴权)。
//! 注意**缺会员行 / 缺档位目录行不算这条**:module procedure 已按 `Basic` 失败关闭并返回 `ok: true`。
//! 能走到这条说明读链路不可用,不能把付费账号静默降级成 `Basic`。
@@ -20,6 +20,15 @@ use shared_contracts::llm_catalog::LlmModelUnavailableReason;
/// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。
pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN";
/// 所选模型不在目录或已停用时对外返回的错误码。
pub const MODEL_UNAVAILABLE: &str = "MODEL_UNAVAILABLE";
/// 当前会员档位在目录里没有任何可用模型时对外返回的错误码。
///
/// 与 [`MODEL_UNAVAILABLE`] 区分:后者是「单个模型的问题」(422),本码是「目录 / 档位配置
/// 问题」(503),修法不同,客户端只按 `error.code` 也能分辨。
pub const MODEL_UNAVAILABLE_FOR_TIER: &str = "MODEL_UNAVAILABLE_FOR_TIER";
/// 单个模型要求的权限档。
///
/// 缺省 `Basic`(失败开放):存量目录 JSON 缺该字段、后台未人工标注都按基础档处理,
@@ -101,7 +110,8 @@ pub enum AgcModelResolveError {
impl AgcModelResolveError {
pub const fn code(self) -> &'static str {
match self {
Self::Unavailable | Self::NoModelForTier => "MODEL_UNAVAILABLE",
Self::Unavailable => MODEL_UNAVAILABLE,
Self::NoModelForTier => MODEL_UNAVAILABLE_FOR_TIER,
Self::NotAvailableForPlan => MODEL_NOT_AVAILABLE_FOR_PLAN,
}
}
@@ -343,16 +353,14 @@ mod tests {
AgcModelResolveError::NotAvailableForPlan.code(),
MODEL_NOT_AVAILABLE_FOR_PLAN
);
assert_eq!(
AgcModelResolveError::Unavailable.code(),
"MODEL_UNAVAILABLE"
);
// 「该档没有可用模型」沿用 MODEL_UNAVAILABLE 错误码,但调用方按变体把它映射到 503,
// 与列表端点的状态保持一致。
assert_eq!(AgcModelResolveError::Unavailable.code(), MODEL_UNAVAILABLE);
// 「该档没有可用模型」是目录 / 档位配置问题(503),与单个模型不可用(422)用不同的码,
// 让只按 error.code 分派的客户端也能区分。
assert_eq!(
AgcModelResolveError::NoModelForTier.code(),
"MODEL_UNAVAILABLE"
MODEL_UNAVAILABLE_FOR_TIER
);
assert_ne!(MODEL_UNAVAILABLE, MODEL_UNAVAILABLE_FOR_TIER);
}
#[test]