AGC 模型目录改为启动期从上游同步,退役写死的 gpt-6-astra/luna 与别名(#506) #507

Merged
suzmii merged 6 commits from enhance/default-model into master 2026-09-24 19:15:22 +08:00
19 changed files with 901 additions and 100 deletions
+1
View File
@@ -65,6 +65,7 @@ temp*build*/
/apps/preview-deployer-web/node_modules/
/server-rs/.spacetimedb/
/server-rs/.data/
**/server-rs/.data/
/public/generated-animations
/public/generated-character-drafts
/public/generated-characters
@@ -6,7 +6,7 @@
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
"model": "platform-default",
"apiKind": "openai_responses",
"reasoningEffort": "max",
"stream": true,
@@ -1497,6 +1497,15 @@ if (defaultAppConfig.llm?.apiKey !== '') {
throw new Error('AI game creator shell default llm.apiKey must stay empty');
}
// 首次启动模板必须写入官方路由占位模型(与 config.rs 的
// OFFICIAL_LLM_ROUTER_DEFAULT_MODEL 同源):钉死具体上游模型名会随上游目录
// 变动失效,留空则首启配置不合法。
if (defaultAppConfig.llm?.model !== 'platform-default') {
throw new Error(
'AI game creator shell default llm.model must stay the official route placeholder',
);
}
if (defaultAppConfig.agentMode !== 'codex_app_server') {
throw new Error(
'AI game creator shell default agentMode must be codex_app_server',
@@ -1457,7 +1457,9 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra";
// 默认模型不再写死具体上游模型名:正式构建锁定官方路由,
// 未选择平台目录模型时该占位标识表示“跟随平台默认”(与 config.rs 同源)。
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = OFFICIAL_LLM_ROUTER_DEFAULT_MODEL;
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
@@ -554,6 +554,11 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
template.llm.as_ref().and_then(|llm| llm.max_retries),
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
);
assert_eq!(
template.llm.as_ref().and_then(|llm| llm.model.as_deref()),
Some(DEFAULT_GAME_CREATOR_LLM_MODEL),
"首次启动模板必须写入官方路由占位模型,不能钉死具体上游模型名"
);
assert!(
template.agent_llm.unwrap_or_default().is_empty(),
"bundled template must not persist canonical defaults as explicit overrides"
+6 -7
View File
@@ -10975,14 +10975,13 @@ button.design-workspace-tree__entry:hover,
bottom: calc(100% + 8px);
z-index: 20;
display: grid;
/* 目录项就是上游原始模型名,长度不可控:菜单按最宽条目自动拓宽(锚在触发钮右缘,
向左侧生长),不再用固定 150–190px 把名字截掉;只有极端长名字才受视口宽度限制。 */
width: max-content;
min-width: 150px;
max-width: 190px;
/* 条目多时菜单不能无限长:240px 与视口 40vh 取小者,超出部分在菜单内滚动
(窄屏 / 移动端优先下 40vh 更稳)。滚动不外溢给背后的消息列表,与
`.resource-reference-menu` 同一口径。 */
max-height: min(240px, 40vh);
overflow: auto;
overscroll-behavior: contain;
max-width: calc(100vw - 24px);
/* 目录规模由后台维护(当前是上游在售的个位数模型),菜单按内容高度展开,
不再设 max-height,因此不会出现滚动条。 */
padding: 5px;
border: 1px solid var(--platform-surface-border, #e5e7eb);
border-radius: 10px;
@@ -297,7 +297,7 @@ test('only displays aliases and persists selection through the native command',
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
await screen.findByRole('button', { name: '对话模型' });
expect(screen.queryByText('gpt-6-astra')).toBeNull();
expect(screen.queryByText('quality')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
@@ -0,0 +1,36 @@
# AGC 模型目录上游同步实施计划
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress |
| Date | 2026-09-24 |
| Parent Milestone | `docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md` |
## 修改边界与顺序
1. **领域模型(`module-runtime/src/agc_models.rs`)**:删除写死的 `Default` 实现(原 `quality → gpt-6-astra`、`fast → gpt-5.6-luna`),新增 `from_upstream_models`:按上游模型名排序去重后生成目录项(`modelId`/`alias` = 上游原名,`id` = 模型名 slug,`enabled = true`),默认项取排序后第一项;新增 `resolve_requested`(未选或 `platform-default` 用默认项)。字段、校验规则(32 项上限、id/alias/model_id 约束)与 `resolve` 保持原样。
2. **procedure(`spacetime-module/src/agc_models.rs`)**:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 不变。无表结构变化,不改 `migration.rs`。
3. **api-server 目录模块(`src/agc_models.rs`)**:新增启动期 `ensure_agc_model_catalog_initialized`(读 → 解析/校验 → 缺行或非法则 `GET {控制面}/api/pricing?group=taonier` → 生成目录 → 按存量 revision 写回;冲突后重读确认可用);上游请求 10s 超时、1 MiB 流式上限、禁止重定向、不带凭据;未初始化统一 `503` 文案;后台 PUT 增加未初始化门禁。
4. **api-server 接线(`src/main.rs`、`src/external_api_keys.rs`)**:`try_restore_app_state_for_startup` 按 HTTP 角色调用初始化,失败只 `error!` 记录;抽出 `ensure_llm_router_url_allowed`(只校验地址/scheme,避免被已下线的固定模型哨兵挡住),`LLM_ROUTER_TOKEN_GROUP` / `router_control_origin` 供同步复用。
5. **客户端与后台**:不改。`GET /api/llm/models` 形状、admin DTO、后台「AGC 模型」页、客户端 `select_game_creator_model` 校验全部保持原样。
## 不改的部分
目录字段语义、后台 DTO 与页面、公开 DTO 形状、客户端模型标识校验、`/api/external/v1` 与 OpenAPI、SpacetimeDB 表结构、Router provisioning/额度。
## 验证命令
- `cargo test --locked -p module-runtime --lib agc_models::`
- `cargo test --locked -p api-server --bin api-server agc`、`... llm::`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::`
- `cargo fmt --all -- --check`(两套 workspace)、`npx vitest run apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx`、admin-web 页面定向 Vitest 与 typecheck
- `npm run check:encoding`、`npm run check:doc-index`、`npm run check:spacetime-schema`、`git diff --check`
- 运行时 smoke:本地 dev 栈清空 `agc_model_catalog` 后启动 api-server,确认日志 `已按上游模型列表初始化 AGC 模型目录`、库中 `catalog_json` 为「slug id + 上游原名 alias/modelId」、`GET /api/llm/models` 返回原名;再把上游地址指向不可达端口验证 `503` 与「无替代目录」。
## 风险与回滚点
- **上游端点与鉴权**:分组定价列表端点为实测确认的公开只读接口;若上游改版,同步失败只会让目录保持未初始化(接口 503 + 启动 error),不会写入错误模型。
- **混合版本**:module 的缺行语义变化要求 module 与 api-server 同批发布/回滚;未升级的 api-server 会把自己的 AGC 接口打到 `503`(后台 DTO 未变,admin-web 可独立发布)。回滚点必须同时覆盖 module 与 api-server。
- **存量目录**:结构合法的旧目录(含 `quality/fast`)不会自动重建,需要 owner 在后台修改或清空该行后重启。
- **目录规模**:目录项上限仍是 32;上游在售模型超过 32 条时同步会失败并记录原因,需要 owner 在后台维护子集。
@@ -0,0 +1,56 @@
# AGC 模型目录初始值改为上游同步
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | in-progress(实现与本地真实上游验证完成;生产发布未执行) |
| Date | 2026-09-24 |
| Parent Spec | `docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md` |
## 背景与触发
`agc_model_catalog` 缺行时,`read_agc_model_catalog` 兜底返回写死的初始目录(`高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)。这两个模型已从上游 Router 移除,于是从未配置过目录的环境(新库、清库、本地调试)会把两个不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次目录才恢复。
## 目标
1. 初始目录不再写死:api-server 启动期从上游分组定价列表生成,`alias` 与 `modelId` 都用上游原始模型名(不再填“高质量/快速”这类人工别名)。
2. 拉不到就报错、不写替代目录,并在下一次启动继续重试,直到目录里有数据。
3. **保持既有格式与契约不变**:目录字段(`id`/`alias`/`modelId`/`defaultModelId`)、后台页面与 DTO、`GET /api/llm/models` 形状、客户端模型标识校验都不变,不引入不兼容变更。
## 不在本里程碑内
- 不改目录字段语义与后台维护方式,不删别名/稳定标识概念。
- 不做上游变化的自动跟随同步(由 owner 在后台维护)。
- 不改 Router provisioning、额度与计费链路。
- 不改 `/api/external/v1` 与 OpenAPI,不改 SpacetimeDB 表结构。
## 合同要点
- **初始化**:api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),取 `data[].model_name`,按模型名排序生成目录:`modelId` 与 `alias` 为上游原名、`id` 为模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`)、全部 enabled、默认项取排序后第一项,并以存量 revision 写回自增。
- **失败关闭**:拉取失败、空列表、响应超过 1 MiB、缺可解析 revision、写回失败都只记录 error,不写替代目录;未初始化期间 AGC 目录/对话接口与后台目录接口返回 `503`“模型目录未初始化”。
- **重试口径**:只启动期尝试一次;失败不阻塞启动,下次启动重试,直到目录里有数据。请求侧无法触发同步。
- **幂等与并发**:目录只取决于模型集合(排序后生成),重复同步一致;多实例并发只有一个写入成功,冲突方接受既有目录并校验其可用性。
- **存量目录**:结构合法的目录不会被自动重建(包括旧版写死的 `quality/fast`),需要 owner 在后台改掉或清空该行后重启。
## 依赖
- `module-runtime`:`AgcModelCatalog::from_upstream_models`(slug 生成 + 默认项 + 校验),删除写死的 `Default` 实现。
- `spacetime-module`:`read_agc_model_catalog` 缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`。
- `api-server`:启动期 `ensure_agc_model_catalog_initialized`;上游请求硬化(10s 超时、1 MiB 流式上限、禁止重定向、不带凭据);`ensure_llm_router_url_allowed`(只校验地址,不绑定已下线的固定模型)。
- 文档:主规范、后端数据契约、运维文档、decision-log。
## 验收标准
1. 空目录 + 上游可达:启动后目录自动生成(别名即上游原名),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 是上游原名,界面不出现内置模型名。
2. 空目录 + 上游不可达/非 2xx/空列表:启动只记录 error、不写替代目录;AGC 与后台目录接口 `503`;上游恢复后重启即同步成功。
3. 幂等:同一模型集合重复同步得到一致的目录与默认项。
4. 目录领域校验(id/alias/model_id、32 项上限、默认项必须启用)与请求侧 `422`/`409` 行为与改动前一致。
5. 回归:Rust 定向测试、AGC/admin-web 类型检查与定向测试、`npm run check:encoding`、`check:doc-index`、`check:spacetime-schema`、`git diff --check`。
## 已决与待决
- 已决:上游来源用分组定价列表(2026-09-24 实测:`/v1/models` 用管理 token 返回 401;管理面注册表会带出已下线、无路由绑定的模型)。
- 已决:`id` 用模型名 slug,保持客户端标识校验契约不变。
- 已决:初始化失败不阻塞启动,只在下次启动重试。
- 已决:目录结构与既有 DTO/页面保持不变,本变更不引入不兼容改动。
- 待决:是否需要“上游自动跟随同步”(当前不做)。
@@ -9345,3 +9345,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}`、`server-rs/crates/shared-contracts/src/game_distribution.rs`、`packages/shared/src/contracts/gameDistribution.ts`、`src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}`、`scripts/check-game-distribution-media-e2e.mjs`、`package.json`、平台与运维主规范。
- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。
- 验证:`cargo check -p api-server --tests`、`cargo test -p api-server game_distribution`(27 passed)、`cargo fmt --all --check`、`npx vitest run src/components/game-distribution`(57 passed)、`npm run check:nginx-spa-routes`、`npm run check:encoding`(5060 文件)、`npm run check:doc-index`、`git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/` 与 `./assets/index-2Ws3zHlS.js` 均 200。
## 2026-09-24 AGC 模型目录初始值改为上游同步:不再回退写死的 gpt-6-astra/gpt-5.6-luna
- 背景:`agc_model_catalog` 缺行时 procedure 兜底返回内置目录(`quality → gpt-6-astra`、`fast → gpt-5.6-luna`),两个模型都已从上游移除;从未配置过目录的环境(新库、清库、本地调试)会把不存在的模型下发给客户端,选中后上游 `model_not_found`。
- 决策(范围):本次只改目录初始值的来源,保持既有格式与契约不变 —— 目录字段仍是 `id`/`alias`/`modelId`/`defaultModelId`,后台页面与 admin DTO、`GET /api/llm/models` 形状、客户端 `select_game_creator_model` 的标识校验都不动,因此没有不兼容变更。
- 决策(初始化):api-server(API/All 角色)启动时目录缺失、结构与当前定义不符或校验不通过即视为未初始化;此时请求上游 Router 控制面的分组定价列表 `GET {控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 排序生成目录:`modelId` 与 `alias` 用上游原名(不再填“高质量/快速”),`id` 用模型名 slug(小写字母/数字/`-`/`_`,同名冲突追加 `-2`,因此客户端标识校验无需放宽),全部 enabled,默认项取排序后第一项,并按存量 revision 写回自增。
- 决策(来源选择,2026-09-24 实测后确定):不用管理面模型注册表 `/api/models/`(会带出已下线、没有路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,用管理 token 实测 401)。当日 `group=taonier` 在售 6 个:`deepseek-flash`、`deepseek-v4-pro`、`glm-5.3`、`glm-5.3-flash`、`qwen-image-3.0`、`qwen3.8-flash`。
- 决策(失败关闭与重试):拉取失败、空列表、响应超 1 MiB、缺可解析 revision 或写回失败都只记录 error,不写替代目录;未初始化期间 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`“模型目录未初始化”;只在启动期尝试一次,下一次启动重试,直到目录里有数据。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 起不来。
- 决策(幂等与并发):目录只取决于模型集合(排序后生成),重复同步结果一致;多实例并发启动只有一个写入成功,冲突方重读并校验既有目录可用性。目录只在未初始化时重建,上游变化不自动跟随。
- 决策(存量目录):结构合法的目录不会被自动重建,包括旧版写死的 `quality/fast` —— 需要 owner 在后台改掉,或清空该行后重启重新同步。
- 影响范围:`module-runtime`(`from_upstream_models` + slug 生成,删除写死的 `Default`)、`spacetime-module`(缺行返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`)、`api-server`(启动期同步、上游请求硬化、只校验地址的目标校验、后台 PUT 未初始化门禁)、AGC 客户端(默认模型占位改为 `platform-default`)、AGC 模型弹层 CSS、主规范/后端契约/运维文档。
- 验证:`cargo test -p module-runtime --lib agc_models::`(4 passed)、`cargo test -p api-server --bin api-server agc` 与 `llm::`、AGC 客户端 `configuration::`、admin-web 页面定向 Vitest 与 typecheck、两套 workspace 的 `cargo fmt -- --check`、`check:encoding`/`check:doc-index`/`check:spacetime-schema`/`git diff --check`。
- 验证(真实上游 smoke,本地 dev DB):清空 `agc_model_catalog` 后启动 api-server → 日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;登录后 `GET /api/llm/models` 返回同一批模型、`displayName` 即上游原名、默认项为排序后第一项;上游不可达/非 2xx 时启动只记录 error、AGC 接口 `503` 且目录保持未初始化;目录已存在时重启不重写。
- 边界(未验证/残留):上游在售模型超过 32 条时同步会失败(目录项上限未改);`qwen-image-3.0` 这类图像模型会一起进入目录,是否对 AGC 隐藏由 owner 在后台停用;混合版本期间未升级的 api-server 会把自己的 AGC 接口打到 `503`,module 与 api-server 必须同批发布/回滚。
@@ -1,5 +1,7 @@
# AGC 后台模型别名与对话选择
更新时间:`2026-09-24`。本次只改“目录初始值从哪来”:缺配置时不再回退写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`,改为启动期从上游同步(这两条初始目录里的模型已从上游移除)。目录结构、后台维护字段和客户端契约都保持不变。
## 本地自定义 LLM
- 本地 `game-creator.config.json` 的 `llm.customEnabled` 默认 `false`;显式设为 `true` 后,常用设置展示 API 地址、API Key、读取模型列表与勾选区域。DirectProject 沿用 OpenAI Responses 协议,地址填写 API 根地址(例如 `https://provider.example/v1`)。开关只由配置文件控制。
@@ -33,7 +35,11 @@
## 官方路由契约
- 后台 owner 在“AGC 模型”维护列表;每项包含稳定 `id`、必填 `alias`、服务端 `modelId`、`enabled`。默认项必须启用。标识唯一,别名唯一,列表最多 32 项。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。缺少配置时使用初始目录,高质量对应 `gpt-6-astra`,快速对应 `gpt-5.6-luna`。
- 配置保存到私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,重启及多 api-server 实例共享同一事实。
- 目录初始值来自上游同步:api-server(API/All 角色)启动时检查目录,缺失、结构与当前定义不符或校验不通过都算“未初始化”;此时调用上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(控制面地址由 `{LLM Router 地址}` 去掉 `/v1` 得到;公开只读接口,不带凭据),读取 `data[].model_name` 作为“该分组可见的在售模型”,按模型名排序后生成目录:每项 `modelId` 与 `alias` 都用上游原始模型名(不再填“高质量/快速”这类人工别名),`id` 是模型名的稳定 slug(小写字母、数字、`-`、`_`,同名冲突追加 `-2`),`enabled = true`,默认项取排序后第一项,并以存量 revision 写回(`revision` 自增)。不使用管理面模型注册表 `/api/models/`——它会残留已下线、没有路由绑定的条目;也不使用 `/v1/models`——它要求 Router 用户 Key,平台没有服务级 Key。并发启动的多个实例里只有一个写入成功,其余接受既有目录。
- 同步失败(网络、非 2xx、空列表、响应超过 1 MiB、缺少目录行 revision、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。
- 目录未初始化时 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律失败关闭(`503`),错误文案指向“模型目录未初始化”。恢复路径是修好上游可达性后重启 api-server,或由运维清空 `agc_model_catalog` 该行后再重启。
- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型由 owner 在后台增删条目或调整启用、默认项。
- `GET/PUT /admin/api/agc-models` 仅 owner 可用,返回完整配置;PUT 携带上次读取的 revision,冲突拒绝覆盖。
- `GET /api/llm/models` 返回启用项的 `id/displayName`、`defaultModelId` 和目录 `revision`,不返回实际模型名、Router 目录、凭据或能力原始数据。
- 客户端缓存最近 `revision`,在项目切换 / 对话表面挂载 / 下拉展开 / 窗口聚焦时条件刷新:`revision` 未变化不更新界面,同一时刻只保留一个在途请求,刷新失败保留上一次有效目录与本地选择。发起对话前用同一份快照校验所选模型仍启用,已停用或删除则回退默认模型并提示。
@@ -48,6 +54,9 @@
## 验收
- 空目录 + 上游可达:启动后目录自动生成(`id` 为模型名 slug、`alias`/`modelId` 为上游原名、`enabled` 全为真、默认项为排序后第一项),`revision` 自增一次,`GET /api/llm/models` 的 `displayName` 就是上游原名,界面不出现任何内置模型名。
- 空目录 + 上游不可达/空列表/非 2xx:启动只记录 error,不生成替代目录;AGC 接口与后台目录接口返回 `503`“模型目录未初始化”;下游可恢复后重启即同步成功(不需要人工造目录)。
- 同一模型集合重复同步结果一致(上游返回顺序不影响目录与默认项)。
- 目录领域校验、未知/停用模型拒绝、客户端响应不包含实际模型名。
- 后台鉴权、持久化 revision 冲突处理;客户端选择保存后重新读取,设置保存不覆盖选择。
- 目录 `revision` 条件刷新与并发触发去重、发送前回退默认模型、刷新失败可恢复。
@@ -507,7 +507,8 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
### `agc_model_catalog`
- 私有单例表,主键 `id=0`,保存 `catalog_json`、`revision`、`updated_at`;不存凭据。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁。
- `read_agc_model_catalog` / `save_agc_model_catalog` 只接受已登记的 runtime service identity,保存使用 revision 乐观锁;缺行时读取返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不返回任何内置目录。
- 目录初始值来自上游同步:api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,就用分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据)的 `data[].model_name` 生成目录(`id` 为模型名 slug,`alias`/`modelId` 为上游原名),失败只记录 error、不写替代目录,由下一次启动重试;未初始化期间 AGC 目录与对话接口、后台目录接口都失败关闭(`503`)。
- 后台 owner 通过 `GET/PUT /admin/api/agc-models` 管理稳定标识、必填别名、实际模型名、启用状态和默认项;客户端 `GET /api/llm/models` 仅返回启用项的稳定标识、别名和目录 `revision`(供条件刷新,不暴露实际模型名)。
- Responses / Chat 请求按目录解析模型;未知或停用项拒绝。AGC 的 `platform-default` 请求标识使用目录默认项。详细契约见 `technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`。
@@ -589,6 +589,14 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
本地联调使用 `dev`,且 `GENARRATIVE_ENV` 为 `development`(默认)、`test` 或 `container` 时,允许规范 HTTP(S) loopback 地址及可变端口,无需配置独立埋点变量。客户端登录时使用实际 API 入口,容器使用宿主机映射入口。线上部署设置 `GENARRATIVE_ENV=production`,不接受 loopback 例外;详细合同见[客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)第 13 节。
### AGC 模型目录上游同步
`api-server`(API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时请求上游 Router 控制面的分组定价列表 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL 去掉 /v1}/api/pricing?group=taonier`(公开只读接口,不带凭据),按返回的 `data[].model_name` 排序生成目录并写回(revision 自增):`modelId` 与 `alias` 都是上游原始模型名,`id` 是模型名的 slug,默认项为排序后第一项。目录结构、后台字段与客户端契约都保持不变。
上游不可达、返回非 2xx、列表为空或响应超过 1 MiB 时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…`,`GET /api/llm/models`、`/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;修好上游可达性后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。存量目录(含旧版写死的 `高质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`)结构合法时不会被自动重建,需要 owner 在后台改掉,或清空 `agc_model_catalog` 该行后重启让其重新同步。
发布与回滚注意:本变更改的是 module 的缺行语义(由“返回内置目录”改为报错)与 api-server 的启动期同步,**module 与 api-server 必须同批发布、同批回滚**;混合版本期间未升级的 api-server 会把自己的 AGC 目录与对话接口打到 `503`(不会崩,但 AGC 不可用)。后台 DTO 与 admin-web 未改动,可独立发布。
### AGC 项目快照上传目标
后台“项目工程”(`/admin/#project-snapshots`)按项目列出远端快照,默认只看本部署渠道,顶部“渠道”选择框可切换远端已存在的其它渠道;列表按游标分页(每页 20/50/100,上一页复用已取得的游标,远端不给总数所以只显示当前页)。完整快照提供“下载完整工程”,按原始目录返回 ZIP;未完成同步的项目暂不可下载,旧清单缺少完整性声明时显示“完整性未知”,只能“下载已存文件”。“用户”列与“素材查询”同口径展示昵称与陶泥号,并可点开用户详情;不要直接把 OSS 的 `files/{size}-{digest}/` 目录下载当成工程。
+363 -14
View File
@@ -7,26 +7,208 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use module_runtime::AgcModelCatalog;
use module_runtime::{
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog,
};
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
use spacetime_client::SpacetimeClientError;
use std::time::Duration;
use tracing::warn;
/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。
pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str =
"模型目录未初始化,服务端正在尝试从上游同步,请稍后重试";
/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。
///
/// 同步发生在启动期、且在开始对外服务之前,超时必须足够短:上游挂起时不能让
/// 每个 API/All 实例都延迟三十秒才可用。单次失败只记录 error,下次启动会重试。
const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024;
/// 只读 `revision`:存量目录内容不合法时,覆盖写入仍需对齐乐观锁版本。
#[derive(serde::Deserialize)]
struct StoredCatalogRevision {
revision: u64,
}
pub(crate) async fn load_catalog(state: &AppState) -> Result<AgcModelCatalog, AppError> {
let json = state
.spacetime_client()
.read_agc_model_catalog()
.await
.map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
})?;
let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效")
})?;
catalog.validate().map_err(|message| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
let stored = read_stored_catalog(state).await.map_err(|_| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
})?;
let Some(json) = stored else {
return Err(uninitialized_error());
};
parse_catalog(&json).map_err(|_| uninitialized_error())
}
fn uninitialized_error() -> AppError {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
.with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE)
}
/// 解析并校验目录内容;解析或校验失败都按“未初始化”处理,由启动期重新同步。
fn parse_catalog(json: &str) -> Result<AgcModelCatalog, String> {
let catalog: AgcModelCatalog =
serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?;
catalog.validate()?;
Ok(catalog)
}
async fn read_stored_catalog(state: &AppState) -> Result<Option<String>, SpacetimeClientError> {
match state.spacetime_client().read_agc_model_catalog().await {
Ok(json) => Ok(Some(json)),
Err(SpacetimeClientError::Procedure(message))
if message == AGC_MODEL_CATALOG_NOT_INITIALIZED =>
{
Ok(None)
}
Err(error) => Err(error),
}
}
/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。
///
/// 目录已可用时不做任何写入;只有缺失、结构与当前定义不符或校验不通过才重建,因此上游模型
/// 变化不会自动覆盖后台维护过的目录。
pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> {
let stored = read_stored_catalog(state)
.await
.map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?;
let revision = match stored.as_deref() {
Some(json) => match parse_catalog(json) {
Ok(_) => return Ok(()),
Err(message) => {
warn!(
error = %message,
"AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建"
);
stored_catalog_revision(json)
}
},
None => Some(0),
};
let revision = revision.ok_or_else(|| {
"存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string()
})?;
let models = fetch_upstream_model_names(state).await?;
let catalog = AgcModelCatalog::from_upstream_models(models, revision)?;
let payload =
serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?;
match state
.spacetime_client()
.save_agc_model_catalog(payload)
.await
{
Ok(saved) => {
let saved: AgcModelCatalog = serde_json::from_str(&saved)
.map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?;
tracing::info!(
revision = saved.revision,
model_count = saved.models.len(),
"已按上游模型列表初始化 AGC 模型目录"
);
Ok(())
}
// 多实例同时启动时只有一个写入成功:接受既有目录,但仍要确认它可用,
// 否则会静默地把「每次启动都冲突、目录一直不可用」变成没有任何线索的黑洞。
Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => {
let stored = read_stored_catalog(state)
.await
.map_err(|error| format!("写入冲突后重读 AGC 模型目录失败:{error}"))?;
if stored
.as_deref()
.map(parse_catalog)
.is_some_and(|result| result.is_ok())
{
warn!("AGC 模型目录写入冲突:已接受其它实例写入的目录");
Ok(())
} else {
Err("AGC 模型目录写入冲突后仍不可用,需要人工检查该行内容与 revision".to_string())
}
}
Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")),
}
}
fn stored_catalog_revision(json: &str) -> Option<u64> {
serde_json::from_str::<StoredCatalogRevision>(json)
.ok()
.map(|stored| stored.revision)
}
/// 上游在售模型列表:`GET {Router 控制面}/api/pricing?group=taonier` 的 `data[].model_name`。
///
/// 取“该分组可见的在售模型”,而不是管理面模型注册表:注册表里会残留已下线、没有路由绑定的
/// 条目(例如已从上游移除的 `gpt-6-astra`/`gpt-6-luna`),而定价列表就是 AGC 账号实际能调用的集合。
/// 该端点是公开只读接口,不需要管理凭据。
async fn fetch_upstream_model_names(state: &AppState) -> Result<Vec<String>, String> {
crate::external_api_keys::ensure_llm_router_url_allowed(state)?;
let origin =
crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?;
let url = format!(
"{origin}/api/pricing?group={}",
crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP
);
let client = reqwest::Client::builder()
.timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?;
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("请求上游模型列表失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("上游模型列表返回 HTTP {status}"));
}
let bytes = read_bounded_json_body(response).await?;
let payload: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?;
parse_upstream_model_names(&payload)
}
async fn read_bounded_json_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
// 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度,
// 逐块累计超阈值立即中断,避免 `bytes()` 一次性分配任意大小响应撑爆内存。
if response
.content_length()
.is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64)
{
return Err("上游模型列表响应超过大小上限".to_string());
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("读取上游模型列表失败:{error}"))?
{
if bytes.len().saturating_add(chunk.len()) > AGC_MODEL_LIST_MAX_BYTES {
return Err("上游模型列表响应超过大小上限".to_string());
}
bytes.extend_from_slice(chunk.as_ref());
}
Ok(bytes)
}
fn parse_upstream_model_names(payload: &serde_json::Value) -> Result<Vec<String>, String> {
let data = payload
.get("data")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?;
let models = data
.iter()
.filter_map(|entry| entry.get("model_name").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
if models.iter().all(|model| model.trim().is_empty()) {
return Err("上游模型列表为空".to_string());
}
Ok(models)
}
pub async fn admin_get_agc_models(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -44,6 +226,8 @@ pub async fn admin_save_agc_models(
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminAgcModelCatalog>,
) -> Result<Json<serde_json::Value>, AppError> {
// 目录只来自上游同步:未初始化时后台写入同样失败关闭,避免出现第二条绕过同步的写入口。
load_catalog(&state).await?;
let catalog = AgcModelCatalog {
revision: payload.revision,
default_model_id: payload.default_model_id,
@@ -68,7 +252,7 @@ pub async fn admin_save_agc_models(
.save_agc_model_catalog(payload)
.await
.map_err(|error| {
if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) {
if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) {
AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取")
} else {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试")
@@ -95,3 +279,168 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn upstream_model_names_come_from_pricing_data_array() {
let payload = json!({
"auto_groups": ["default"],
"data": [
{"model_name": "glm-5.3", "model_ratio": 1.0},
{"model_name": "deepseek-flash", "model_ratio": 0.075},
{"model_ratio": 1.0}
]
});
assert_eq!(
parse_upstream_model_names(&payload).unwrap(),
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
);
assert_eq!(
parse_upstream_model_names(&json!({"data": []})).unwrap_err(),
"上游模型列表为空"
);
assert_eq!(
parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(),
"上游模型列表为空"
);
assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err());
}
#[test]
fn stored_catalog_revision_reads_row_revision() {
assert_eq!(
stored_catalog_revision(
r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"#
),
Some(4)
);
assert_eq!(stored_catalog_revision("not json"), None);
assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None);
}
#[test]
fn catalog_parsing_marks_unusable_content_as_uninitialized() {
// 后台保存过的目录结构必须能直接解析。
let catalog = AgcModelCatalog::from_upstream_models(
vec!["deepseek-v4-pro".to_string(), "glm-5.3".to_string()],
4,
)
.unwrap();
assert_eq!(
parse_catalog(&serde_json::to_string(&catalog).unwrap()).unwrap(),
catalog
);
// 结构或内容不合法(例如被外部工具改过)都按未初始化处理,由启动期重新同步。
assert!(
parse_catalog(r#"{"revision":1,"defaultModel":"deepseek-v4-pro","models":[]}"#)
.is_err()
);
assert!(parse_catalog(
r#"{"revision":1,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":false}]}"#
)
.is_err());
}
struct MockModelListServer {
base_url: String,
captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
_handle: std::thread::JoinHandle<()>,
}
fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds");
let address = listener.local_addr().expect("mock address");
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
let captured_for_thread = std::sync::Arc::clone(&captured);
let response = format!(
"HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock accept");
let mut buffer = [0u8; 8192];
let read = stream.read(&mut buffer).unwrap_or_default();
*captured_for_thread.lock().expect("captured lock") =
Some(String::from_utf8_lossy(&buffer[..read]).to_string());
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
});
MockModelListServer {
base_url: format!("http://{address}/v1"),
captured,
_handle: handle,
}
}
fn model_list_state(base_url: &str) -> AppState {
AppState::new(crate::config::AppConfig {
llm_router_base_url: base_url.to_string(),
..crate::config::AppConfig::default()
})
.expect("state should build")
}
#[tokio::test]
async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() {
let server = spawn_mock_model_list_server(
"200 OK",
&json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]})
.to_string(),
);
let state = model_list_state(&server.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap(),
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
);
let request = server
.captured
.lock()
.expect("captured lock")
.clone()
.expect("mock server should capture request");
// 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。
assert!(
request.starts_with("GET /api/pricing?group=taonier HTTP/1.1"),
"{request}"
);
// 定价列表是公开只读接口:不得把任何凭据发过去。
assert!(
!request.to_ascii_lowercase().contains("authorization:"),
"{request}"
);
}
#[tokio::test]
async fn fetch_upstream_model_names_fails_closed_when_upstream_unavailable_or_empty() {
let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}");
let state = model_list_state(&unauthorized.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表返回 HTTP 401 Unauthorized"
);
let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string());
let state = model_list_state(&empty.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表为空"
);
let failing = spawn_mock_model_list_server("500 Internal Server Error", "{}");
let state = model_list_state(&failing.base_url);
assert_eq!(
fetch_upstream_model_names(&state).await.unwrap_err(),
"上游模型列表返回 HTTP 500 Internal Server Error"
);
}
}
@@ -49,7 +49,7 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [
const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate";
/// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。
const LLM_ROUTER_USER_GROUP: &str = "taonier";
const LLM_ROUTER_TOKEN_GROUP: &str = "taonier";
pub(crate) const LLM_ROUTER_TOKEN_GROUP: &str = "taonier";
const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"];
const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1;
const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60;
@@ -1624,7 +1624,7 @@ async fn ensure_router_token_contract(
Ok(())
}
fn router_control_origin(base_url: &str) -> Result<String, String> {
pub(crate) fn router_control_origin(base_url: &str) -> Result<String, String> {
let mut url = reqwest::Url::parse(base_url.trim_end_matches('/'))
.map_err(|error| format!("LLM Router 地址无效:{error}"))?;
let is_loopback = url.host_str().is_some_and(|host| {
@@ -1643,7 +1643,11 @@ fn router_control_origin(base_url: &str) -> Result<String, String> {
Ok(url.to_string().trim_end_matches('/').to_string())
}
fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
/// 只校验 LLM Router 目标地址是否允许(官方路由 / loopback、scheme),不校验固定模型。
///
/// 与具体模型无关的调用(例如按分组定价列表同步 AGC 模型目录)用这个入口,
/// 避免被“必须使用官方固定模型”的哨兵常量挡住。
pub(crate) fn ensure_llm_router_url_allowed(state: &AppState) -> Result<(), String> {
let base_url = state.config.llm_router_base_url.trim_end_matches('/');
let url =
reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?;
@@ -1664,9 +1668,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
if base_url != OFFICIAL_LLM_ROUTER_BASE_URL {
return Err("生产环境 LLM Router 必须使用官方固定路由".to_string());
}
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
return Err("生产环境 LLM Router 必须使用官方固定模型".to_string());
}
if url.scheme() != "https" {
return Err("生产环境 LLM Router 只允许 HTTPS 地址".to_string());
}
@@ -1674,9 +1675,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
}
if base_url == OFFICIAL_LLM_ROUTER_BASE_URL {
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
return Err("LLM Router 必须使用官方固定模型".to_string());
}
if url.scheme() != "https" {
return Err("官方 LLM Router 只允许 HTTPS 地址".to_string());
}
@@ -1698,6 +1696,19 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
Ok(())
}
pub(crate) fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
ensure_llm_router_url_allowed(state)?;
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
if state.config.is_production() {
return Err("生产环境 LLM Router 必须使用官方固定模型".to_string());
}
if state.config.llm_router_base_url.trim_end_matches('/') == OFFICIAL_LLM_ROUTER_BASE_URL {
return Err("LLM Router 必须使用官方固定模型".to_string());
}
}
Ok(())
}
fn router_username_for_owner(owner_user_id: &str) -> String {
// New API 的 User.Username 校验上限是 20 个字符。保留可读前缀后只
// 能放 11 个字符;使用完整 owner id 做 SHA-256,再编码成 8 字节的
+110 -23
View File
@@ -37,18 +37,24 @@ mod model_catalog_tests {
use super::*;
#[test]
fn public_catalog_only_exposes_alias_and_stable_id() {
let mut catalog = module_runtime::AgcModelCatalog::default();
catalog.revision = 7;
fn public_catalog_exposes_stable_id_and_upstream_alias() {
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
7,
)
.expect("catalog should build");
catalog.models[1].enabled = false;
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
// 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。
assert_eq!(
payload["models"],
json!([{"id": "quality", "displayName": "高质量"}])
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol"}])
);
assert_eq!(payload["defaultModelId"], "quality");
assert_eq!(payload["defaultModelId"], "gpt-5-6-sol");
assert_eq!(payload["revision"], json!(7));
assert!(!payload.to_string().contains("gpt-"));
assert!(payload.get("defaultModel").is_none());
assert!(payload["models"][0].get("enabled").is_none());
assert!(payload["models"][0].get("modelId").is_none());
}
}
@@ -194,6 +200,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
.filter(|model| model.enabled)
.map(|model| LlmModelSummary {
id: model.id,
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
display_name: model.alias,
})
.collect(),
@@ -201,6 +208,29 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
}
}
/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」,
/// 也验证标识 → 实际模型名的映射;默认项是排序后的第一项,`TEST_AGC_MODEL_ID` 不是默认项。
#[cfg(test)]
pub(crate) const TEST_AGC_MODEL_ID: &str = "test-router-model";
#[cfg(test)]
pub(crate) const TEST_AGC_MODEL_MODEL_ID: &str = "test-router.model";
#[cfg(test)]
pub(crate) const TEST_AGC_MODEL_DEFAULT_ID: &str = "test-router-default";
#[cfg(test)]
pub(crate) const TEST_AGC_MODEL_DEFAULT_MODEL_ID: &str = "test-router.default";
#[cfg(test)]
pub(crate) fn test_agc_model_catalog() -> module_runtime::AgcModelCatalog {
module_runtime::AgcModelCatalog::from_upstream_models(
vec![
TEST_AGC_MODEL_DEFAULT_MODEL_ID.to_string(),
TEST_AGC_MODEL_MODEL_ID.to_string(),
],
0,
)
.expect("test catalog should build")
}
async fn load_llm_catalog(
state: &AppState,
owner: &str,
@@ -211,7 +241,7 @@ async fn load_llm_catalog(
.expect("fixture lock")
.contains_key(owner)
{
return Ok(module_runtime::AgcModelCatalog::default());
return Ok(test_agc_model_catalog());
}
let _ = owner;
crate::agc_models::load_catalog(state).await
@@ -283,9 +313,8 @@ pub async fn proxy_llm_responses(
] {
object.remove(field);
}
// The AGC client may select a model from the server-provided Router
// directory. Older callers without the reserved marker remain pinned to
// the official default model.
// AGC 客户端可以在服务端目录内选择模型;`model` 就是上游原始模型名。
// 老客户端存的历史稳定标识与目录外模型一律拒绝,不回退其它模型。
let agc_client = headers
.get("x-genarrative-client")
.and_then(|value| value.to_str().ok())
@@ -293,16 +322,13 @@ pub async fn proxy_llm_responses(
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
.await
.map_err(|error| llm_error_response(&request_context, error))?;
let selected_id = if agc_client {
requested_model
.as_deref()
.filter(|id| *id != "platform-default")
let requested_model = if agc_client {
requested_model.as_deref()
} else {
None
}
.unwrap_or(&catalog.default_model_id);
};
let selected_model = catalog
.resolve(selected_id)
.resolve_requested(requested_model)
.map_err(|message| {
llm_error_response(
&request_context,
@@ -847,7 +873,7 @@ async fn resolve_llm_router_client(
let catalog = load_llm_catalog(state, owner_user_id)
.await
.map_err(|_| "模型目录暂不可用".to_string())?;
let model = catalog.resolve(&catalog.default_model_id)?;
let model = catalog.resolve_requested(None)?;
let config = platform_llm::LlmConfig::new(
platform_llm::LlmProvider::OpenAiCompatible,
base_url.to_string(),
@@ -1304,11 +1330,14 @@ mod tests {
}
#[tokio::test]
async fn llm_responses_proxy_forces_official_model_and_keeps_router_key_server_side() {
async fn llm_responses_without_agc_marker_uses_catalog_default_and_keeps_router_key_server_side()
{
let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: r#"{"id":"resp_proxy_01","model":"gpt-6-astra","output":[]}"#.to_string(),
body: format!(
r#"{{"id":"resp_proxy_01","model":"{TEST_AGC_MODEL_DEFAULT_MODEL_ID}","output":[]}}"#
),
extra_headers: Vec::new(),
});
let (state, user_id) = seed_authenticated_state(AppConfig {
@@ -1373,12 +1402,64 @@ mod tests {
.expect("upstream request body");
let upstream_payload: Value =
serde_json::from_str(upstream_body).expect("upstream body should be json");
assert_eq!(upstream_payload["model"], "gpt-6-astra");
assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID);
assert_ne!(upstream_payload["model"], "client-must-not-control");
}
#[tokio::test]
async fn llm_responses_rejects_upstream_names_and_unknown_catalog_ids() {
async fn llm_responses_forwards_catalog_model_selected_by_agc_client() {
let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: format!(
r#"{{"id":"resp_proxy_02","model":"{TEST_AGC_MODEL_MODEL_ID}","output":[]}}"#
),
extra_headers: Vec::new(),
});
let (state, user_id) = seed_authenticated_state(AppConfig {
llm_router_base_url: server_url.clone(),
llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()),
..AppConfig::default()
})
.await;
install_test_provisioned_router_credential(&user_id, server_url, "fixture-router-key");
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/llm/responses")
.header("authorization", format!("Bearer {token}"))
.header("x-genarrative-client", "agc")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": TEST_AGC_MODEL_ID, "input": "hello"}).to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let upstream_request = captured_request
.lock()
.expect("captured request lock")
.clone()
.expect("mock server should capture upstream request");
let (_, upstream_body) = upstream_request
.split_once("\r\n\r\n")
.expect("upstream request body");
let upstream_payload: Value =
serde_json::from_str(upstream_body).expect("upstream body should be json");
// 客户端只能回传目录标识,服务端映射成上游实际模型名;默认项不参与。
assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_MODEL_ID);
assert_ne!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID);
}
#[tokio::test]
async fn llm_responses_rejects_models_outside_catalog() {
let (state, user_id) = seed_authenticated_state(AppConfig::default()).await;
install_test_provisioned_router_credential(
&user_id,
@@ -1387,7 +1468,13 @@ mod tests {
);
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
for model in ["gpt-6-astra", "unlisted"] {
// 历史稳定标识、目录外名称、以及「直接拿上游实际模型名当标识」都必须拒绝。
for model in [
"quality",
"gpt-6-astra",
"unlisted",
TEST_AGC_MODEL_MODEL_ID,
] {
let response = app
.clone()
.oneshot(
+14
View File
@@ -500,6 +500,10 @@ fn should_initialize_editor_generation_pricing_for_startup(process_role: Process
process_role.runs_http()
}
fn should_initialize_agc_model_catalog_for_startup(process_role: ProcessRole) -> bool {
process_role.runs_http()
}
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
let bind_address = config.bind_socket_addr();
let listen_backlog = config.listen_backlog;
@@ -764,6 +768,16 @@ async fn try_restore_app_state_for_startup(
))
})?;
}
// AGC 模型目录只来自上游同步或后台保存;这里同步失败不阻塞启动,由下一次启动重试,
// 未初始化期间 AGC 相关接口失败关闭。
if should_initialize_agc_model_catalog_for_startup(process_role) {
if let Err(error) = crate::agc_models::ensure_agc_model_catalog_initialized(&state).await {
error!(
error = %error,
"AGC 模型目录未初始化:本次启动未从上游同步到模型列表,AGC 目录与对话接口将失败关闭,下次启动会重试"
);
}
}
Ok(state)
}
+238 -36
View File
@@ -1,9 +1,18 @@
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
/// 目录 revision 乐观锁冲突。
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
pub const AGC_MODEL_CATALOG_NOT_INITIALIZED: &str = "AGC_MODEL_CATALOG_NOT_INITIALIZED";
/// 客户端未显式选择模型时使用的占位标识。
pub const AGC_MODEL_PLATFORM_DEFAULT: &str = "platform-default";
/// 模型标识的长度上限,与客户端 `select_game_creator_model` 的校验保持一致。
pub const AGC_MODEL_ID_MAX_BYTES: usize = 64;
/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。
pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModel {
pub id: String,
@@ -12,7 +21,7 @@ pub struct AgcModel {
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModelCatalog {
pub revision: u64,
@@ -20,40 +29,62 @@ pub struct AgcModelCatalog {
pub models: Vec<AgcModel>,
}
impl Default for AgcModelCatalog {
fn default() -> Self {
Self {
revision: 0,
default_model_id: "quality".into(),
models: vec![
AgcModel {
id: "quality".into(),
alias: "高质量".into(),
model_id: "gpt-6-astra".into(),
enabled: true,
},
AgcModel {
id: "fast".into(),
alias: "快速".into(),
model_id: "gpt-5.6-luna".into(),
enabled: true,
},
],
}
}
}
impl AgcModelCatalog {
/// 按上游模型列表生成目录:`modelId` 是上游原始模型名,`alias` 也直接用原名
/// (不再填「高质量/快速」这类人工别名),`id` 是模型名的稳定 slug。
///
/// 上游返回顺序不稳定,所以先按原始模型名排序再生成,重复同步得到一致的目录与默认项。
pub fn from_upstream_models(
models: impl IntoIterator<Item = String>,
revision: u64,
) -> Result<Self, String> {
let mut model_names = models
.into_iter()
.map(|model| model.trim().to_string())
.filter(|model| !model.is_empty())
.collect::<Vec<_>>();
model_names.sort();
model_names.dedup();
if model_names.is_empty() {
return Err("上游模型列表为空".into());
}
let mut used_ids = HashSet::new();
let mut entries = Vec::with_capacity(model_names.len());
for model_id in model_names {
let id = unique_model_id(&model_id, &mut used_ids);
entries.push(AgcModel {
id,
alias: model_id.clone(),
model_id,
enabled: true,
});
}
let default_model_id = entries
.first()
.map(|entry| entry.id.clone())
.ok_or_else(|| "上游模型列表为空".to_string())?;
let catalog = Self {
revision,
default_model_id,
models: entries,
};
catalog.validate()?;
Ok(catalog)
}
pub fn validate(&self) -> Result<(), String> {
if self.models.is_empty() || self.models.len() > 32 {
return Err("模型列表必须包含 1 至 32 项".into());
if self.models.is_empty() || self.models.len() > AGC_MODEL_CATALOG_MAX_MODELS {
return Err(format!(
"模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项"
));
}
let mut ids = HashSet::new();
let mut aliases = HashSet::new();
for model in &self.models {
if model.id.is_empty()
|| model.id == "platform-default"
|| model.id.len() > 64
|| model.id == AGC_MODEL_PLATFORM_DEFAULT
|| model.id.len() > AGC_MODEL_ID_MAX_BYTES
|| !model
.id
.bytes()
@@ -88,31 +119,202 @@ impl AgcModelCatalog {
.map(|m| m.model_id.as_str())
.ok_or_else(|| "所选模型不可用,请刷新模型列表".into())
}
/// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。
pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> {
let requested = requested
.map(str::trim)
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
match requested {
Some(id) => self.resolve(id),
None => self.resolve(&self.default_model_id),
}
}
}
/// 由上游模型名生成稳定标识:只保留小写字母、数字、连字符与下划线,其余字符折叠成 `-`。
fn agc_model_id_from_name(model_name: &str) -> String {
let mut id = String::new();
let mut separator_pending = false;
for value in model_name.chars() {
let lowered = value.to_ascii_lowercase();
if lowered.is_ascii_alphanumeric() || lowered == '_' {
if separator_pending && !id.is_empty() {
id.push('-');
}
separator_pending = false;
id.push(lowered);
} else {
separator_pending = true;
}
}
id
}
/// 生成在本次目录内唯一的标识:同名 slug 追加 `-2`/`-3`,并保证不超过长度上限。
fn unique_model_id(model_name: &str, used_ids: &mut HashSet<String>) -> String {
let slug = agc_model_id_from_name(model_name);
let slug = if slug.is_empty() {
"model".to_string()
} else {
slug
};
// 预留后缀空间(`-` 加最多两位序号)后截断,保证候选标识仍在长度上限内。
let base = slug
.char_indices()
.take_while(|(index, _)| *index < AGC_MODEL_ID_MAX_BYTES - 3)
.map(|(_, value)| value)
.collect::<String>();
let base = base.trim_end_matches('-').to_string();
let base = if base.is_empty() {
"model".to_string()
} else {
base
};
let mut candidate = base.clone();
let mut suffix = 2;
while !used_ids.insert(candidate.clone()) {
candidate = format!("{base}-{suffix}");
suffix += 1;
}
candidate
}
#[cfg(test)]
mod tests {
use super::*;
fn upstream(models: &[&str]) -> Vec<String> {
models.iter().map(|model| (*model).to_string()).collect()
}
fn model(id: &str, alias: &str, model_id: &str) -> AgcModel {
AgcModel {
id: id.into(),
alias: alias.into(),
model_id: model_id.into(),
enabled: true,
}
}
#[test]
fn catalog_builds_from_upstream_models_with_stable_ids() {
let catalog = AgcModelCatalog::from_upstream_models(
upstream(&[
" qwen3.8-flash ",
"glm-5.3",
"qwen3.8-flash",
"deepseek-v4-pro",
"",
"vendor/model.v1:latest",
]),
3,
)
.unwrap();
assert_eq!(catalog.revision, 3);
// 默认项是排序后第一项,与上游返回顺序无关。
assert_eq!(catalog.default_model_id, "deepseek-v4-pro");
assert_eq!(
catalog.models,
vec![
model("deepseek-v4-pro", "deepseek-v4-pro", "deepseek-v4-pro"),
model("glm-5-3", "glm-5.3", "glm-5.3"),
model("qwen3-8-flash", "qwen3.8-flash", "qwen3.8-flash"),
model(
"vendor-model-v1-latest",
"vendor/model.v1:latest",
"vendor/model.v1:latest"
),
]
);
assert!(catalog.validate().is_ok());
// 同一模型集合重复生成结果一致。
assert_eq!(
AgcModelCatalog::from_upstream_models(
upstream(&[
"vendor/model.v1:latest",
"deepseek-v4-pro",
"glm-5.3",
"qwen3.8-flash",
]),
3
)
.unwrap(),
catalog
);
assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err());
}
#[test]
fn catalog_keeps_ids_unique_and_within_client_contract() {
// 不同模型名折叠成同一个 slug 时按排序追加序号,且标识始终符合客户端校验。
let catalog = AgcModelCatalog::from_upstream_models(
upstream(&["GLM-5.3", "glm/5.3", "glm_5.3", "模型名"]),
0,
)
.unwrap();
let ids = catalog
.models
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["glm-5-3", "glm-5-3-2", "glm_5-3", "model"]);
for entry in &catalog.models {
assert!(entry.id.len() <= AGC_MODEL_ID_MAX_BYTES);
assert!(
entry
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
);
}
assert!(catalog.validate().is_ok());
}
#[test]
fn catalog_maps_only_enabled_ids() {
let mut catalog = AgcModelCatalog::default();
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
assert!(catalog.validate().is_ok());
assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra");
assert!(catalog.resolve("gpt-6-astra").is_err());
assert!(catalog.resolve("unknown").is_err());
assert_eq!(catalog.resolve("model-a").unwrap(), "model-a");
// 客户端不能直接指定实际模型名,只能回传目录标识。
assert!(catalog.resolve("model-c").is_err());
assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a");
assert_eq!(
catalog
.resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT))
.unwrap(),
"model-a"
);
catalog.models[0].enabled = false;
assert!(catalog.resolve("quality").is_err());
assert!(catalog.resolve("model-a").is_err());
assert!(catalog.validate().is_err());
}
#[test]
fn catalog_rejects_duplicate_aliases_and_ids() {
let mut catalog = AgcModelCatalog::default();
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
catalog.models[1].alias = catalog.models[0].alias.clone();
assert!(catalog.validate().is_err());
catalog.models[1].alias = "快速".into();
catalog.models[1].alias = "model-b".into();
catalog.models[1].id = catalog.models[0].id.clone();
assert!(catalog.validate().is_err());
catalog.models[1].id = "model-b".into();
catalog.models[1].id = AGC_MODEL_PLATFORM_DEFAULT.into();
assert!(catalog.validate().is_err());
catalog.models[1].id = "model-b".into();
catalog.models[1].model_id = "".into();
assert!(catalog.validate().is_err());
let too_many = (0..AGC_MODEL_CATALOG_MAX_MODELS + 1)
.map(|index| format!("model-{index}"))
.collect::<Vec<_>>();
assert_eq!(
AgcModelCatalog::from_upstream_models(too_many, 0).unwrap_err(),
format!("模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项")
);
}
}
@@ -16,16 +16,14 @@ pub fn read_agc_model_catalog(ctx: &mut ProcedureContext) -> Result<String, Stri
crate::editor_project_storage::require_editor_generation_runtime_service_identity(
tx, caller,
)?;
Ok(tx
.db
// 目录只有一份事实来源:上游同步或后台保存。缺行时不得返回任何内置目录,
// 由 api-server 按“未初始化”处理并触发启动期同步。
tx.db
.agc_model_catalog()
.id()
.find(0)
.map(|row| row.catalog_json)
.unwrap_or_else(|| {
serde_json::to_string(&module_runtime::AgcModelCatalog::default())
.expect("default catalog")
}))
.ok_or_else(|| module_runtime::AGC_MODEL_CATALOG_NOT_INITIALIZED.to_string())
})
}