From c85da3a9839f2ee4a191a606d48b55be6b832053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 5 Oct 2026 15:11:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D(AGC=E6=A8=A1=E5=9E=8B):=20?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E6=97=A0=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=8B=AC=E7=AB=8B=E9=94=99=E8=AF=AF=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 与决策日志 --- .../shared-memory/decision-log.md | 7 ++++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 4 ++-- server-rs/crates/api-server/src/llm/mod.rs | 10 ++++---- .../crates/api-server/src/llm/model_access.rs | 7 +++--- .../module-runtime/src/agc_model_access.rs | 24 ++++++++++++------- 5 files changed, 35 insertions(+), 17 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f390729dc..c81ab03c1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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`。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 5f300ab28..ae6b19de4 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -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`。 diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index ab4c6a8db..bbff617a8 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -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 { 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))? 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 3e538ee05..a1760483c 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -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`。 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 67c0109b7..5e81a77fb 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -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]