From cbe6697d03e4ce9f4fdc71677f3012eb0256d482 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:08:38 +0800 Subject: [PATCH 01/25] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E5=AE=9A?= =?UTF-8?q?=E6=A1=88=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E4=B8=8A=E9=99=90=E7=9A=84=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ADR「模型权限与并发上限的强制边界」:并发只在认领事务按账号过滤 running job、模型档缺省 Basic、直连同步生成暂不计入 - 技术设计新增 §11,承接并作废 §9/§10 的「只落字段、不拦截」表述 - CONTEXT 修订「模型权限」「并发上限」词条,新增「独立生成任务」 - docs/README 与 decision-log 登记本 ADR 与拷问定案 --- CONTEXT.md | 12 ++++-- docs/README.md | 1 + ...€‘模型权限与并发上限的强制边界-2026-10-03.md | 26 +++++++++++++ .../shared-memory/decision-log.md | 10 +++++ ...®¾计】泥点三池与会员计费后端设计-2026-10-02.md | 39 ++++++++++++++++++- 5 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md diff --git a/CONTEXT.md b/CONTEXT.md index 1f3c268af..460a1abfc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -326,12 +326,16 @@ _Avoid_: 旧的月卡 / 季卡 / 年卡命名、把档位写成自由字符串 _Avoid_: 把价格写死在代码、按档位名字硬编码权益 **模型权限**: -会员档位允许选择的模型范围;`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 -_Avoid_: 计价分辨率档、模型定价档位 +会员档位允许用户选择的 Agent 模型范围,分「基础」与「高性能」两档;每个模型属于哪一档由模型目录的权限标注决定,`Starter` 只含基础模型,`Plus` / `Pro` / `Max` 含基础与高性能模型。 +_Avoid_: 计价分辨率档、模型定价档位、把档位写成自由字符串 **并发上限**: -同一账号可同时运行的独立生成任务数上限;`Max` 不设上限。 -_Avoid_: 旧的排队展示字段、把「不设上限」写成空值 +同一账号可同时处于执行中的服务端生成任务数上限;排队等待中的任务不计入,`Max` 不设上限。 +_Avoid_: 旧的排队展示字段、把「不设上限」写成空值、把排队中的任务计入 + +**独立生成任务**: +用户提交、由服务端排队并执行的单条生成工作单元(图片 / 视频 / 音频等),带独立生命周期与计点;并发上限只对处于执行中的该任务计数,与用户本地的 Agent 回合无关。 +_Avoid_: 本地 Agent 回合、前端提交节流、直连同步生成请求(当前不计入并发) ## Relationships diff --git a/docs/README.md b/docs/README.md index 14d0d17fa..20f59fa9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -112,6 +112,7 @@ - [会员账期按开通日自然月](./adr/【ADR】会员账期按开通日自然月-2026-10-02.md):北京时间开通日锚点、自然月推进与月末夹取、年付 12 期的期数表达与取整口径。 - [会员订单复用充值订单与补差升级幂等锚点](./adr/【ADR】会员订单复用充值订单与补差升级幂等锚点-2026-10-02.md):会员订单复用 `profile_recharge_order`、`MembershipUpgradeGrant` 账本幂等、后端只读报价与退款人工复核边界。 - [会员档位以枚举为权威](./adr/【ADR】会员档位以枚举为权威-2026-10-02.md):档位身份改为 Rust 枚举、目录表 `profile_membership_plan` 以枚举为主键、删除两处 `tier` 列与旧档位枚举、并发用哨兵值 `128` 表示不设上限。 +- [模型权限与并发上限的强制边界](./adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md):并发上限只在认领事务按账号过滤队列 job、只算 `running`;模型权限只约束 AGC LLM 模型、缺省 `Basic` 由后台人工标 `Full`;raw gpt-image-2 等直连同步生成暂不计入并发。 ## 后端、运维与测试 diff --git a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md new file mode 100644 index 000000000..c817b6756 --- /dev/null +++ b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md @@ -0,0 +1,26 @@ +# 模型权限与并发上限的强制边界 + +## Status + +accepted(2026-10-03) + +## 背景与决策 + +`profile_membership_plan` 的 `model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端不做拦截。2026-10-03 决定升级为服务端强制,并划清本期边界: + +- **并发上限**只在已有的 `claim_external_generation_jobs_tx`(唯一的 `pending → running` 事务点)按账号过滤,只统计 `status = running` 的 `external_generation_job`;不新建队列、不设排队深度上限、不新增计数表(加 `(owner_user_id, status)` 组合索引现算)。上限读后台目录 `profile_membership_plan`,缺行失败关闭到 `Normal`(=1),`128` 哨兵表示不设上限。 +- **模型权限**只约束 AGC 的 LLM 模型:`AgcModel` 末位加权限档,**缺省 `Basic`(失败开放)**,由人工在后台「AGC 模型」页手动标 `Full`;在 `GET /api/llm/models` 与代理解析处校验,越权返回 4xx `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **直连同步生成暂不计入并发**:`/api/raw/v1/images/edit`(gpt-image-2)、`editor_project` 直连图片 / 图标 / 背景、角色资源与音频等路径不产生 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径留作后续设计。 +- 不设灰度开关,直接对所有账号生效。 + +## 考虑过的替代方案 + +- 新增 SpacetimeDB「在飞租约表」统一统计队列 job 与直连同步生成:更完整,但需要租约、心跳与回收语义,本期未做。 +- 把直连同步生成统一改走 `external_generation_job`:最一致,但要重做 raw / editor 直连接口,风险最大。 +- 模型档位缺省 `Full`(失败关闭):更安全,但上线即需先标注全部基础模型,运营成本高。 + +## 后果 + +- 模型限制在人工标注 `Full` 之前不会拦截任何请求。 +- `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。 +- 无灰度开关;出问题只能回滚发版。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 3cb8cf1ef..6d0c7a9d0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9621,6 +9621,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo check -p api-server`;`npm run admin-web:typecheck`;`npx vitest run apps/admin-web` → 26 files / 248 tests passed(新增「会员订单在发放泥点列展示会员变更快照」及用户详情会员断言)。 - 边界:`/admin/api/*` 不在 `/api/external/v1` OpenAPI 门禁内,本次未补接口级契约测试,会员快照只在管理端页面用例层面取证。 + +## 2026-10-03 模型权限与并发上限的强制边界(拷问定案) + +- 背景:`model_access` 与 `concurrent_job_limit` 此前只落字段、只用于展示,服务端零拦截;用户点出 raw gpt-image-2 这类同步生成,确认「只统计 job 的 `running`」覆盖不到它,因此先把「并发单位」定义清楚再强制。 +- 决策(并发上限只约束队列 job):强制点放在唯一的 `pending → running` 事务点 `claim_external_generation_jobs_tx`,认领时按账号统计 `running` 数、达上限则跳过(任务留在 `pending`,天然排队,不新建队列)。只算 `running`(含过期待回收的 `expired_running`);失败退回 `pending` 重试释放名额;自身过期 `running` 回收豁免;上限读 `profile_membership_plan.concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 哨兵为不设上限;计数用新增 `(owner_user_id, status)` 组合索引现算,不建计数表;`/api/external/v1` 任务同样计入;不设排队深度上限。 +- 决策(模型权限只约束 AGC LLM 模型):`AgcModel` 末位加权限档、缺省 `Basic`(失败开放),由人工在后台「AGC 模型」页手动标 `Full`;`GET /api/llm/models` 按档过滤并把 `defaultModelId` 取可用集合首个,`/api/llm/responses` 经 `resolve_requested` 后校验、越权返 `MODEL_NOT_AVAILABLE_FOR_PLAN`;本地残留旧选择明确 4xx,不静默回退;不设灰度开关。 +- 边界(本轮不做):直连同步生成(raw gpt-image-2、`editor_project` 直连图片 / 图标 / 背景、角色资源、音频)没有 job 行,`running` 计数覆盖不到;统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续 TODO。 +- 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index a97893ab5..9f0718dad 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -511,6 +511,8 @@ flowchart TD | 14 | 下单接口 | 复用 `POST /api/profile/recharge/orders` | | 15 | `walletBalance` | 用户面不暴露 | +> **2026-10-03 更新**:上表第 6、7 行的「本期不实现限制」由 §11 取代——`model_access` 与 `concurrent_job_limit` 进入服务端强制。 + ## 10. 评审输入与实施清单 ### 10.1 已确认输入(本轮拍板) @@ -558,10 +560,45 @@ flowchart TD 未做(依决策保留,不是遗漏): -- 模型权限与并发上限**只落字段**,服务端不做拦截。 +- 模型权限与并发上限**只落字段**,服务端不做拦截。(2026-10-03 改:已由 §11 升级为服务端强制,本条作废。) - 不提供 `availableTotalPoints`;合计由前端派生。 - 会员购买 / 升级的 C 端 UI 未做(会员侧未上线,充值弹层只陈列泥点商品)。 - 永久泥点仍是「总额 − 每日免费 − 月度」的推导余数,独立存储见 [`【ADR】泥点三池以单一总额为权威`](../../adr/【ADR】泥点三池以单一总额为权威-2026-10-02.md) 的 TODO 与触发条件。 +## 11. 模型权限与并发上限的强制(2026-10-03 定案) + +> 承接 §9 第 6/7 行与 §10「未做」:把 `model_access` 与 `concurrent_job_limit` 从「只落字段、只用于展示」升级为**服务端强制**。 +> 范围:`module-runtime`(目录与判定纯函数)、`spacetime-module`(认领事务 + schema 索引)、`api-server`(模型代理)、`shared-contracts`、后台「AGC 模型」页。**不含 C 端 UI、不含灰度开关。** + +### 11.1 并发上限(只约束队列 job) + +- **强制点唯一**:`claim_external_generation_jobs_tx`(`spacetime-module/src/external_generation.rs`)是唯一的 `pending → running` 事务点。认领时按账号统计在飞数,未达上限才置 `running`;达上限则**跳过**该账号的候选任务——任务保持 `pending`,天然形成排队,无需新建队列。 +- **计入口径**:只算 `status = running`(含 lease 过期待回收的 `expired_running`)。失败退回 `pending` 等待重试的任务**释放**名额;`pending`(含 `available_at` 延时重试)不计入。 +- **回收豁免**:同一账号 lease 已过期的 `running` 被回收重认领时**不受自身上限限制**,否则超限账号的卡死任务永远无法回收。 +- **上限来源**:`profile_membership.plan` → `profile_membership_plan.concurrent_job_limit`(后台权威);缺会员行 / 缺目录行**失败关闭**到 `Normal`(=1);`128` 哨兵 = 不设上限(复用 `is_unlimited_concurrency`)。 +- **计数实现**:给 `external_generation_job` 追加 `(owner_user_id, status)` 组合索引,事务内现算 `running` 行数;**不**新增计数表(`running` 行即真相)。 +- **覆盖范围**:本期只约束 `external_generation_job`;`/api/external/v1` 触发、挂在账号下的任务**同样计入**。 +- **排队深度**:**不设**单账号 `pending` 深度上限。 +- **⚠️ TODO(用户保留,后续单独设计)**:**直连同步生成不计入本期并发**——`/api/raw/v1/images/edit`(gpt-image-2,见 [`【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07`](./【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md))、`editor_project` 直连图片 / 图标 / 背景、`character_visual_assets` / `character_animation_assets`、`vector_engine_audio_generation` 等路径**没有** `external_generation_job` 行,`running` 计数覆盖不到。统一「账号在飞生成」口径(在飞租约表 / 或统一改走 job)留作后续设计。当前全局护栏仍只有 worker 并发、`max_concurrent_requests` 与 raw 解码信号量。 + +### 11.2 模型权限(只约束 AGC LLM 模型) + +- **字段**:`AgcModel`(`module-runtime/src/agc_models.rs`)末位追加权限档字段(`Basic` / `Full`),**缺省 `Basic`(失败开放)**;存量目录 JSON 缺该字段时按 `Basic` 解析。目录仍是一行 JSON + `revision` 乐观锁,非表。 +- **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。 +- **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。 +- **生效点**: + - `GET /api/llm/models`:按账号档位过滤(`Basic` 档只看到非 `Full` 模型),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。 + - `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 + - `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。 + - 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。 +- **覆盖范围**:只约束 AGC 对话 / Agent 的 LLM 模型;图片 / 视频 / 音频等生成模型不纳入;`/api/external/v1` 不纳入。 + +### 11.3 共同约定 + +- **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。 +- 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。 + +--- + > 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 From 56708988edf323e22ca7d7f5979f0fa24ae58847 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:14 +0800 Subject: [PATCH 02/25] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=A1=A5=20?= =?UTF-8?q?=C2=A711.4=20=E5=AE=9E=E7=8E=B0=E8=90=BD=E7=82=B9=EF=BC=8C?= =?UTF-8?q?=E9=92=89=E6=AD=BB=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=9A=84=E7=BC=96=E7=A0=81=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 §11.4:module-runtime 的 AgcModelAccess / 解析错误、spacetime-module 的 (owner_user_id, status) 索引与认领过滤、api-server 的档位解析与 403 错误码、后台 access 字段 - 明确默认模型语义:登记默认项优先,不在该档可用集合里时回退目录顺序首个可用项 - 记录本期复用 get_profile_recharge_center 解析档位的性能 TODO --- ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 9f0718dad..46cc93b20 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -599,6 +599,17 @@ flowchart TD - **不设灰度开关**:直接对所有账号生效(2026-10-03 定案)。 - 因为模型档位缺省 `Basic`,**上线后若不人工标 `Full`,模型限制不会产生任何拦截**——这是有意的失败开放,操作责任在后台。 +### 11.4 实现落点(2026-10-03 编码约定) + +- **`module-runtime`**: + - 新增 `agc_model_access.rs`:权限档 `AgcModelAccess`(`basic` / `full`,`Default = Basic`)、越权错误码常量 `MODEL_NOT_AVAILABLE_FOR_PLAN`、解析错误 `AgcModelResolveError::{Unavailable, NotAvailableForPlan}`,以及 `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`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 +- **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 + --- > 约束来源:`scripts/check-spacetime-schema-guard.mjs` 只做源码静态比对,破坏性变更必须显式带 `SPACETIME_SCHEMA_GUARD_ALLOW_BREAKING=1` 才会放行;而 `module-runtime` 与 `spacetime-module` 相互依赖,任一侧单独清退都会让另一侧无法编译,所以领域层清退必须与 schema 变更同批提交。 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 03/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20AGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E6=96=B0=E5=A2=9E=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A1=A3=E4=B8=8E=E6=8C=89=E6=A1=A3=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E8=83=BD=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::*; From 103280025c1915ae084fcb27a5a8ed38bc8aaa85 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:20:34 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=94=9F=E6=88=90=E8=AE=A4=E9=A2=86=E6=8C=89=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - external_generation_job 追加 (owner_user_id, status) 组合索引,认领事务内现算 running 行数,不建计数表 - claim_external_generation_jobs_tx 认领前判定会员并发上限:达上限的 pending 保持排队 - 账号自身过期 running 的回收豁免上限,避免超限账号卡死任务无法回收 - profile.rs 新增 effective_profile_concurrent_job_limit:缺会员行按 Normal、缺目录行失败关闭到 1 - 抽取纯函数 external_generation_claim_within_concurrency_limit 并补单元测试 --- .../src/external_generation.rs | 86 +++++++++++++++++++ .../src/runtime/active/profile.rs | 20 +++++ 2 files changed, 106 insertions(+) diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 5e93b162c..ab09c27c3 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -47,6 +47,11 @@ const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str = accessor = by_external_generation_job_owner_user_id, btree(columns = [owner_user_id]) ), + // 会员并发上限在认领事务里按账号现算 `status = running` 行数;不建计数表,`running` 行即真相。 + index( + accessor = by_external_generation_job_owner_status, + btree(columns = [owner_user_id, status]) + ), index( accessor = by_external_generation_job_cursor, btree(columns = [job_id, source_module]) @@ -885,6 +890,11 @@ fn claim_external_generation_jobs_tx( finalize_external_generation_job_after_lease_exhaustion(ctx, row, claim_time)?; continue; } + // 会员并发上限:只约束 `pending → running` 的认领。达上限的任务留在 `pending` 天然排队; + // 账号自己那条已过期的 `running` 回收不受限,否则超限账号的卡死任务永远无法回收。 + if !external_generation_owner_has_concurrency_capacity(ctx, &row, claim_time) { + continue; + } let next_attempt = row.attempt.saturating_add(1); let lease_token = build_external_generation_lease_token( &row.job_id, @@ -917,6 +927,53 @@ fn claim_external_generation_jobs_tx( Ok(claimed) } +/// 认领前的并发上限判定。 +/// +/// 计数只算 `status = running`(含 lease 过期待回收的 `expired_running`):`pending`(含延时重试) +/// 不占名额,失败退回 `pending` 会自然释放。`128` 哨兵由 [`is_unlimited_concurrency`] 解释。 +fn external_generation_owner_has_concurrency_capacity( + ctx: &ReducerContext, + row: &ExternalGenerationJob, + now: Timestamp, +) -> bool { + let limit = crate::effective_profile_concurrent_job_limit(ctx, &row.owner_user_id); + if is_unlimited_concurrency(limit) { + return true; + } + // 回收豁免:候选本身已是该账号过期的 `running`,它已计入在飞数;放行回收不会推高在飞任务数。 + let is_recycling = row.status == EXTERNAL_GENERATION_STATUS_RUNNING + && is_external_generation_job_claimable(row, now); + external_generation_claim_within_concurrency_limit( + is_recycling, + count_running_external_generation_jobs_for_owner(ctx, &row.owner_user_id), + limit, + ) +} + +/// 并发上限判定(纯函数):`running_count >= limit` 时只放行对账号自身过期 `running` 的回收。 +fn external_generation_claim_within_concurrency_limit( + is_recycling: bool, + running_count: u32, + limit: u32, +) -> bool { + is_unlimited_concurrency(limit) || is_recycling || running_count < limit +} + +/// 账号当前在飞的 `running` 行数;事务内的认领写入会立即反映到下一次计数。 +fn count_running_external_generation_jobs_for_owner( + ctx: &ReducerContext, + owner_user_id: &str, +) -> u32 { + ctx.db + .external_generation_job() + .by_external_generation_job_owner_status() + .filter(&owner_user_id.to_string()) + .filter(|row| row.status == EXTERNAL_GENERATION_STATUS_RUNNING) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + fn finalize_external_generation_job_after_lease_exhaustion( ctx: &ReducerContext, row: ExternalGenerationJob, @@ -3850,6 +3907,35 @@ mod tests { } } + #[test] + fn concurrency_limit_blocks_pending_at_limit_but_recycles_expired_running() { + // pending 在飞数达到上限后不再认领,任务保持 pending 排队。 + assert!(external_generation_claim_within_concurrency_limit( + false, 0, 1 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 1, 1 + )); + assert!(external_generation_claim_within_concurrency_limit( + false, 2, 3 + )); + assert!(!external_generation_claim_within_concurrency_limit( + false, 3, 3 + )); + + // 回收账号自身过期的 running 不受上限限制,否则超限账号的卡死任务永远无法回收。 + assert!(external_generation_claim_within_concurrency_limit( + true, 3, 1 + )); + + // 128 哨兵表示不设上限。 + assert!(external_generation_claim_within_concurrency_limit( + false, + 10_000, + MEMBERSHIP_UNLIMITED_CONCURRENCY, + )); + } + fn micros(value: i64) -> Timestamp { Timestamp::from_micros_since_unix_epoch(value) } diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index aabd8c845..7e1a9e4db 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -9480,6 +9480,26 @@ fn membership_plan_row( ctx.db.profile_membership_plan().plan().find(&plan) } +/// 账号当前有效的并发上限:`profile_membership.plan` → 目录行 `concurrent_job_limit`。 +/// +/// 缺会员行按 `Normal`(非会员);缺目录行**失败关闭**到 `Normal`(=1), +/// `128` 哨兵由 [`is_unlimited_concurrency`] 在调用方解释。 +pub(crate) fn effective_profile_concurrent_job_limit( + ctx: &ReducerContext, + owner_user_id: &str, +) -> u32 { + let plan = ctx + .db + .profile_membership() + .user_id() + .find(&owner_user_id.to_string()) + .map(|row| row.plan) + .unwrap_or(RuntimeProfileMembershipPlan::Normal); + membership_plan_row(ctx, plan) + .map(|row| row.concurrent_job_limit) + .unwrap_or(1) +} + fn membership_plan_period_points(ctx: &ReducerContext, plan: RuntimeProfileMembershipPlan) -> u64 { membership_plan_row(ctx, plan) .map(|row| row.period_points) From 2630b0f6906cbdf7740d45ae149609a4adb3aba0 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:29:03 +0800 Subject: [PATCH 05/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20AGC=20=E6=A8=A1=E5=9E=8B=E6=94=AF=E6=8C=81=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=9D=83=E9=99=90=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminAgcModel 新增 access 字段(缺省 basic,失败开放),GET 回读 PUT 写入 - api-server 保存时校验权限档只允许 basic/full - 后台 AGC 模型页新增「权限档」列,可按模型选择基础/高性能模型 - fake API 与页面用例覆盖权限档选择与保存 --- apps/admin-web/src/api/adminApiTypes.ts | 3 ++ .../src/pages/AdminAgcModelsPage.test.tsx | 36 +++++++++++++++++++ .../src/pages/AdminAgcModelsPage.tsx | 17 +++++++++ scripts/admin-web-fake-api.mjs | 3 ++ server-rs/crates/api-server/src/agc_models.rs | 10 ++++-- .../crates/shared-contracts/src/admin.rs | 8 +++++ 6 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 715d40d64..ceb6db413 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1174,6 +1174,7 @@ export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcModelAccess = 'basic' | 'full'; export type AdminAgcModelProtocol = | 'openai_responses' | 'openai_chat' @@ -1187,6 +1188,8 @@ export interface AdminAgcModel { agentMode: AdminAgcAgentMode; /** 上游线协议,与执行器正交。 */ protocol?: AdminAgcModelProtocol; + /** 该模型要求的会员档位,缺省 `basic`(失败开放)。 */ + access?: AdminAgcModelAccess; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index feb4ce9d3..3279a5342 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -101,10 +101,46 @@ test('new models default to Codex and save the selected Agent mode', async () => modelId: '', enabled: true, agentMode: 'cc', + access: 'basic', }); }); }); +test('权限档可按模型单独选择并随目录保存', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'gpt-6-astra', + enabled: true, + agentMode: 'codex' as const, + access: 'basic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + + const access = screen.getByLabelText('模型 1 权限档') as HTMLSelectElement; + expect(access.value).toBe('basic'); + fireEvent.change(access, { target: { value: 'full' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ access: 'full' }); + }); +}); + test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { const catalog = { revision: 3, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index b009aa222..9e14400cb 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -118,6 +118,7 @@ export function AdminAgcModelsPage({ { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, { key: 'protocol', label: '协议' }, + { key: 'access', label: '权限档' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -159,6 +160,7 @@ export function AdminAgcModelsPage({ enabled: true, agentMode: 'codex', protocol: 'openai_responses', + access: 'basic', }, ], }, @@ -246,6 +248,21 @@ export function AdminAgcModelsPage({ + + + , AppError>>()?; @@ -297,6 +302,7 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog { enabled: m.enabled, agent_mode: m.agent_mode.as_str().to_string(), protocol: Some(protocol), + access: m.access.as_str().to_string(), } }) .collect(), diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 6516d4e20..a0ff50bbd 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -1704,6 +1704,11 @@ fn default_codex_agent_mode() -> String { "codex".to_string() } +/// 存量后台请求缺省为 `basic`(失败开放),只有人工标注才收紧到 `full`。 +fn default_basic_agc_model_access() -> String { + "basic".to_string() +} + #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct AdminAgcModel { @@ -1718,6 +1723,9 @@ pub struct AdminAgcModel { /// 服务端回读时始终给出解析后的具体值。 #[serde(default, skip_serializing_if = "Option::is_none")] pub protocol: Option, + /// 该模型要求的会员档位:`basic`(缺省,对全部档位开放)/ `full`(后台人工标注)。 + #[serde(default = "default_basic_agc_model_access")] + pub access: String, } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] From 3306d932bb38ad2ce2add856d521fd73c313b059 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:36:05 +0800 Subject: [PATCH 06/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20AGC=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=BB=A3=E7=90=86=E6=8C=89=E4=BC=9A=E5=91=98?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E8=BF=87=E6=BB=A4=E4=B8=8E=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 llm/model_access.rs:用 get_profile_recharge_center 解析账号档位到 AgcModelAccess,缺会员行/缺目录行/读失败统一失败关闭到 Basic - GET /api/llm/models 按档过滤并给出该档默认模型;该档无可用模型时返回 503 而不是不可解析的默认项 - /api/llm/responses、/api/llm/chat/completions、/api/llm/anthropic/* 全部改按档解析;越权返回 403 + MODEL_NOT_AVAILABLE_FOR_PLAN,目录外/停用仍是 422 - 补 api-server 单测:Basic 档过滤 Full 模型并回退默认项、越权错误码与状态映射 --- server-rs/crates/api-server/src/llm/mod.rs | 162 +++++++++++++----- .../crates/api-server/src/llm/model_access.rs | 55 ++++++ 2 files changed, 176 insertions(+), 41 deletions(-) create mode 100644 server-rs/crates/api-server/src/llm/model_access.rs diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 351ffbb52..385dd21cf 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -9,6 +9,7 @@ use axum::{ }, }; use futures_util::StreamExt; +use module_runtime::AgcModelResolveError; use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ @@ -42,6 +43,10 @@ mod anthropic_bridge; pub(crate) mod icon_specs; +mod model_access; + +use model_access::{OwnerAgcModelCatalog, resolve_owner_agc_model_access}; + #[cfg(test)] mod model_catalog_tests { use super::*; @@ -54,7 +59,14 @@ mod model_catalog_tests { ) .expect("catalog should build"); catalog.models[1].enabled = false; - let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap(); + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Full, + }) + .unwrap(), + ) + .unwrap(); // 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。 assert_eq!( payload["models"], @@ -66,6 +78,44 @@ mod model_catalog_tests { assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); } + + #[test] + fn public_catalog_filters_full_models_for_basic_plan() { + let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( + vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], + 0, + ) + .expect("catalog should build"); + // 登记的默认项是 Full;Basic 档看不到它,默认项回退到目录顺序里第一个可用项。 + catalog.models[0].access = module_runtime::AgcModelAccess::Full; + let payload = serde_json::to_value( + public_model_catalog(super::OwnerAgcModelCatalog { + catalog, + access: module_runtime::AgcModelAccess::Basic, + }) + .unwrap(), + ) + .unwrap(); + assert_eq!( + payload["models"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) + ); + assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); + } + + #[test] + fn plan_violation_maps_to_forbidden_with_dedicated_code() { + let violation = + agc_model_resolve_error(module_runtime::AgcModelResolveError::NotAvailableForPlan); + assert_eq!(violation.status_code(), StatusCode::FORBIDDEN); + assert_eq!( + violation.code(), + module_runtime::MODEL_NOT_AVAILABLE_FOR_PLAN + ); + let unavailable = + agc_model_resolve_error(module_runtime::AgcModelResolveError::Unavailable); + assert_eq!(unavailable.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + } } #[cfg(test)] @@ -195,32 +245,45 @@ pub async fn list_llm_models( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result { - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let owner_catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; - Ok(json_success_body(Some(&request_context), public_model_catalog(catalog)).into_response()) + let catalog = public_model_catalog(owner_catalog) + .map_err(|error| llm_error_response(&request_context, error))?; + Ok(json_success_body(Some(&request_context), catalog).into_response()) } -fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .filter(|model| model.enabled) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id, - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias, - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } - }) - .collect(), +fn public_model_catalog( + owner_catalog: OwnerAgcModelCatalog, +) -> Result { + let OwnerAgcModelCatalog { catalog, access } = owner_catalog; + // 该档一个可用模型都没有属于后台目录配置问题:明确报错,而不是下发一个不可解析的默认项。 + let default_model_id = catalog + .default_model_id_for(access) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_code("MODEL_UNAVAILABLE") + .with_message("当前会员档位没有可用模型") + })? + .to_string(); + let models = catalog + .available_models_for(access) + .map(|model| { + let protocol = model.resolved_protocol().as_str().to_string(); + LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode.as_str().to_string(), + protocol, + } + }) + .collect(); + Ok(LlmModelsResponse { + default_model_id, + models, revision: catalog.revision, - } + }) } /// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」, @@ -262,6 +325,27 @@ async fn load_llm_catalog( crate::agc_models::load_catalog(state).await } +/// 全局目录 + 账号权限档;所有对外 LLM 路由都经它解析模型。 +async fn load_owner_llm_catalog( + state: &AppState, + owner: &str, +) -> Result { + let catalog = load_llm_catalog(state, owner).await?; + let access = resolve_owner_agc_model_access(state, owner).await; + Ok(OwnerAgcModelCatalog { catalog, access }) +} + +/// 模型解析错误的统一映射:档位不够是 403 + 专用错误码,目录外 / 停用仍是 422。 +fn agc_model_resolve_error(error: AgcModelResolveError) -> AppError { + let status = match error { + AgcModelResolveError::NotAvailableForPlan => StatusCode::FORBIDDEN, + AgcModelResolveError::Unavailable => StatusCode::UNPROCESSABLE_ENTITY, + }; + AppError::from_status(status) + .with_code(error.code()) + .with_message(error.message()) +} + /// Proxies the OpenAI-compatible Responses protocol for the LLM Router. /// /// The caller only presents the platform access token. The Router credential @@ -334,7 +418,7 @@ pub async fn proxy_llm_responses( .get("x-genarrative-client") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value == "agc"); - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; let requested_model = if agc_client { @@ -343,13 +427,9 @@ pub async fn proxy_llm_responses( None }; let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -550,21 +630,17 @@ pub async fn proxy_llm_messages( ] { object.remove(field); } - let catalog = load_llm_catalog(&state, authenticated.claims().user_id()) + let catalog = load_owner_llm_catalog(&state, authenticated.claims().user_id()) .await .map_err(|error| llm_error_response(&request_context, error))?; // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补 // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识; - // 目录外取值仍由 `resolve_requested` 拒绝。 + // 目录外取值与档位不足都由 `resolve_requested_for` 拒绝。 let requested_model = requested_model.as_deref(); let selected_model = catalog - .resolve_requested(requested_model) - .map_err(|message| { - llm_error_response( - &request_context, - AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message), - ) - })? + .catalog + .resolve_requested_for(requested_model, catalog.access) + .map_err(|error| llm_error_response(&request_context, agc_model_resolve_error(error)))? .to_string(); // 原生直通要用目录解析后的上游模型名;桥接路径由转换器显式接收同一个名字。 object.insert("model".to_string(), Value::String(selected_model.clone())); @@ -1402,10 +1478,14 @@ async fn resolve_llm_router_client( owner_user_id: &str, ) -> Result<(platform_llm::LlmClient, String), String> { let (base_url, api_key, key_id) = resolve_llm_router_credentials(state, owner_user_id).await?; - let catalog = load_llm_catalog(state, owner_user_id) + let catalog = load_owner_llm_catalog(state, owner_user_id) .await .map_err(|_| "模型目录暂不可用".to_string())?; - let model = catalog.resolve_requested(None)?; + // Chat Completions 的 `model` 恒为账号该档的默认模型,不接收客户端选择。 + let model = catalog + .catalog + .resolve_requested_for(None, catalog.access) + .map_err(|error| error.message().to_string())?; let config = platform_llm::LlmConfig::new( platform_llm::LlmProvider::OpenAiCompatible, base_url.to_string(), diff --git a/server-rs/crates/api-server/src/llm/model_access.rs b/server-rs/crates/api-server/src/llm/model_access.rs new file mode 100644 index 000000000..65716b042 --- /dev/null +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -0,0 +1,55 @@ +//! 账号会员档位 → AGC 模型权限档的解析(BFF 侧,带 IO)。 +//! +//! 模型目录本身是全局的,权限判定在 `module-runtime`;这里只负责把「当前账号」解析成 +//! [`AgcModelAccess`],再把全局目录与该档位打包给路由层使用。 +//! +//! 失败关闭:会员行缺失、目录行缺失、或 SpacetimeDB 读失败时都按 `Normal`(`Basic`)处理, +//! 只放宽到基础模型,不放开高性能模型。 + +use crate::state::AppState; +use module_runtime::{AgcModelAccess, AgcModelCatalog}; + +/// 账号视角的 AGC 模型目录:全局目录 + 该账号的权限档。 +pub(crate) struct OwnerAgcModelCatalog { + pub(crate) catalog: AgcModelCatalog, + pub(crate) access: AgcModelAccess, +} + +/// 解析账号的 AGC 模型权限档。 +/// +/// 走现有 `get_profile_recharge_center` 读模型(它同时返回会员行与档位目录行); +/// 该读带幂等的账期刷新写入,后续可替换为更轻的专用读。 +pub(crate) async fn resolve_owner_agc_model_access( + state: &AppState, + owner_user_id: &str, +) -> AgcModelAccess { + #[cfg(test)] + if super::test_provisioned_router_credentials() + .lock() + .expect("fixture lock") + .contains_key(owner_user_id) + { + // 测试夹具目录里的模型全是缺省 `Basic`,返回 `Full` 以避免夹具再去连 SpacetimeDB。 + return AgcModelAccess::Full; + } + match state + .spacetime_client() + .get_profile_recharge_center(owner_user_id.to_string()) + .await + { + Ok(center) => center + .membership_plans + .iter() + .find(|row| row.plan == center.membership.plan) + .map(|row| AgcModelAccess::from(row.model_access)) + .unwrap_or(AgcModelAccess::Basic), + Err(error) => { + tracing::warn!( + user_id = %owner_user_id, + error = %error, + "读取会员档位失败,AGC 模型权限按 Basic 失败关闭" + ); + AgcModelAccess::Basic + } + } +} From 413b3645c21d464accc5c6965760abfb871255ea 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:39:01 +0800 Subject: [PATCH 07/25] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=A1=A5?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E4=B8=8E=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E7=9A=84=E5=AE=9E=E7=8E=B0=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E5=A5=91=E7=BA=A6=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据契约 external_generation_job 增补 2026-10-03 会员并发上限:新增 (owner_user_id, status) 索引、认领只算 running、过期回收豁免、缺行失败关闭到 Normal - 设计文档 §11.4 增补落地状态与提交号,并标注账号档位轻量读与客户端 403 重选提示为后续可选项 - decision-log 追加落地实现条目:实现拆分、Basic/Full 与 Normal 的失败语义、验证结论与边界 TODO --- docs/project-memory/shared-memory/decision-log.md | 11 +++++++++++ ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 1 + ...端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + 3 files changed, 13 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6d0c7a9d0..cafcb0a0e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9631,6 +9631,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md`(新增 §11,修订 §9/§10)、`CONTEXT.md`(并发上限 / 新增「独立生成任务」)、`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`、`docs/README.md`。未改代码。 - 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +## 2026-10-03 模型权限与并发上限落地实现 + +- 背景:承接同日「模型权限与并发上限的强制边界(拷问定案)」,把定案按 §11.4 编码约定落地;本文记实现拆分与验证,不再重复决策理由。 +- 实现(`module-runtime`,`b78003143`):新增 `agc_model_access.rs`(`AgcModelAccess::Basic/Full`、`MODEL_NOT_AVAILABLE_FOR_PLAN`、`AgcModelResolveError::{Unavailable, NotAvailableForPlan}`、`RuntimeProfileMembershipModelAccess → AgcModelAccess`);`AgcModel` 末位追加 `#[serde(default)] access`(存量 JSON 缺字段即 `Basic`,失败开放);`AgcModelCatalog` 增加 `available_models_for / default_model_id_for / resolve_requested_for`,默认模型优先取登记项、不在档内则回退目录顺序首个可用项、无可用项返回 `Unavailable`。 +- 实现(`spacetime-module`,`db8d467b8`):`external_generation_job` 追加 btree 组合索引 `(owner_user_id, status)`;`claim_external_generation_jobs_tx` 在 `pending → running` 前按 owner 现算 `running` 行数并过滤,达上限留在 `pending`;只算 `running`(含过期待回收),自身过期 `running` 回收豁免,`pending` 不占名额;`effective_profile_concurrent_job_limit` 走 `profile_membership.plan → concurrent_job_limit`,缺行失败关闭到 `Normal`(=1),`128` 为不限;抽取纯函数 `external_generation_claim_within_concurrency_limit` 并补单测。 +- 实现(后台,`919b4b01d`):`AdminAgcModel` 新增 `access`(缺省 `basic`),api-server 保存时只接受 `basic/full`,`GET` 回读 / `PUT` 写入;后台「AGC 模型」页新增「权限档」列与选择器,fake API 与页面用例覆盖。 +- 实现(`api-server`,`9b1a538d2`):新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 解析账号档位到 `AgcModelAccess`,缺会员行 / 缺目录行 / 读失败统一失败关闭到 `Basic`;`GET /api/llm/models` 按档过滤且默认项取该档可用集合;`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全改按档解析;越权映射 403 + `MODEL_NOT_AVAILABLE_FOR_PLAN`,目录外 / 停用仍为 422。 +- 术语口径:`Basic` / `Full` 是模型对会员档的要求,`Normal` 等是会员档位;缺省语义统一「失败开放到基础档、失败关闭到 1 并发」——模型档缺省 `Basic` 是失败开放(不拦),并发上限缺行失败关闭到 `Normal`(=1)。 +- 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。 +- 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 46cc93b20..182072df7 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -609,6 +609,7 @@ flowchart TD - **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 - **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `b78003143`;`spacetime-module` 并发索引与认领过滤 `db8d467b8`;后台 `access` 标注与页面 `919b4b01d`;`api-server` 按档过滤 / 校验 `9b1a538d2`。AGC 客户端侧仍以「目录按档过滤 + 本地选择对账回退默认项」为主,未额外新增针对 403 `MODEL_NOT_AVAILABLE_FOR_PLAN` 的专门提示,作为后续可选项。 --- diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f51a57277..9cd812614 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -286,6 +286,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` - 现役覆盖:worker claim 只允许 `source_module = editor-canvas`;任务领取、续租、阶段更新和结果完成必须携带并校验同一 `worker_id + lease_token`。 +- 2026-10-03 会员并发上限:新增 btree 组合索引 `by_external_generation_job_owner_status (owner_user_id, status)`。`claim_external_generation_jobs_tx` 在 `pending → running` 前按任务 owner 的 `profile_membership.plan → profile_membership_plan.concurrent_job_limit` 现算该 owner `status = running` 的行数(含租约过期待回收的 `expired_running`),达上限的 pending 保持排队;账号自身过期的 running 回收豁免上限,避免超限账号的卡死任务无法回收。`pending` 不占名额,失败退回 `pending` 会自然释放名额;不新增计数表。缺会员行按 `Normal`(上限 1)失败关闭,目录行缺失同样按 1;`128` 哨兵由 `is_unlimited_concurrency` 解释为不限。 - 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 2026-08-06 收口覆盖:上一条用途描述中“当前先 CAS、单事务仍是目标”的旧句已作废。现役编辑器生成不再组合调用 object confirm、resource create、asset create、canvas save 和 job complete。`api-server` 只准备稳定候选,再经 `spacetime-client` 调用 `persist_editor_generation_result_and_return`;procedure 在同一 `try_with_tx` 内写入可选 `asset_object`、全部 `editor_project_resource`、`editor_asset`、可选 `asset_entity_binding`、可选 canvas V2 CAS、queue job 终态和 `editor_generation_operation` receipt。结构化 canvas 的 layer / dialog / revision 与未激活 canvas 的 legacy `layers_json` 仍经既有 V2 布局验证分流,前端不直接发明正式完成态。queue 首次提交在同一快照验证 owner、job kind、request fingerprint 和有效 `job_id + worker_id + lease_token`;统一 procedure 已完成 job 后 worker 不得再单独 complete。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。未登记的 source module 不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 From 1d09d7e061e534399aec3d51589c40a89e95e81e 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 17:23:19 +0800 Subject: [PATCH 08/25] =?UTF-8?q?perf(=E4=BC=9A=E5=91=98):=20=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=94=9F=E6=88=90=E8=AE=A4=E9=A2=86=E6=8C=89=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=8A=98=E5=8F=A0=E5=B9=B6=E5=8F=91=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E4=B8=8E=E5=9C=A8=E9=A3=9E=E8=AE=A1=E6=95=B0=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExternalGenerationOwnerConcurrencyCache:单次 claim 事务内按账号只查一次 concurrent_job_limit,running 计数按账号缓存 - 认领 / lease 耗尽终结后失效该账号 running 缓存,判定结果与逐候选行现查一致 - 回收豁免与不限档不再触发 running 计数查询;补缓存失效单测 --- .../src/external_generation.rs | 86 ++++++++++++++++--- 1 file changed, 76 insertions(+), 10 deletions(-) diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index ab09c27c3..91f0758ba 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -1,5 +1,6 @@ use crate::*; use std::cmp::Ordering; +use std::collections::HashMap; use std::ops::RangeFrom; const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending"; @@ -885,14 +886,23 @@ fn claim_external_generation_jobs_tx( }); let mut claimed = Vec::new(); + let mut concurrency = ExternalGenerationOwnerConcurrencyCache::default(); for mut row in candidates.into_iter().take(limit) { if external_generation_job_has_exhausted_attempts(&row) { + let owner_user_id = row.owner_user_id.clone(); finalize_external_generation_job_after_lease_exhaustion(ctx, row, claim_time)?; + // 终结可能把 running 行改成 failed,账号计数缓存随之失效。 + concurrency.invalidate_running(&owner_user_id); continue; } // 会员并发上限:只约束 `pending → running` 的认领。达上限的任务留在 `pending` 天然排队; // 账号自己那条已过期的 `running` 回收不受限,否则超限账号的卡死任务永远无法回收。 - if !external_generation_owner_has_concurrency_capacity(ctx, &row, claim_time) { + if !external_generation_owner_has_concurrency_capacity( + ctx, + &mut concurrency, + &row, + claim_time, + ) { continue; } let next_attempt = row.attempt.saturating_add(1); @@ -921,33 +931,77 @@ fn claim_external_generation_jobs_tx( Some(worker_id.clone()), claim_time, ); + // 本次认领把一行推进到 running,账号计数缓存失效,后续候选行重新现查。 + concurrency.invalidate_running(&row.owner_user_id); claimed.push(map_external_generation_job_row(row)); } Ok(claimed) } +/// 单次 claim 事务内按账号缓存有效并发上限与 running 行数。 +/// +/// 上限在一次事务内是常量;running 计数在账号的任一行被认领或终结后立即失效、下次重查, +/// 因此判定结果与逐候选行现查一致,只是把同一账号的重复查询折叠为一次。 +#[derive(Default)] +struct ExternalGenerationOwnerConcurrencyCache { + limits: HashMap, + running: HashMap, +} + +impl ExternalGenerationOwnerConcurrencyCache { + fn limit(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(limit) = self.limits.get(owner_user_id) { + return *limit; + } + let limit = crate::effective_profile_concurrent_job_limit(ctx, owner_user_id); + self.limits.insert(owner_user_id.to_string(), limit); + limit + } + + fn running_count(&mut self, ctx: &ReducerContext, owner_user_id: &str) -> u32 { + if let Some(count) = self.running.get(owner_user_id) { + return *count; + } + let count = count_running_external_generation_jobs_for_owner(ctx, owner_user_id); + self.running.insert(owner_user_id.to_string(), count); + count + } + + /// 账号任一 `running` 行发生状态变化(认领 / 终结)后调用,保证下次读取重新现查。 + fn invalidate_running(&mut self, owner_user_id: &str) { + self.running.remove(owner_user_id); + } + + fn allows(&mut self, ctx: &ReducerContext, owner_user_id: &str, is_recycling: bool) -> bool { + let limit = self.limit(ctx, owner_user_id); + if is_unlimited_concurrency(limit) { + return true; + } + // 回收豁免不需要计数:自身过期 running 已计入在飞数,放行不会推高它。 + let running = if is_recycling { + 0 + } else { + self.running_count(ctx, owner_user_id) + }; + external_generation_claim_within_concurrency_limit(is_recycling, running, limit) + } +} + /// 认领前的并发上限判定。 /// /// 计数只算 `status = running`(含 lease 过期待回收的 `expired_running`):`pending`(含延时重试) /// 不占名额,失败退回 `pending` 会自然释放。`128` 哨兵由 [`is_unlimited_concurrency`] 解释。 fn external_generation_owner_has_concurrency_capacity( ctx: &ReducerContext, + cache: &mut ExternalGenerationOwnerConcurrencyCache, row: &ExternalGenerationJob, now: Timestamp, ) -> bool { - let limit = crate::effective_profile_concurrent_job_limit(ctx, &row.owner_user_id); - if is_unlimited_concurrency(limit) { - return true; - } // 回收豁免:候选本身已是该账号过期的 `running`,它已计入在飞数;放行回收不会推高在飞任务数。 let is_recycling = row.status == EXTERNAL_GENERATION_STATUS_RUNNING && is_external_generation_job_claimable(row, now); - external_generation_claim_within_concurrency_limit( - is_recycling, - count_running_external_generation_jobs_for_owner(ctx, &row.owner_user_id), - limit, - ) + cache.allows(ctx, &row.owner_user_id, is_recycling) } /// 并发上限判定(纯函数):`running_count >= limit` 时只放行对账号自身过期 `running` 的回收。 @@ -3936,6 +3990,18 @@ mod tests { )); } + #[test] + fn owner_concurrency_cache_only_invalidates_running_count() { + let mut cache = ExternalGenerationOwnerConcurrencyCache::default(); + cache.running.insert("owner-a".to_string(), 2); + cache.limits.insert("owner-a".to_string(), 3); + // 认领 / 终结后 running 计数必须重查,否则会把上一行的变更带到下一候选行。 + cache.invalidate_running("owner-a"); + assert!(cache.running.get("owner-a").is_none()); + // 上限在一次事务内是常量,不受 running 计数失效影响。 + assert_eq!(cache.limits.get("owner-a"), Some(&3)); + } + fn micros(value: i64) -> Timestamp { Timestamp::from_micros_since_unix_epoch(value) } From e91abf9875e6d2e881012db77dca6ce1ccc3d921 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 17:46:49 +0800 Subject: [PATCH 09/25] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E5=AE=9A?= =?UTF-8?q?=E6=A1=88=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=8F=AF=E7=94=A8?= =?UTF-8?q?/=E4=B8=8D=E5=8F=AF=E7=94=A8=E5=88=86=E6=A1=B6=E5=A5=91?= =?UTF-8?q?=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADR 增补修订节:GET /api/llm/models 返回 models + unavailableModels,reason 枚举 plan_required/disabled/unknown,停用优先,不放松服务端强制 - 技术设计 §11.2 改述列表契约为分桶,§11.4 补 llm_catalog.rs 落点、域层分桶方法、AGC 客户端 hover 文案与 ts-rs 单一真源,并刷新落地提交号 - AGC 后台模型别名技术方案补 2026-10-03 修订说明 - AGC 模型目录上游同步里程碑标注「形状不变」已被后续变更取代 - decision-log 追加分桶决策、兼容与客户端口径 --- .../【ADR】模型权限与并发上限的强制边界-2026-10-03.md | 11 +++++++++++ .../plans/【里程碑】AGC模型目录上游同步-2026-09-24.md | 2 ++ docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ ...€�技术方案】AGC后台模型别名与对话选择-2026-09-05.md | 1 + ...�技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 7 +++++-- 5 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md index c817b6756..658f92635 100644 --- a/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md +++ b/docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md @@ -24,3 +24,14 @@ accepted(2026-10-03) - 模型限制在人工标注 `Full` 之前不会拦截任何请求。 - `raw` gpt-image-2 与其它直连同步生成不受 `concurrent_job_limit` 约束(TODO)。 - 无灰度开关;出问题只能回滚发版。 + +## 修订:列表改为可用 / 不可用分桶(2026-10-03) + +原实现让 `GET /api/llm/models` 只返回本档可用模型(`Basic` 看不到 `Full`),降级用户在客户端只会看到「所选模型已停用」的笼统提示,且产品无法在列表里露出可升级的高档模型。修订为: + +- 返回两个平行数组:`models` 只放本档可用(`enabled` 且档位允许);`unavailableModels` 放目录里其余全部,元素额外带 `reason` 枚举。 +- `reason` 取值 `plan_required`(档位不够)/ `disabled`(后台停用)/ `unknown`(前向兜底);同时停用且档位不够时取 `disabled`(升级也解锁不了,不能标 `plan_required`)。 +- 上游真实模型名(`modelId`)两桶都不下发;`defaultModelId` 必须落在 `models` 内。 +- 分桶只是展示层的信息补充,**不放松服务端强制**:`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 仍按档 4xx。 +- `unavailableModels` 是加法字段:旧客户端忽略后行为不变;`reason` 在 wire 侧带 `unknown` 兜底,后端新增取值不会让旧客户端解析失败。 +- 目录 DTO 与枚举改为 ts-rs 单一真源(whole DTO 导出),`agentMode` / `protocol` 由 `String` 收敛为 Rust 枚举。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md index 3c14f4a5e..327ac50b1 100644 --- a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -17,6 +17,8 @@ 2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。 3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。 +> 2026-10-03 修订:本条「`GET /api/llm/models` 形状不变」只约束本里程碑自身;后续「模型权限」变更已把该接口改为可用 / 不可用分桶(新增 `unavailableModels` 与 `reason` 枚举),见 `docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md` 的修订节。 + ## 不在本里程碑内 - 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cafcb0a0e..ab10fcd52 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9642,6 +9642,16 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 验证:`cargo check -p api-server`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 49 passed、`model_catalog_tests` 3 passed(含 Basic 档过滤 Full 模型、403 错误码映射);`npm run check:spacetime-schema` 的失败项全部是分支既有 `profile_membership` / `profile_recharge_product_config` 字段变更(对照 `origin/master` merge-base `25beb3ad5`),与本次仅新增 btree 索引无关,guard 不比较索引;`npm run check:encoding`、`git diff --check`。 - 边界 / TODO:账号档位仍复用带周期刷新写入的 `get_profile_recharge_center`,后续替换为轻量专用读;AGC 客户端仍靠「目录按档过滤 + 本地选择对账回退默认项」,未加专门的 403 重选提示;统一「账号在飞生成」口径(含无 job 行的同步生成)仍为后续 TODO。 +## 2026-10-03 模型列表改为可用 / 不可用分桶(修订) + +- 背景:`GET /api/llm/models` 原按档过滤,降级用户在客户端只看到「所选模型已停用」的笼统提示,且无法在列表里露出可升级的高档模型。 +- 决策(契约):返回 `models`(本档可用)与 `unavailableModels`(目录里其余全部,带 `reason` 枚举)两个平行数组,**不新增** `available` 布尔;`reason` 取 `plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`;`unavailableModels` 保持目录顺序、恒定下发(无内容为空数组);上游真实模型名两桶都不下发;`defaultModelId` 必须落在 `models` 内;某档一个可用模型都没有时保留 503 `MODEL_UNAVAILABLE`。 +- 决策(兼容):`unavailableModels` 是加法字段,旧客户端忽略后行为不变,无需能力协商;`reason` 在 wire 侧带 `unknown` 兜底,后端将来新增取值不会让旧客户端解析失败;服务端按档强制不变。 +- 决策(客户端):AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,仅 hover / 键盘 focus 出 tooltip(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」;不再新增「当前已选不可用」的 API 字段,前端自行从 `unavailableModels` 推断。 +- 决策(ts-rs 单一真源):整个目录 DTO 与枚举导出到 `packages/shared/src/contracts/generated/`;新增 barrel `packages/shared/src/llm/modelCatalog.ts` 并由 `packages/shared/src/index.ts` 转发;`AgcAgentMode` / `AgcModelProtocol` 从 `module-runtime` 迁入 `shared-contracts`(`module-runtime` re-export);`revision: u64` 标 `#[ts(as = "f64")]`;AGC 前端删除手写 `ClientLlmModel`,改从 `@genarrative/shared` 根导入。 +- 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。 +- 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index d14ae6087..eb2e3cc57 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -42,6 +42,7 @@ - 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。 - `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。 - `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。 +- 2026-10-03 修订(模型权限):`GET /api/llm/models` 改为两个平行数组——`models` 只放本档可用;`unavailableModels` 放目录里其余(含后台停用与本档档位不够),元素带 `reason` 枚举(`plan_required` / `disabled` / `unknown`,同时停用且档位不够取 `disabled`)。`unavailableModels` 是加法字段,旧客户端忽略后行为不变,且不放松服务端按档强制;`defaultModelId` 仍必须落在 `models` 内。客户端把 `unavailableModels` 渲染为不可点项并 hover / focus 出原因(`plan_required`→「订阅计划不支持」、`disabled`→「该模型已下线」、`unknown`→「暂不可用」);已选模型落进不可用桶时自动回退默认并给出对应原因文案,后台直接删除(两桶都没有)用泛化文案。 - 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。 - 手动刷新立即显示进行中状态;真实刷新成功后显示完成反馈,即使 `revision` 未变化也有反馈。失败沿用有效缓存时仍显示失败,不能报告刷新成功;HTTP 状态和超时使用可辨认的提示。 - 模型目录与其它客户端 JSON API 的成功、失败响应体读取均复用 `readClientHttpResponseText` 的 15 秒上限;响应头已返回但响应体卡住时必须结束本次等待、释放目录在途请求并允许重试,迟到的响应不得覆盖新目录。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index 182072df7..d6826571b 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -588,7 +588,7 @@ flowchart TD - **标注**:由**人工在后台「AGC 模型」页手动**把高性能模型标为 `Full`;代码里**不**写死清单、不播种。 - **档位解析**:`load_llm_catalog(owner)` 已带 owner;由 `profile_membership.plan` → 目录 `model_access` 解析;缺档位失败关闭到 `Normal`(`Basic`)。 - **生效点**: - - `GET /api/llm/models`:按账号档位过滤(`Basic` 档只看到非 `Full` 模型),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。 + - `GET /api/llm/models`:返回 `models`(本档可用)与 `unavailableModels`(目录里其余,元素带 `reason` 枚举 `plan_required` / `disabled` / `unknown`;同时停用且档位不够取 `disabled`),`defaultModelId` 取该档可用集合的默认项(见 §11.4)。分桶只补充展示信息,不放松下述强校验。 - `POST /api/llm/responses`:`catalog.resolve_requested()` 映射后校验档位,越权返回 4xx 专用错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - `POST /api/llm/chat/completions`(`model` 恒为默认)与 anthropic bridge:只需保证解析出的默认模型落在该档可用集合内。 - 客户端本地残留旧选择(`select_game_creator_model` 持久化)→ **明确 4xx**,由客户端提示重选;**不静默回退**。 @@ -609,7 +609,10 @@ flowchart TD - **`api-server`**:新增 `llm/model_access.rs`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 - **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 -- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `b78003143`;`spacetime-module` 并发索引与认领过滤 `db8d467b8`;后台 `access` 标注与页面 `919b4b01d`;`api-server` 按档过滤 / 校验 `9b1a538d2`。AGC 客户端侧仍以「目录按档过滤 + 本地选择对账回退默认项」为主,未额外新增针对 403 `MODEL_NOT_AVAILABLE_FOR_PLAN` 的专门提示,作为后续可选项。 +- **列表契约(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`。 +- **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 +- **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。 --- From cf40df6be3d3842d05800da8f5649c476829011d 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 17:49:59 +0800 Subject: [PATCH 10/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=8F=AA=E8=AF=BB=20AGC=20=E6=A8=A1=E5=9E=8B=E6=9D=83?= =?UTF-8?q?=E9=99=90=20procedure=20=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - module-runtime 新增 RuntimeProfileAgcModelAccessSnapshot / ProcedureResult 领域类型 - spacetime-module 新增 get_profile_agc_model_access_and_return:只读会员账期投影 + 档位目录 model_access,缺目录行失败关闭 Basic - 重新生成绑定并补 spacetime-client get_profile_agc_model_access 与结果映射 --- server-rs/crates/module-runtime/src/domain.rs | 20 +++++++ .../src/active/mapper/runtime_profile.rs | 16 +++++ .../spacetime-client/src/active/runtime.rs | 28 +++++++++ .../spacetime-client/src/module_bindings.rs | 6 ++ ...e_agc_model_access_and_return_procedure.rs | 59 +++++++++++++++++++ ..._agc_model_access_procedure_result_type.rs | 19 ++++++ ..._profile_agc_model_access_snapshot_type.rs | 20 +++++++ .../src/runtime/active/profile.rs | 49 +++++++++++++++ 8 files changed, 217 insertions(+) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 94860d76b..6477930ac 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1731,6 +1731,26 @@ pub struct RuntimeProfileMembershipSnapshotProcedureResult { pub error_message: Option, } +/// 账号当前有效的 AGC 模型权限档。 +/// +/// 与写库刷新的充值中心快照不同,它只读会员账期投影与档位目录,不触发换期 / 免费点刷新,供 LLM 代理热路径使用。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +/// 只读 AGC 模型权限 procedure 的结果:成功时 `record` 为解析后的权限档,失败时给出 `error_message`。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderSnapshot { diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs index 73a65cdc2..fda444938 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime_profile.rs @@ -779,6 +779,22 @@ pub(crate) fn map_runtime_profile_membership_snapshot_procedure_result( )) } +pub(crate) fn map_runtime_profile_agc_model_access_procedure_result( + result: RuntimeProfileAgcModelAccessProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile AGC model access 快照"))?; + Ok(module_runtime::RuntimeProfileAgcModelAccessSnapshot { + user_id: record.user_id, + plan: map_runtime_profile_membership_plan_back(record.plan), + model_access: map_runtime_profile_membership_model_access_back(record.model_access), + }) +} + pub(crate) fn map_runtime_profile_recharge_order_procedure_result( result: RuntimeProfileRechargeCenterProcedureResult, ) -> Result< diff --git a/server-rs/crates/spacetime-client/src/active/runtime.rs b/server-rs/crates/spacetime-client/src/active/runtime.rs index 99e3f4bf6..dd1d32724 100644 --- a/server-rs/crates/spacetime-client/src/active/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/runtime.rs @@ -422,6 +422,34 @@ impl SpacetimeClient { .await } + /// 只读 AGC 模型权限:不刷新账期 / 免费点,供 LLM 代理热路径使用。 + pub async fn get_profile_agc_model_access( + &self, + user_id: String, + ) -> Result { + let procedure_input = build_runtime_profile_membership_get_input(user_id) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_agc_model_access_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_agc_model_access_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_agc_model_access_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_profile_recharge_order( &self, user_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index d7ef52226..55a56de2b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -488,6 +488,7 @@ pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; +pub mod get_profile_agc_model_access_and_return_procedure; pub mod get_payment_order_and_return_procedure; pub mod get_payment_order_by_checkout_token_and_return_procedure; pub mod get_profile_dashboard_procedure; @@ -691,6 +692,8 @@ pub mod runtime_profile_admin_wallet_detail_procedure_result_type; pub mod runtime_profile_admin_wallet_get_input_type; pub mod runtime_profile_admin_wallet_procedure_result_type; pub mod runtime_profile_admin_wallet_snapshot_type; +pub mod runtime_profile_agc_model_access_procedure_result_type; +pub mod runtime_profile_agc_model_access_snapshot_type; pub mod runtime_profile_code_operation_snapshot_type; pub mod runtime_profile_daily_free_points_snapshot_type; pub mod runtime_profile_dashboard_get_input_type; @@ -1366,6 +1369,7 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; +pub use get_profile_agc_model_access_and_return_procedure::get_profile_agc_model_access_and_return; pub use get_payment_order_and_return_procedure::get_payment_order_and_return; pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; @@ -1569,6 +1573,8 @@ pub use runtime_profile_admin_wallet_detail_procedure_result_type::RuntimeProfil pub use runtime_profile_admin_wallet_get_input_type::RuntimeProfileAdminWalletGetInput; pub use runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; pub use runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +pub use runtime_profile_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +pub use runtime_profile_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; pub use runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; pub use runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs new file mode 100644 index 000000000..eca693a53 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_agc_model_access_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_agc_model_access_procedure_result_type::RuntimeProfileAgcModelAccessProcedureResult; +use super::runtime_profile_membership_get_input_type::RuntimeProfileMembershipGetInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileAgcModelAccessAndReturnArgs { + pub input: RuntimeProfileMembershipGetInput, +} + +impl __sdk::InModule for GetProfileAgcModelAccessAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_agc_model_access_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_agc_model_access_and_return { + fn get_profile_agc_model_access_and_return(&self, input: RuntimeProfileMembershipGetInput) { + self.get_profile_agc_model_access_and_return_then(input, |_, _| {}); + } + + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_agc_model_access_and_return for super::RemoteProcedures { + fn get_profile_agc_model_access_and_return_then( + &self, + input: RuntimeProfileMembershipGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileAgcModelAccessProcedureResult>( + "get_profile_agc_model_access_and_return", + GetProfileAgcModelAccessAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs new file mode 100644 index 000000000..f95d34ce8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_agc_model_access_snapshot_type::RuntimeProfileAgcModelAccessSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs new file mode 100644 index 000000000..ecbf88e77 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_agc_model_access_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_membership_model_access_type::RuntimeProfileMembershipModelAccess; +use super::runtime_profile_membership_plan_type::RuntimeProfileMembershipPlan; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAgcModelAccessSnapshot { + pub user_id: String, + pub plan: RuntimeProfileMembershipPlan, + pub model_access: RuntimeProfileMembershipModelAccess, +} + +impl __sdk::InModule for RuntimeProfileAgcModelAccessSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 7e1a9e4db..94fa85e50 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -1727,6 +1727,55 @@ pub fn get_profile_membership_snapshot_and_return( } } +/// 账号当前有效的 AGC 模型权限:只读账期投影 + 档位目录 `model_access`,不刷新、不写库。 +/// +/// 供 api-server 的 LLM 代理热路径使用;缺会员行 / 已过期按 `Normal`,缺目录行失败关闭到 `Basic`。 +#[spacetimedb::procedure] +pub fn get_profile_agc_model_access_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileMembershipGetInput, +) -> RuntimeProfileAgcModelAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_membership_get_input(input.user_id.clone()) + .map_err(|error| error.to_string())?; + Ok(build_profile_agc_model_access_snapshot_readonly( + tx, + &validated.user_id, + )) + }) { + Ok(record) => RuntimeProfileAgcModelAccessProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileAgcModelAccessProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +/// 纯读解析:用只读账期投影拿当前档位,再取档位目录的 `model_access`;缺目录行失败关闭到 `Basic`。 +fn build_profile_agc_model_access_snapshot_readonly( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileAgcModelAccessSnapshot { + let membership = build_profile_membership_snapshot_readonly(ctx, user_id); + let model_access = membership_plan_row(ctx, membership.plan) + .map(|row| row.model_access) + .unwrap_or(RuntimeProfileMembershipModelAccess::Basic); + RuntimeProfileAgcModelAccessSnapshot { + user_id: membership.user_id, + plan: membership.plan, + model_access, + } +} + #[spacetimedb::procedure] pub fn get_profile_recharge_order_and_return( ctx: &mut ProcedureContext, From 243ad3c0e4b0e03cc92a2d0eb1da286e27a3e6ed 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 17:50:48 +0800 Subject: [PATCH 11/25] =?UTF-8?q?perf(=E4=BC=9A=E5=91=98):=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=9D=83=E9=99=90=E8=A7=A3=E6=9E=90=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20procedure=EF=BC=8C=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E7=83=AD=E8=B7=AF=E5=BE=84=E5=86=99=E5=BA=93=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server llm/model_access.rs 改调 get_profile_agc_model_access,不再走带账期刷新的 get_profile_recharge_center - 失败关闭语义不变:procedure 失败或档位目录缺行仍按 Basic --- server-rs/crates/api-server/src/llm/model_access.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 65716b042..a6c094f03 100644 --- a/server-rs/crates/api-server/src/llm/model_access.rs +++ b/server-rs/crates/api-server/src/llm/model_access.rs @@ -17,8 +17,8 @@ pub(crate) struct OwnerAgcModelCatalog { /// 解析账号的 AGC 模型权限档。 /// -/// 走现有 `get_profile_recharge_center` 读模型(它同时返回会员行与档位目录行); -/// 该读带幂等的账期刷新写入,后续可替换为更轻的专用读。 +/// 走专用只读 procedure `get_profile_agc_model_access`:它只读会员账期投影与档位目录, +/// 不触发换期 / 免费点刷新,适合 LLM 代理热路径。 pub(crate) async fn resolve_owner_agc_model_access( state: &AppState, owner_user_id: &str, @@ -34,15 +34,10 @@ pub(crate) async fn resolve_owner_agc_model_access( } match state .spacetime_client() - .get_profile_recharge_center(owner_user_id.to_string()) + .get_profile_agc_model_access(owner_user_id.to_string()) .await { - Ok(center) => center - .membership_plans - .iter() - .find(|row| row.plan == center.membership.plan) - .map(|row| AgcModelAccess::from(row.model_access)) - .unwrap_or(AgcModelAccess::Basic), + Ok(record) => AgcModelAccess::from(record.model_access), Err(error) => { tracing::warn!( user_id = %owner_user_id, From 35a54d19e21b22905ceb5861f2f40596144bf03b 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 17:52:10 +0800 Subject: [PATCH 12/25] =?UTF-8?q?docs(=E4=BC=9A=E5=91=98):=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=20procedure=20=E7=9A=84=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 技术设计 §11.4:api-server 改调 get_profile_agc_model_access_and_return,性能说明去掉 TODO 并登记提交 - decision-log 追加「模型权限改走只读 procedure(性能收口)」条目 --- docs/project-memory/shared-memory/decision-log.md | 7 +++++++ .../【技术设计】泥点三池与会员计费后端设计-2026-10-02.md | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ab10fcd52..385c81cd4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9652,6 +9652,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响范围:`docs/adr/【ADR】模型权限与并发上限的强制边界-2026-10-03.md`(修订节)、技术设计 §11.2/§11.4、`docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`、`docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md`、本文件。 - 验证:`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 +## 2026-10-03 模型权限改走只读 procedure(性能收口) + +- 背景:`api-server` 的 AGC 模型权限解析原复用 `get_profile_recharge_center`,它带幂等的账期 / 免费点刷新与钱包 / 商品等无关快照字段,位于每个 `/api/llm/*` 请求热路径;同日「落地实现」条目已把它列为 TODO。 +- 决策:新增只读 procedure `get_profile_agc_model_access_and_return`(输入沿用 `RuntimeProfileMembershipGetInput`,结果 `RuntimeProfileAgcModelAccessSnapshot { user_id, plan, model_access }`,仅 editor generation runtime service identity 可调):只读会员账期投影 + `profile_membership_plan.model_access`,缺会员行 / 已过期按 `Normal`,缺档位目录行失败关闭到 `Basic`,不刷新、不写库;`api-server` 改调它,失败关闭语义不变。 +- 影响范围:`module-runtime`(领域类型 `9e397fe9c`)、`spacetime-module`(procedure,同提交)、生成绑定、`spacetime-client`(`get_profile_agc_model_access`)、`api-server/src/llm/model_access.rs`(`b0dc94b46`)、技术设计 §11.4。 +- 验证:`cargo check -p spacetime-module`、`cargo check -p spacetime-client`、`cargo check -p api-server --tests`;`cargo test -p api-server llm` 51 passed;`npm run check:encoding`、`git diff --check`;`npm run check:spacetime-schema` 失败项仍为分支既有 `profile_membership` / `profile_recharge_product_config` 表字段变更,与本次仅新增 procedure、未改表无关。 + ## 2026-10-03 会员链路错误分类改为机器可读错误码(typed error) - 背景:`api-server/src/runtime_profile.rs` 的 `is_runtime_profile_membership_domain_error` 用中文 `error_message` 前缀 / 精确匹配决定返回 400 还是 502;文案一改(或新增拒绝点)就静默退化,且分类逻辑与 module 的错误文案跨层重复。 diff --git a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md index d6826571b..aae4bbf1a 100644 --- a/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md +++ b/docs/technical/【技术设计】泥点三池与会员计费后端设计-2026-10-02.md @@ -606,13 +606,13 @@ flowchart TD - `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`,用现有 `get_profile_recharge_center` 读出账号 `membership.plan` 与目录行 `model_access` 并映射到 `AgcModelAccess`;`GET /api/llm/models` 按档过滤,`/api/llm/responses`、`/api/llm/chat/completions`、`/api/llm/anthropic/*` 全部按档解析,越权返回 403 + 错误码 `MODEL_NOT_AVAILABLE_FOR_PLAN`。 +- **`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`。 - **后台**:`AdminAgcModel` 与后台「AGC 模型」页新增 `access` 字段(`basic` / `full`,缺省 `basic`),GET 回读、PUT 写入;**不做**自动播种或代码内清单。 -- **性能说明**:本期账号档位复用现有 `get_profile_recharge_center` 读模型,它带幂等的周期刷新写入;后续可替换为更轻的专用读(TODO)。 +- **性能说明(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`。 - **分桶来源**:`module-runtime` 新增 `AgcModelCatalog::unavailable_models_for(access)`(保持目录顺序,返回 `(&AgcModel, LlmModelUnavailableReason)`),`api-server` 只做 DTO 映射;`models` 仍走 `available_models_for`;`unavailableModels` 恒定下发(无内容时为空数组)。 - **客户端**:AGC 桌面选择器把 `unavailableModels` 渲染为不可点项,hover / 键盘 focus 出 tooltip(`plan_required` → 「订阅计划不支持」、`disabled` → 「该模型已下线」、`unknown` → 「暂不可用」);降级仍自动回退默认,提示按 `reason` 取;后台直接删除(两桶都没有)用泛化文案「所选模型已不可用,已切回默认模型」。 -- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。 +- **落地状态(2026-10-03)**:`module-runtime` 权限档与目录解析 `da9602839`;`spacetime-module` 并发索引与认领过滤 `df7682816`;后台 `access` 标注与页面 `54b02e228`;`api-server` 按档过滤 / 校验 `3aae77bc5`;认领按账号折叠并发查询 `c3c3fc5a2`。AGC 客户端侧原以「目录按档过滤 + 本地选择对账回退默认项」为主,本次修订为可用 / 不可用分桶。账号 AGC 模型权限改走只读 procedure `9e397fe9c`,`api-server` 切读 `b0dc94b46`。 --- From e89420053ac16aed927cab21bb97e6aea3a29fa6 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 18:03:20 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(=E4=BC=9A=E5=91=98):=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E4=B8=8E=E4=B8=8D=E5=8F=AF=E7=94=A8=E5=88=86=E6=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts 新增 llm_catalog.rs:LlmModelsResponse/LlmModelSummary/LlmUnavailableModel/LlmModelUnavailableReason(plan_required/disabled/unknown),未停用优先于档位的 disabled 语义留给域层 - AgcAgentMode/AgcModelProtocol 从 module-runtime 迁入 shared-contracts(前后端 + ts-rs 单一真源),module-runtime re-export 保持路径可用 - 整个目录 DTO 与枚举走 ts-rs,生成到 packages/shared/src/contracts/generated,revision 标 number - module-runtime 新增 AgcModelCatalog::unavailable_models_for(access):保持目录顺序,停用优先于档位不足,补单测 - api-server public_model_catalog 产出 models + unavailableModels 两桶,重写 model_catalog_tests --- .../src/contracts/generated/AgcAgentMode.ts | 6 + .../contracts/generated/AgcModelProtocol.ts | 10 + .../contracts/generated/LlmModelSummary.ts | 16 ++ .../generated/LlmModelUnavailableReason.ts | 8 + .../contracts/generated/LlmModelsResponse.ts | 20 ++ .../generated/LlmUnavailableModel.ts | 9 + server-rs/crates/api-server/src/llm/mod.rs | 43 +++- .../module-runtime/src/agc_model_access.rs | 57 +++++ .../crates/module-runtime/src/agc_models.rs | 96 +------- server-rs/crates/shared-contracts/src/lib.rs | 1 + server-rs/crates/shared-contracts/src/llm.rs | 25 +- .../shared-contracts/src/llm_catalog.rs | 226 ++++++++++++++++++ 12 files changed, 393 insertions(+), 124 deletions(-) create mode 100644 packages/shared/src/contracts/generated/AgcAgentMode.ts create mode 100644 packages/shared/src/contracts/generated/AgcModelProtocol.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelSummary.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts create mode 100644 packages/shared/src/contracts/generated/LlmModelsResponse.ts create mode 100644 packages/shared/src/contracts/generated/LlmUnavailableModel.ts create mode 100644 server-rs/crates/shared-contracts/src/llm_catalog.rs diff --git a/packages/shared/src/contracts/generated/AgcAgentMode.ts b/packages/shared/src/contracts/generated/AgcAgentMode.ts new file mode 100644 index 000000000..a23cf0075 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcAgentMode.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 客户端启动的执行器。与线协议正交。 + */ +export type AgcAgentMode = "codex" | "cc"; diff --git a/packages/shared/src/contracts/generated/AgcModelProtocol.ts b/packages/shared/src/contracts/generated/AgcModelProtocol.ts new file mode 100644 index 000000000..c6fa1e618 --- /dev/null +++ b/packages/shared/src/contracts/generated/AgcModelProtocol.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 上游线协议,与执行器正交。 + * + * `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 + * `/api/llm/v1/chat/completions`;`anthropic` 走独立的 + * `/api/llm/anthropic/{*path}`。 + */ +export type AgcModelProtocol = "openai_responses" | "openai_chat" | "anthropic"; diff --git a/packages/shared/src/contracts/generated/LlmModelSummary.ts b/packages/shared/src/contracts/generated/LlmModelSummary.ts new file mode 100644 index 000000000..be2e3c30c --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelSummary.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; + +/** + * 本档可用的一条模型摘要。不包含上游真实模型名。 + */ +export type LlmModelSummary = { displayName: string, id: string, +/** + * 执行器;旧服务端缺字段时按 `codex` 兼容。 + */ +agentMode: AgcAgentMode, +/** + * 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + */ +protocol: AgcModelProtocol, }; diff --git a/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts new file mode 100644 index 000000000..a9a55039a --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelUnavailableReason.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 模型对当前账号不可用的原因。 + * + * `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 + */ +export type LlmModelUnavailableReason = "plan_required" | "disabled" | "unknown"; diff --git a/packages/shared/src/contracts/generated/LlmModelsResponse.ts b/packages/shared/src/contracts/generated/LlmModelsResponse.ts new file mode 100644 index 000000000..ce1a75fef --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmModelsResponse.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LlmModelSummary } from "./LlmModelSummary"; +import type { LlmUnavailableModel } from "./LlmUnavailableModel"; + +/** + * `GET /api/llm/models` 的响应。 + */ +export type LlmModelsResponse = { defaultModelId: string, +/** + * 本档可用,保持目录顺序。 + */ +models: Array, +/** + * 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + */ +unavailableModels: Array, +/** + * 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + */ +revision: number, }; diff --git a/packages/shared/src/contracts/generated/LlmUnavailableModel.ts b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts new file mode 100644 index 000000000..f41401bbc --- /dev/null +++ b/packages/shared/src/contracts/generated/LlmUnavailableModel.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgcAgentMode } from "./AgcAgentMode"; +import type { AgcModelProtocol } from "./AgcModelProtocol"; +import type { LlmModelUnavailableReason } from "./LlmModelUnavailableReason"; + +/** + * 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 + */ +export type LlmUnavailableModel = { displayName: string, id: string, agentMode: AgcAgentMode, protocol: AgcModelProtocol, reason: LlmModelUnavailableReason, }; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 385dd21cf..f25a64ab5 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -14,7 +14,7 @@ use platform_llm::{LlmApiKind, LlmMessage, LlmMessageRole, LlmRunRequest}; use serde_json::{Value, json}; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, - LlmModelSummary, LlmModelsResponse, + LlmModelSummary, LlmModelsResponse, LlmUnavailableModel, }; use spacetime_client::SpacetimeClientError; use std::convert::Infallible; @@ -52,7 +52,7 @@ mod model_catalog_tests { use super::*; #[test] - fn public_catalog_exposes_stable_id_and_upstream_alias() { + fn public_catalog_splits_available_and_disabled_models() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 7, @@ -72,15 +72,21 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses"}]) ); + // 后台停用的模型仍下发到不可用桶,带 disabled 原因。 + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses", "reason": "disabled"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-sol"); assert_eq!(payload["revision"], json!(7)); assert!(payload.get("defaultModel").is_none()); assert!(payload["models"][0].get("enabled").is_none()); assert!(payload["models"][0].get("modelId").is_none()); + assert!(payload["unavailableModels"][0].get("modelId").is_none()); } #[test] - fn public_catalog_filters_full_models_for_basic_plan() { + fn public_catalog_puts_full_models_in_plan_required_bucket_for_basic_plan() { let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models( vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()], 0, @@ -100,6 +106,10 @@ mod model_catalog_tests { payload["models"], json!([{"id": "gpt-5-6-terra", "displayName": "gpt-5.6-terra", "agentMode": "codex", "protocol": "openai_responses"}]) ); + assert_eq!( + payload["unavailableModels"], + json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol", "agentMode": "codex", "protocol": "openai_responses", "reason": "plan_required"}]) + ); assert_eq!(payload["defaultModelId"], "gpt-5-6-terra"); } @@ -268,20 +278,29 @@ fn public_model_catalog( .to_string(); let models = catalog .available_models_for(access) - .map(|model| { - let protocol = model.resolved_protocol().as_str().to_string(); - LlmModelSummary { - id: model.id.clone(), - // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 - display_name: model.alias.clone(), - agent_mode: model.agent_mode.as_str().to_string(), - protocol, - } + .map(|model| LlmModelSummary { + id: model.id.clone(), + // 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。 + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + }) + .collect(); + // 不可用桶只是展示信息:含后台停用与本档档位不够,保持目录顺序。 + let unavailable_models = catalog + .unavailable_models_for(access) + .map(|(model, reason)| LlmUnavailableModel { + id: model.id.clone(), + display_name: model.alias.clone(), + agent_mode: model.agent_mode, + protocol: model.resolved_protocol(), + reason, }) .collect(); Ok(LlmModelsResponse { default_model_id, models, + unavailable_models, revision: catalog.revision, }) } 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 76559aaed..051475330 100644 --- a/server-rs/crates/module-runtime/src/agc_model_access.rs +++ b/server-rs/crates/module-runtime/src/agc_model_access.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::{ AGC_MODEL_PLATFORM_DEFAULT, AgcModel, AgcModelCatalog, RuntimeProfileMembershipModelAccess, }; +use shared_contracts::llm_catalog::LlmModelUnavailableReason; /// 越权选择模型时对外返回的专用错误码(`api-server` 直接透传到 `AppError.code`)。 pub const MODEL_NOT_AVAILABLE_FOR_PLAN: &str = "MODEL_NOT_AVAILABLE_FOR_PLAN"; @@ -105,6 +106,26 @@ impl AgcModelCatalog { .filter(move |model| model.is_available_for(access)) } + /// 该档位**不可用**的模型(含后台停用与本档档位不够),保持目录顺序。 + /// + /// 与 [`Self::available_models_for`] 互补:两桶并集是目录全集、互不相交。同时停用且 + /// 档位不够时取 [`LlmModelUnavailableReason::Disabled`]——升级也解锁不了,不能标 + /// `plan_required` 误导用户。 + pub fn unavailable_models_for( + &self, + access: AgcModelAccess, + ) -> impl Iterator { + self.models.iter().filter_map(move |model| { + if !model.enabled { + Some((model, LlmModelUnavailableReason::Disabled)) + } else if !access.allows(model.access) { + Some((model, LlmModelUnavailableReason::PlanRequired)) + } else { + None + } + }) + } + /// 该档位的默认模型标识:优先目录登记的 `default_model_id`,不在该档可用集合里时 /// 回退到目录顺序里第一个可用项;一个可用模型都没有时返回 `None`。 pub fn default_model_id_for(&self, access: AgcModelAccess) -> Option<&str> { @@ -287,4 +308,40 @@ mod tests { "MODEL_UNAVAILABLE" ); } + + #[test] + fn unavailable_models_for_splits_disabled_and_plan_gated_in_catalog_order() { + let mut catalog = catalog_from_ids(&["model-a", "model-b", "model-c"], 0); + mark_full(&mut catalog, "model-b"); + mark_full(&mut catalog, "model-c"); + catalog.models[2].enabled = false; + + let unavailable = catalog + .unavailable_models_for(AgcModelAccess::Basic) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + // model-c 同时停用且 Full:reason 取 disabled(升级也解锁不了)。 + assert_eq!( + unavailable, + vec![ + ("model-b", LlmModelUnavailableReason::PlanRequired), + ("model-c", LlmModelUnavailableReason::Disabled), + ] + ); + + // Full 档只剩停用的 model-c;可用集合与不可用集合互补。 + let unavailable_full = catalog + .unavailable_models_for(AgcModelAccess::Full) + .map(|(model, reason)| (model.id.as_str(), reason)) + .collect::>(); + assert_eq!( + unavailable_full, + vec![("model-c", LlmModelUnavailableReason::Disabled)] + ); + let available = catalog + .available_models_for(AgcModelAccess::Basic) + .map(|model| model.id.as_str()) + .collect::>(); + assert_eq!(available, vec!["model-a"]); + } } diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index 05e115db8..31479f3d8 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -14,99 +14,9 @@ pub const AGC_MODEL_ID_MAX_BYTES: usize = 64; /// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。 pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgcAgentMode { - Codex, - /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; - /// 协议本身由 `AgcModel.protocol` 表达。 - #[serde(alias = "anthropic")] - Cc, -} - -impl Default for AgcAgentMode { - fn default() -> Self { - Self::Codex - } -} - -impl AgcAgentMode { - pub const fn as_str(self) -> &'static str { - match self { - Self::Codex => "codex", - Self::Cc => "cc", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "codex" => Ok(Self::Codex), - "cc" => Ok(Self::Cc), - _ => Err("Agent 模式只允许 codex 或 cc".into()), - } - } - - /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: - /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 - /// Anthropic Messages。 - pub const fn supports(self, protocol: AgcModelProtocol) -> bool { - match self { - Self::Codex => matches!( - protocol, - AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat - ), - Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), - } - } - - /// 存量目录缺少协议字段时按执行器的默认线协议解释。 - pub const fn default_protocol(self) -> AgcModelProtocol { - match self { - Self::Codex => AgcModelProtocol::OpenAiResponses, - Self::Cc => AgcModelProtocol::Anthropic, - } - } -} - -/// 上游线协议,与执行器正交。 -/// -/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 -/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 -/// `/api/llm/anthropic/{*path}`。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum AgcModelProtocol { - #[serde(rename = "openai_responses")] - OpenAiResponses, - #[serde(rename = "openai_chat")] - OpenAiChat, - #[serde(rename = "anthropic")] - Anthropic, -} - -impl Default for AgcModelProtocol { - fn default() -> Self { - Self::OpenAiResponses - } -} - -impl AgcModelProtocol { - pub const fn as_str(self) -> &'static str { - match self { - Self::OpenAiResponses => "openai_responses", - Self::OpenAiChat => "openai_chat", - Self::Anthropic => "anthropic", - } - } - - pub fn parse(value: &str) -> Result { - match value { - "openai_responses" => Ok(Self::OpenAiResponses), - "openai_chat" => Ok(Self::OpenAiChat), - "anthropic" => Ok(Self::Anthropic), - _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), - } - } -} +// 执行器与线协议枚举已迁到 `shared-contracts`(前后端 + ts-rs 单一真源); +// 这里 re-export,保持 `module_runtime::AgcAgentMode` / `AgcModelProtocol` 路径可用。 +pub use shared_contracts::llm_catalog::{AgcAgentMode, AgcModelProtocol}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index ed92100fb..08ce1bec6 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -23,5 +23,6 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod llm_catalog; pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/llm.rs b/server-rs/crates/shared-contracts/src/llm.rs index a02708cf7..a729f0994 100644 --- a/server-rs/crates/shared-contracts/src/llm.rs +++ b/server-rs/crates/shared-contracts/src/llm.rs @@ -60,22 +60,9 @@ mod tests { assert_eq!(payload["messages"][0]["role"], json!("system")); } } -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelSummary { - pub display_name: String, - pub id: String, - /// 执行器:`codex` 或 `cc`。 - pub agent_mode: String, - /// 上游线协议,与执行器正交:`openai_responses` / `openai_chat` / `anthropic`。 - pub protocol: String, -} - -#[derive(Debug, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelsResponse { - pub default_model_id: String, - pub models: Vec, - /// 模型目录版本,客户端据此做条件刷新。 - pub revision: u64, -} +// 目录 DTO 与枚举已迁到 `llm_catalog`(同一 ts-rs 生成目录);这里 re-export 保持既有 +// `shared_contracts::llm::*` 路径可用。 +pub use crate::llm_catalog::{ + AgcAgentMode, AgcModelProtocol, LlmModelSummary, LlmModelUnavailableReason, LlmModelsResponse, + LlmUnavailableModel, +}; diff --git a/server-rs/crates/shared-contracts/src/llm_catalog.rs b/server-rs/crates/shared-contracts/src/llm_catalog.rs new file mode 100644 index 000000000..72a79bfce --- /dev/null +++ b/server-rs/crates/shared-contracts/src/llm_catalog.rs @@ -0,0 +1,226 @@ +//! AGC LLM 模型目录的对外契约与枚举(前后端唯一真源)。 +//! +//! `GET /api/llm/models` 返回两个平行数组:`models` 只放本档可用模型; +//! `unavailableModels` 放目录里其余全部(含后台停用与本档档位不够),元素带 `reason`。 +//! 整个 DTO 与枚举经 ts-rs 导出到 `packages/shared/src/contracts/generated/`, +//! AGC 前端不再手写第二份类型。 + +use serde::{Deserialize, Serialize}; + +/// 客户端启动的执行器。与线协议正交。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcAgentMode { + Codex, + /// `anthropic` 是早期把协议混进执行器时写下的历史取值,读取时归一到 `cc`; + /// 协议本身由 [`AgcModelProtocol`] 表达。 + #[serde(alias = "anthropic")] + Cc, +} + +impl Default for AgcAgentMode { + fn default() -> Self { + Self::Codex + } +} + +impl AgcAgentMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Cc => "cc", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "codex" => Ok(Self::Codex), + "cc" => Ok(Self::Cc), + _ => Err("Agent 模式只允许 codex 或 cc".into()), + } + } + + /// 该执行器允许的线协议。协议与执行器是正交维度,但合法组合是封闭的: + /// Codex 只能走 OpenAI Responses / Chat Completions,Claude Code 只能走 + /// Anthropic Messages。 + pub const fn supports(self, protocol: AgcModelProtocol) -> bool { + match self { + Self::Codex => matches!( + protocol, + AgcModelProtocol::OpenAiResponses | AgcModelProtocol::OpenAiChat + ), + Self::Cc => matches!(protocol, AgcModelProtocol::Anthropic), + } + } + + /// 存量目录缺少协议字段时按执行器的默认线协议解释。 + pub const fn default_protocol(self) -> AgcModelProtocol { + match self { + Self::Codex => AgcModelProtocol::OpenAiResponses, + Self::Cc => AgcModelProtocol::Anthropic, + } + } +} + +/// 上游线协议,与执行器正交。 +/// +/// `openai_responses` / `openai_chat` 走 `/api/llm/v1/responses` 与 +/// `/api/llm/v1/chat/completions`;`anthropic` 走独立的 +/// `/api/llm/anthropic/{*path}`。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum AgcModelProtocol { + #[serde(rename = "openai_responses")] + OpenAiResponses, + #[serde(rename = "openai_chat")] + OpenAiChat, + #[serde(rename = "anthropic")] + Anthropic, +} + +impl Default for AgcModelProtocol { + fn default() -> Self { + Self::OpenAiResponses + } +} + +impl AgcModelProtocol { + pub const fn as_str(self) -> &'static str { + match self { + Self::OpenAiResponses => "openai_responses", + Self::OpenAiChat => "openai_chat", + Self::Anthropic => "anthropic", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "openai_responses" => Ok(Self::OpenAiResponses), + "openai_chat" => Ok(Self::OpenAiChat), + "anthropic" => Ok(Self::Anthropic), + _ => Err("协议只允许 openai_responses、openai_chat 或 anthropic".into()), + } + } +} + +/// 本档可用的一条模型摘要。不包含上游真实模型名。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelSummary { + pub display_name: String, + pub id: String, + /// 执行器;旧服务端缺字段时按 `codex` 兼容。 + #[serde(default)] + pub agent_mode: AgcAgentMode, + /// 上游线协议;旧服务端缺字段时按 `openai_responses` 兼容。 + #[serde(default)] + pub protocol: AgcModelProtocol, +} + +/// 目录里存在、但当前账号不可用的一条模型。字段与 [`LlmModelSummary`] 对齐,另带原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmUnavailableModel { + pub display_name: String, + pub id: String, + #[serde(default)] + pub agent_mode: AgcAgentMode, + #[serde(default)] + pub protocol: AgcModelProtocol, + pub reason: LlmModelUnavailableReason, +} + +/// 模型对当前账号不可用的原因。 +/// +/// `Unknown` 是前向兜底:后端将来新增取值时旧客户端不会解析失败。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub enum LlmModelUnavailableReason { + /// 会员档位不够(升级可解锁)。 + PlanRequired, + /// 后台人工停用(升级也解锁不了)。 + Disabled, + /// 未知原因的前向兜底;当前服务端不会主动产出。 + #[serde(other)] + Unknown, +} + +/// `GET /api/llm/models` 的响应。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS))] +#[cfg_attr( + feature = "ts-bindings", + ts( + export, + export_to = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../packages/shared/src/contracts/generated/" + ) + ) +)] +pub struct LlmModelsResponse { + pub default_model_id: String, + /// 本档可用,保持目录顺序。 + pub models: Vec, + /// 目录里其余全部,保持目录顺序;旧服务端缺字段时为空。 + #[serde(default)] + pub unavailable_models: Vec, + /// 模型目录版本;ts-rs 默认把 `u64` 映射成 `bigint`,前端消费 `number`。 + #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] + pub revision: u64, +} From f3f89efddd0fc05d21918554414509228c5b9179 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 18:03:47 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(shared):=20=E8=BD=AC=E5=8F=91=20AGC?= =?UTF-8?q?=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=20ts-rs=20=E7=BB=91?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 packages/shared/src/llm/modelCatalog.ts,re-export Rust 生成的目录 DTO 与枚举 - 根 index.ts 增加 export type * from './llm/modelCatalog',AGC 从 @genarrative/shared 根导入 --- packages/shared/src/index.ts | 1 + packages/shared/src/llm/modelCatalog.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 packages/shared/src/llm/modelCatalog.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56504ea57..ecf7a17c8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,6 +10,7 @@ export type * from './contracts/hyper3d'; export * from './contracts/payment'; export * from './contracts/runtime'; export * from './http'; +export type * from './llm/modelCatalog'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; export * from './stores/createProfileWalletStore'; diff --git a/packages/shared/src/llm/modelCatalog.ts b/packages/shared/src/llm/modelCatalog.ts new file mode 100644 index 000000000..a483dd19a --- /dev/null +++ b/packages/shared/src/llm/modelCatalog.ts @@ -0,0 +1,12 @@ +/** + * AGC LLM 模型目录契约(Rust `shared-contracts::llm_catalog` 的 ts-rs 生成物)。 + * + * 前端不再手写第二份 `ClientLlmModel`:可用 / 不可用分桶、`reason` 枚举与 + * `agentMode` / `protocol` 联合类型都以这里为准。 + */ +export type { AgcAgentMode } from '../contracts/generated/AgcAgentMode'; +export type { AgcModelProtocol } from '../contracts/generated/AgcModelProtocol'; +export type { LlmModelsResponse } from '../contracts/generated/LlmModelsResponse'; +export type { LlmModelSummary } from '../contracts/generated/LlmModelSummary'; +export type { LlmModelUnavailableReason } from '../contracts/generated/LlmModelUnavailableReason'; +export type { LlmUnavailableModel } from '../contracts/generated/LlmUnavailableModel'; From 9437b71aee07c843752312ce9a5d1646164cf633 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 18:03:58 +0800 Subject: [PATCH 15/25] =?UTF-8?q?feat(AGC):=20=E6=A8=A1=E5=9E=8B=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=20wire=20=E5=A4=8D=E7=94=A8=20shared-contracts=20DTO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llm_catalog.rs 删掉手写 WireModelCatalog/WireModelSummary 与 map_catalog,直接反序列化 LlmModelsResponse - 旧服务端缺 agentMode/protocol/unavailableModels 时按 Codex + OpenAI Responses + 空桶兼容 - 新增未知 reason 收口为 unknown 且 agentMode/protocol 解析为枚举的单测 --- .../src-tauri/src/llm_catalog.rs | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 7e89cb64f..620701eb0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -6,40 +6,12 @@ use futures::StreamExt; use serde::Deserialize; use serde_json::Value; use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER; -use shared_contracts::llm::{LlmModelSummary, LlmModelsResponse}; +use shared_contracts::llm::LlmModelsResponse; use std::time::Duration; const API_RESPONSE_ENVELOPE_VERSION: &str = "v1"; const MODEL_CATALOG_MAX_BYTES: usize = 1024 * 1024; -fn default_codex_agent_mode() -> String { - "codex".to_string() -} - -fn default_model_protocol() -> String { - "openai_responses".to_string() -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelSummary { - id: String, - display_name: String, - #[serde(default = "default_codex_agent_mode")] - agent_mode: String, - /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 - #[serde(default = "default_model_protocol")] - protocol: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WireModelCatalog { - default_model_id: String, - models: Vec, - revision: u64, -} - #[derive(Debug, Deserialize)] struct ApiErrorPayload { code: Option, @@ -49,27 +21,12 @@ struct ApiErrorPayload { #[derive(Debug, Deserialize)] struct ApiEnvelope { ok: bool, - data: Option, + /// 直接复用 shared-contracts 的目录 DTO:`agentMode` / `protocol` / `unavailableModels` + /// 都带 `#[serde(default)]`,旧服务端缺字段时按 Codex + OpenAI Responses + 空桶兼容。 + data: Option, error: Option, } -fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { - LlmModelsResponse { - default_model_id: catalog.default_model_id, - models: catalog - .models - .into_iter() - .map(|model| LlmModelSummary { - id: model.id, - display_name: model.display_name, - agent_mode: model.agent_mode, - protocol: model.protocol, - }) - .collect(), - revision: catalog.revision, - } -} - fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; @@ -98,12 +55,10 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { } return envelope .data - .map(map_catalog) .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); } - serde_json::from_value::(value) - .map(map_catalog) + serde_json::from_value::(value) .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) } @@ -293,6 +248,48 @@ mod tests { .contains("x-genarrative-response-envelope: v1")); } + #[test] + fn catalog_maps_unknown_unavailable_reason_to_unknown() { + let body = serde_json::json!({ + "ok": true, + "data": { + "defaultModelId": "quality", + "models": [{ + "id": "quality", + "displayName": "高质量", + "agentMode": "codex", + "protocol": "openai_responses" + }], + "unavailableModels": [{ + "id": "pro", + "displayName": "专业版", + "agentMode": "cc", + "protocol": "anthropic", + "reason": "some_future_reason" + }], + "revision": 8 + }, + "error": null + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("catalog should parse"); + assert_eq!(catalog.models.len(), 1); + assert_eq!(catalog.unavailable_models.len(), 1); + assert_eq!( + catalog.unavailable_models[0].reason, + shared_contracts::llm::LlmModelUnavailableReason::Unknown + ); + assert_eq!( + catalog.unavailable_models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Cc + ); + assert_eq!( + catalog.unavailable_models[0].protocol, + shared_contracts::llm::AgcModelProtocol::Anthropic + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); From 93fc9f98bac429dcf83af88aa3ed9979a2086ddb 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 18:04:11 +0800 Subject: [PATCH 16/25] =?UTF-8?q?feat(AGC):=20=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E5=B1=95=E7=A4=BA=E4=B8=8D=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E4=B8=8E=E9=99=8D=E7=BA=A7=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clientApi/llmModelCatalog 改用 @genarrative/shared 生成类型,自定义目录补 unavailableModels 空桶与 protocol - 新增 modelAvailabilityCopy.ts:锁定项原因文案与按 reason 的降级提示 - ConversationModelSelect 渲染锁定项(不可点、可 focus、data-tooltip),降级提示按 reason 取值 - styles.css 补锁定项与 tooltip 样式(仅 hover / focus-visible) - 新增 vitest 覆盖文案映射与锁定项渲染,vitest.config 纳入 features/project-workspace 用例 --- .../ConversationModelSelect.test.tsx | 123 ++++++++++++++++++ .../ConversationModelSelect.tsx | 43 +++++- .../modelAvailabilityCopy.test.ts | 29 +++++ .../modelAvailabilityCopy.ts | 38 ++++++ .../src/services/clientApi.ts | 53 +++----- .../src/services/llmModelCatalog.ts | 11 +- apps/ai-game-creator-shell/src/styles.css | 46 +++++++ vitest.config.ts | 3 + 8 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.test.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/modelAvailabilityCopy.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx new file mode 100644 index 000000000..ab1accc17 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -0,0 +1,123 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + catalog: { + defaultModelId: 'basic-model', + models: [ + { + id: 'basic-model', + displayName: '基础模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + }, + ], + unavailableModels: [ + { + id: 'pro-model', + displayName: '专业模型', + agentMode: 'codex' as const, + protocol: 'openai_responses' as const, + reason: 'plan_required' as const, + }, + { + id: 'retired-model', + displayName: '下线模型', + agentMode: 'cc' as const, + protocol: 'anthropic' as const, + reason: 'disabled' as const, + }, + ], + revision: 1, + }, +})); + +vi.mock('../../app/tauri', () => ({ + resolveTauriInvoke: () => mocks.invoke, +})); + +vi.mock('../../services/llmModelCatalog', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + cachedLlmModelCatalog: () => mocks.catalog, + refreshLlmModelCatalog: () => Promise.resolve(mocks.catalog), + }; +}); + +import { ConversationModelSelect } from './ConversationModelSelect'; + +function mockConfig(selectedModelId: string, isDefault: boolean) { + mocks.invoke.mockImplementation(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + config: { + selectedModelId, + selectedModelIsDefault: isDefault, + }, + }; + } + if (command === 'select_game_creator_model') { + return { + config: { + selectedModelId: 'basic-model', + selectedModelIsDefault: true, + }, + }; + } + return {}; + }); +} + +afterEach(() => { + cleanup(); + mocks.invoke.mockReset(); +}); + +describe('ConversationModelSelect 锁定模型', () => { + it('不可用模型渲染为不可点、可聚焦的原因项', async () => { + mockConfig('basic-model', true); + render(); + // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith('read_game_creator_app_config'), + ); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + + const planLocked = screen.getByText('专业模型').closest('[role="option"]'); + expect(planLocked?.tagName).toBe('DIV'); + expect(planLocked?.getAttribute('aria-disabled')).toBe('true'); + expect(planLocked?.getAttribute('tabindex')).toBe('0'); + expect(planLocked?.getAttribute('data-reason')).toBe('plan_required'); + expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持'); + + const disabledLocked = screen + .getByText('下线模型') + .closest('[role="option"]'); + expect(disabledLocked?.getAttribute('data-tooltip')).toBe('该模型已下线'); + }); + + it('已选模型落进不可用桶时按 reason 回退默认并提示', async () => { + mockConfig('retired-model', false); + render(); + + await waitFor(() => + expect(screen.getByRole('status').textContent).toBe( + '所选模型已下线,已切换为默认模型', + ), + ); + expect(mocks.invoke).toHaveBeenCalledWith( + 'select_game_creator_model', + expect.objectContaining({ modelId: 'basic-model', isDefault: true }), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 2bc9df75c..b763d77b6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; +import { Check, ChevronDown, Lock, RefreshCcw } from 'lucide-react'; import type { Ref } from 'react'; import { useCallback, @@ -14,10 +14,15 @@ import { cachedLlmModelCatalog, type ClientLlmModel, type ClientLlmModelCatalog, + type ClientLlmUnavailableModel, LLM_CONFIG_CHANGED_EVENT, LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +import { + unavailableModelFallbackNotice, + unavailableModelReasonLabel, +} from './modelAvailabilityCopy'; /** * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 @@ -76,6 +81,9 @@ export function ConversationModelSelect({ const [models, setModels] = useState( initialCatalog?.models ?? [], ); + const [unavailableModels, setUnavailableModels] = useState< + ClientLlmUnavailableModel[] + >(initialCatalog?.unavailableModels ?? []); const [selected, setSelected] = useState(''); const [defaultModelId, setDefaultModelId] = useState( initialCatalog?.defaultModelId ?? '', @@ -143,6 +151,7 @@ export function ConversationModelSelect({ ) { appliedRevisionRef.current = catalog.revision; setModels(catalog.models); + setUnavailableModels(catalog.unavailableModels); setDefaultModelId(catalog.defaultModelId); } const invoke = resolveTauriInvoke(); @@ -178,7 +187,13 @@ export function ConversationModelSelect({ if (!next && defaultEnabled) { next = catalog.defaultModelId; nextIsDefault = true; - if (saved) nextNotice = '所选模型已停用,已切换为默认模型'; + if (saved) { + // 命中不可用桶时按 reason 给具体原因;两桶都没有(后台直接删除)走泛化文案。 + nextNotice = unavailableModelFallbackNotice( + catalog.unavailableModels.find((model) => model.id === saved) + ?.reason, + ); + } } if (next && (next !== saved || nextIsDefault !== followsDefault)) { let persisted: GameCreatorAppConfigView; @@ -253,6 +268,7 @@ export function ConversationModelSelect({ appliedRevisionRef.current = null; selectedRef.current = ''; setModels([]); + setUnavailableModels([]); setSelected(''); setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); @@ -315,6 +331,7 @@ export function ConversationModelSelect({ selectedRef.current = ''; setSelected(''); setModels([]); + setUnavailableModels([]); void syncCatalog(true); } window.addEventListener('focus', handleWindowFocus); @@ -455,6 +472,28 @@ export function ConversationModelSelect({ ) : null} ))} + {/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */} + {unavailableModels.map((model) => { + const reasonLabel = unavailableModelReasonLabel(model.reason); + return ( +
+ + {model.displayName} + +
+ ); + })}