Merge pull request 'AGC 模型目录改为启动期从上游同步,退役写死的 gpt-6-astra/luna 与别名(#506)' (#507) from enhance/default-model into master
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Reviewed-on: #507
This commit was merged in pull request #507.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}/` 目录下载当成工程。
|
||||
|
||||
@@ -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 字节的
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user