AGC 模型目录改从上游分组定价列表同步,并固定初始化顺序
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 54s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m8s
Project CI / Native shell tests (pull_request) Successful in 5m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 54s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / Repository checks (pull_request) Failing after 17s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m8s
Project CI / Native shell tests (pull_request) Successful in 5m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m57s
- api-server:目录来源改为 GET {Router 控制面}/api/pricing?group=taonier(公开只读、不带凭据),取 data[].model_name;不用 /v1/models(管理 token 实测 401,要用户 Key)与 /api/models/(会带出已下线无路由绑定的 gpt-6-astra/luna)
- module-runtime:from_upstream_models 按模型名字典序去重排序,默认项取排序后第一个,重复同步结果一致(上游返回顺序不稳定)
- 文档:主规范、里程碑、实施计划、运维文档与 decision-log 同步来源与顺序口径,并记录真实 Router 实测结论
This commit is contained in:
@@ -9,9 +9,9 @@
|
||||
|
||||
## 修改边界与顺序
|
||||
|
||||
1. **领域模型(`module-runtime`)**:`src/agc_models.rs` 的 `AgcModel { model, enabled }`、`AgcModelCatalog { revision, default_model, models }`;删除写死 default,改为布尔校验 + `from_upstream_models`(去重保序、剔除空项、默认项取首个)+ `resolve`(只接受启用项)。`model` 非空、无首尾空白、≤200 字节、无控制字符、唯一(不设人工条数上限),`default_model` 必须指向启用项。
|
||||
1. **领域模型(`module-runtime`)**:`src/agc_models.rs` 的 `AgcModel { model, enabled }`、`AgcModelCatalog { revision, default_model, models }`;删除写死 default,改为布尔校验 + `from_upstream_models`(剔除空项、按模型名字典序去重排序、默认项取排序后第一个)+ `resolve`(只接受启用项)。`model` 非空、无首尾空白、≤200 字节、无控制字符、唯一(不设人工条数上限),`default_model` 必须指向启用项。
|
||||
2. **SpacetimeDB procedure(`spacetime-module`)**:`read_agc_model_catalog` 缺行时返回 `AGC_MODEL_CATALOG_NOT_INITIALIZED`,不再返回内置目录;`save_agc_model_catalog` 逻辑不变(validate + revision 乐观锁 upsert)。无表结构变化,不改 `migration.rs`。
|
||||
3. **api-server 目录模块**:`src/agc_models.rs` 拆分“读原始 JSON / 解析校验 / 载入目录”,新增启动期 `ensure_agc_model_catalog_initialized`:目录已初始化直接返回;未初始化则 `GET {llm_router_base_url}/models`(Bearer 管理员 Token、30s 超时、1 MiB 上限、拒绝重定向)取 `data[].id`,用存量 revision 建目录写回;冲突按“他实例已写入”接受;其余失败返回错误。admin DTO 改为 `{revision, defaultModel, models: [{model, enabled}]}`。
|
||||
3. **api-server 目录模块**:`src/agc_models.rs` 拆分“读原始 JSON / 解析校验 / 载入目录”,新增启动期 `ensure_agc_model_catalog_initialized`:目录已初始化直接返回;未初始化则 `GET {Router 控制面}/api/pricing?group=taonier`(30s 超时、1 MiB 上限、拒绝重定向、不带凭据)取 `data[].model_name`,用存量 revision 建目录写回;冲突按“他实例已写入”接受;其余失败返回错误。admin DTO 改为 `{revision, defaultModel, models: [{model, enabled}]}`。
|
||||
4. **api-server 接线**:`src/main.rs` 在 `try_restore_app_state_for_startup` 中按 HTTP 角色调用初始化,失败只 `error!` 记录,不阻塞启动、不返回 `DependencyUnavailable`。`src/llm/mod.rs` 的公开目录映射改为 `id`/`displayName` 取 `model`,请求侧解析改为“目录内原名透传 + `platform-default` 用默认项”。
|
||||
5. **后台页面(`admin-web`)**:`AdminAgcModelsPage.tsx` 去掉别名列,模型名可编辑,保留启用、默认、删除与新增;`adminApiTypes.ts` 同步 DTO;更新该页测试。
|
||||
6. **AGC 客户端**:`src-tauri/src/main.rs` 的 `DEFAULT_GAME_CREATOR_LLM_MODEL` 改为官方占位标识(`platform-default`),`game-creator.config.json` 的 `model` 同步;`scripts/check-config.mjs` 与 `src-tauri/src/tests/configuration.rs` 断言跟随。
|
||||
|
||||
@@ -27,8 +27,10 @@ AGC 平台模型目录在 SpacetimeDB 里没有配置时,由 `spacetime-module
|
||||
## 合同要点
|
||||
|
||||
- **目录内容**:每项只有 `model`(上游原始模型名)与 `enabled`,另有目录级 `defaultModel`(必须指向启用项)与 `revision`;不设人工条数上限,`model` 唯一、无首尾空白、≤200 字节。
|
||||
- **初始化**:api-server(API/All 角色)启动时,目录缺失、结构与当前定义不符或校验不通过时视为未初始化;此时用 Router 管理员 Token 调 `GET {Router 地址}/models`,按返回顺序取 `data[].id` 建目录,默认项为首个模型,写回时沿用存量 revision 并自增。多实例并发只有一个写入成功。
|
||||
- **失败关闭**:拉取失败、缺 Token、空列表或写回失败都只记录 error,不生成替代目录;目录未初始化期间 AGC 相关接口与后台目录接口返回 `503`“模型目录未初始化”,不回退任何内置模型名。
|
||||
- **初始化**:api-server(API/All 角色)启动时,目录缺失、结构与当前定义不符或校验不通过时视为未初始化;此时调上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读接口),取 `data[].model_name` 按模型名字典序建目录(默认项为排序后第一个模型,保证重复同步结果一致),写回时沿用存量 revision 并自增。多实例并发只有一个写入成功。
|
||||
- **初始化幂等性**:目录内容只取决于上游模型集合与存量 revision,不取决于上游返回顺序,因此同一模型集合重复同步得到完全一致的目录。
|
||||
- **上游来源选择**:不用管理面模型注册表 `/api/models/`(会残留已下线、无路由绑定的 `gpt-6-astra`/`gpt-6-luna`),也不用 `/v1/models`(要求 Router 用户 Key,平台无服务级 Key,实测管理 token 返回 401)。
|
||||
- **失败关闭**:拉取失败、空列表、响应越界或写回失败都只记录 error,不生成替代目录;目录未初始化期间 AGC 相关接口与后台目录接口返回 `503`“模型目录未初始化”,不回退任何内置模型名。
|
||||
- **重试口径**:只在启动期尝试;失败后下一次启动重试,直到目录里有数据。启动不因目录未初始化而失败,避免 Router 短时不可用放大成 api-server 无法启动。
|
||||
- **存量数据**:旧结构目录(`id`/`alias`/`modelId`)按未初始化处理并从上游重建,owner 之前的启用与默认选择需要重新设置一次。
|
||||
- **客户端展示**:`GET /api/llm/models` 的 `id` 与 `displayName` 都是上游原始模型名;请求里的 `model` 就是该模型名,`platform-default` 或未提供时使用目录默认项,目录外模型返回 `422`。
|
||||
|
||||
@@ -9334,11 +9334,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
|
||||
- 背景:`agc_model_catalog` 缺行时由 procedure 兜底返回内置目录(`质量 → gpt-6-astra`、`快速 → gpt-5.6-luna`),而这两个模型已从上游 Router 移除;未配置目录的环境(新库、清库、后台从未保存过)会把不存在的模型下发给客户端,选中后上游 `model_not_found`,必须人工在后台保存一次才恢复。
|
||||
- 决策(目录内容):目录只描述“平台开放给 AGC 的模型白名单 + 启用状态 + 默认项”,每项只有上游原始模型名 `model` 与 `enabled`,目录级 `defaultModel` 必须指向启用项;退役稳定标识、别名与“实际模型名”二次映射,不设人工条数上限。
|
||||
- 决策(初始化):api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,用 Router 管理员 Token 调上游 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL}/models`,按 `data[].id` 顺序建目录(全部启用、默认项取首个),并以存量 revision 写回自增;并发启动的实例只有一个写入成功,其余接受既有目录。
|
||||
- 决策(失败关闭与重试):拉取失败、缺 Token、空列表、响应超过 1 MiB 上限或写回失败都只记录 error,不生成任何替代目录;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。未初始化期间 `/api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律 `503`“模型目录未初始化”。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 无法启动。
|
||||
- 决策(初始化):api-server(API/All 角色)启动时若目录缺失、结构与当前定义不符或校验不通过,请求上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(公开只读、不带凭据),按 `data[].model_name` 字典序建目录(全部启用、默认项取排序后第一个;上游返回顺序不稳定,排序保证同一模型集合重复同步结果一致),并以存量 revision 写回自增;并发启动的实例只有一个写入成功,其余接受既有目录。
|
||||
- 决策(上游来源,2026-09-24 实测后确定):真实 Router 上 `/v1/models` 用管理 Token 返回 401(它要 Router 用户 Key,平台无服务级 Key);管理面 `/api/models/` 是模型注册表,会带出 `endpoints` 为空、已下线无路由绑定的 `gpt-6-astra/gpt-6-luna/gpt-6-sol`;`/api/pricing?group=taonier` 才是该分组可见的在售集合(当日 6 个:`qwen3.8-flash`、`qwen-image-3.0`、`glm-5.3`、`glm-5.3-flash`、`deepseek-flash`、`deepseek-v4-pro`)。因此目录来源定为分组定价列表。
|
||||
- 决策(失败关闭与重试):拉取失败、空列表、响应超过 1 MiB 上限或写回失败都只记录 error,不生成任何替代目录;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。未初始化期间 `/api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律 `503`“模型目录未初始化”。启动本身不因同步失败而失败,避免 Router 短时不可用放大成 api-server 无法启动。
|
||||
- 决策(契约兼容):公开目录 DTO 形状不变,`id` 与 `displayName` 都是上游原始模型名,存量客户端无需发版即可显示原名;请求里的 `model` 就是目录中的模型名,`platform-default` 或未提供时使用目录默认项,目录外模型(含 `quality` 等历史稳定标识)返回 `422`。存量旧结构目录(`id`/`alias`/`modelId`)按未初始化处理并从上游重建,owner 之前的启用与默认选择需要重新设置一次。
|
||||
- 决策(上游不自动跟随):目录只在未初始化时重建;上游新增或移除模型由 owner 在后台「AGC 模型」页维护(页面已去掉别名列,只保留模型名、启用、默认与增删)。
|
||||
- 影响范围:`server-rs/crates/{module-runtime,spacetime-module,api-server,shared-contracts}`(目录领域模型、`read_agc_model_catalog` 缺行语义、启动期 `ensure_agc_model_catalog_initialized`、公开目录投影与请求侧解析、admin DTO)、`apps/admin-web`、`apps/ai-game-creator-shell`(首启模板与 Rust 默认模型改为 `platform-default` 占位)、平台/后端/运维三份 docs 与专题主规范。
|
||||
- 验证:`cargo check --locked -p api-server -p module-runtime -p spacetime-module -p shared-contracts --tests`;`cargo test -p module-runtime --lib agc_models::`(3 passed);`cargo test -p api-server --bin api-server agc`(30 passed / 1 ignored)与 `llm::`(18 passed);`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bins configuration::`(45 passed);两套 workspace 的 `cargo fmt -- --check`;`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`;admin-web 页面定向 Vitest 与 typecheck;AGC 客户端 `scripts/check-config.mjs` 与 `conversationModelSelect.test.tsx`。
|
||||
- 验证(真实栈 smoke,本地隔离 SpacetimeDB 与 loopback 假上游):空库 + 上游可达 → 启动日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=2`,`GET /api/llm/models` 返回 `smoke-model-alpha/beta`(id=displayName,revision=1);空库 + 上游不可达 → 启动 `ERROR` 打印未初始化原因,接口 `503`“模型目录未初始化”;同一库改用可达上游重启 → 同步成功(满足“下次启动重试直到有数据”);目录已存在时重启 → 不重写(revision 保持 1)。
|
||||
- 边界(未验证/残留):真实上游 Router 是否接受管理员 Token 调 `GET /v1/models`、以及其分组可见模型集合需要在真实环境确认;`OFFICIAL_LLM_ROUTER_MODEL = "gpt-6-astra"` 仍作为部署配置校验常量与 provisioning 元数据存在(已与目录解耦,建议单独评估是否移除);本地开发需要 `GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN` 才能自动同步目录,缺 Token 时会失败关闭而不是回退内置模型。
|
||||
- 验证(真实栈 smoke):本地隔离 SpacetimeDB + loopback 假上游(OpenAI 兼容 `/v1/models` 形态)阶段验证过空库自动填充、上游 401/空列表失败关闭、同库重启后同步成功、目录已存在时重启不重写(revision 保持 1)。改用分组定价列表口径后,在本地 dev DB `xushi-p4wfr` + **真实 Router** 上复验:清空 `agc_model_catalog` 后启动 api-server → 启动日志 `已按上游模型列表初始化 AGC 模型目录 revision=1 model_count=6`;目录写入 `{"revision":1,"defaultModel":"deepseek-flash","models":[deepseek-flash, deepseek-v4-pro, glm-5.3, glm-5.3-flash, qwen-image-3.0, qwen3.8-flash]}`;登录后 `GET /api/llm/models` 返回同样 6 个 `id`/`displayName` 与默认项 `deepseek-flash`(未带鉴权 401);上游不可达时(同一轮实测的上一版口径)启动只记录 error、接口 `503`“模型目录未初始化”,目录保持未初始化。
|
||||
- 边界(未验证/残留):分组定价列表当前含 `qwen-image-3.0`(图像模型),是否要在 AGC 下拉里隐藏由 owner 在后台停用;上游新增/移除模型不会自动跟随目录;`OFFICIAL_LLM_ROUTER_MODEL = "gpt-6-astra"` 仍作为部署配置校验常量与 provisioning 元数据存在(已与目录解耦,建议单独评估是否移除)。
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
|
||||
- 目录只描述“平台开放给 AGC 的模型白名单 + 启用状态 + 默认项”。每项只有上游原始模型名 `model` 与 `enabled`,不再有稳定标识、别名或“实际模型名”的二次映射;默认项由 `defaultModel` 指定,且必须指向启用项。目录规模不设人工条数上限,由上游列表与同步响应大小上限共同约束。
|
||||
- 目录事实源是私有 `agc_model_catalog` 单例表,使用 revision 乐观锁,多 api-server 实例共享同一事实。
|
||||
- api-server(API/All 角色)启动时检查目录:缺失、无法按当前结构解析、或校验不通过,三种情况都算“未初始化”。此时调用上游 `GET {LLM Router 地址}/models`(OpenAI 兼容列表,Bearer 使用与账号 provisioning 同一份 Router 管理员 Token),读取 `data[].id`,按上游返回顺序重建目录:每项 `enabled = true`,`defaultModel` 取第一个模型,并以存量 revision 写回(`revision` 自增)。并发启动的多个实例里只有一个写入成功,其余按冲突静默接受既有目录。
|
||||
- 同步失败(网络、鉴权、非 2xx、空列表、响应超过大小上限、缺少管理员 Token、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。
|
||||
- api-server(API/All 角色)启动时检查目录:缺失、无法按当前结构解析、或校验不通过,三种情况都算“未初始化”。此时调用上游 Router 控制面的分组定价列表 `GET {Router 控制面}/api/pricing?group=taonier`(控制面地址由 `{LLM Router 地址}` 去掉 `/v1` 得到;公开只读接口,不需要管理凭据),读取 `data[].model_name` 作为“该分组可见的在售模型”,按模型名字典序去重建目录(上游返回顺序每次可能不同,排序后目录只取决于模型集合):每项 `enabled = true`,`defaultModel` 取排序后第一个模型,并以存量 revision 写回(`revision` 自增)。不使用管理面模型注册表 `/api/models/`——它会残留已下线、没有路由绑定的条目(例如 `gpt-6-astra`/`gpt-6-luna`);也不使用 `/v1/models`——它要求 Router 用户 Key,平台没有服务级 Key。并发启动的多个实例里只有一个写入成功,其余按冲突静默接受既有目录。
|
||||
- 同步失败(网络、非 2xx、空列表、响应超过大小上限、地址非法、写回失败)只记录 error 日志,不写任何替代目录、不使用任何内置模型名;本次启动保持未初始化,下一次启动继续重试,直到目录里有数据。
|
||||
- 目录未初始化时 `GET /api/llm/models`、`/api/llm/responses`、`/api/llm/chat/completions` 与后台 `GET/PUT /admin/api/agc-models` 一律失败关闭(`503`),错误文案明确指向“模型目录未初始化”。恢复路径是修复上游可达性后重启,或由运维清空该行后再重启。
|
||||
- 存量旧结构目录(`id`/`alias`/`modelId`/`defaultModelId`)在部署后首次启动时按“未初始化”重建,owner 之前的启用与默认选择需要重新设置一次。
|
||||
- 上游变化不自动跟随:目录只在未初始化时重建;上游新增或移除模型时,由 owner 在后台增删条目或调整启用、默认项。
|
||||
|
||||
@@ -581,9 +581,9 @@ curl -fsS --max-time 5 http://127.0.0.1/api/editor/showcase/resources >/dev/null
|
||||
|
||||
### AGC 模型目录上游同步
|
||||
|
||||
`api-server`(API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时用 `GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN`(与 Router 账号 provisioning 同一份管理员 Token)请求 `{GENARRATIVE_LLM_ROUTER_BASE_URL}/models`,按返回的 `data[].id` 建目录并写回(revision 自增)。目录内容就是“上游原始模型名 + 启用状态 + 默认项”,客户端与后台都直接显示原名。
|
||||
`api-server`(API/All 角色)启动时检查 `agc_model_catalog`:缺失、结构与当前定义不符或校验不通过都算未初始化,此时请求上游 Router 控制面的分组定价列表 `GET {GENARRATIVE_LLM_ROUTER_BASE_URL 去掉 /v1}/api/pricing?group=taonier`(公开只读接口,不带凭据),按返回的 `data[].model_name` 排序去重建目录并写回(revision 自增,默认项为排序后第一个模型)。目录内容就是“上游在售模型名 + 启用状态 + 默认项”,客户端与后台都直接显示原名。不用管理面 `/api/models/`(会带出已下线、无路由绑定的模型),也不用 `/v1/models`(要 Router 用户 Key)。
|
||||
|
||||
没有这份 Token 或上游不可达时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…`,`GET /api/llm/models`、`/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;补齐配置后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。
|
||||
上游不可达、返回非 2xx、列表为空或响应超过 1 MiB 时,启动日志打印 `AGC 模型目录未初始化:本次启动未从上游同步到模型列表…`,`GET /api/llm/models`、`/api/llm/responses` 与后台 `GET/PUT /admin/api/agc-models` 返回 `503`,不返回任何内置模型;修好上游可达性后重启 `api-server` 即会重试成功。目录只在未初始化时重建,上游新增或移除模型由后台「AGC 模型」页维护,不会自动跟随。
|
||||
|
||||
### AGC 项目快照上传目标
|
||||
|
||||
|
||||
@@ -121,22 +121,19 @@ fn stored_catalog_revision(json: &str) -> Option<u64> {
|
||||
.map(|stored| stored.revision)
|
||||
}
|
||||
|
||||
/// 上游 OpenAI 兼容模型列表:`GET {LLM Router 地址}/models`,Bearer 为 Router 管理员 Token。
|
||||
/// 上游在售模型列表:`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_target_allowed(state)?;
|
||||
let token = state
|
||||
.config
|
||||
.llm_router_admin_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "缺少 LLM Router 管理员 Token,无法从上游同步 AGC 模型目录".to_string())?;
|
||||
let origin =
|
||||
crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?;
|
||||
let url = format!(
|
||||
"{}/models",
|
||||
state.config.llm_router_base_url.trim_end_matches('/')
|
||||
"{origin}/api/pricing?group={}",
|
||||
crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP
|
||||
);
|
||||
let url = reqwest::Url::parse(url.as_str())
|
||||
.map_err(|error| format!("LLM Router 模型列表地址无效:{error}"))?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
@@ -144,7 +141,6 @@ async fn fetch_upstream_model_names(state: &AppState) -> Result<Vec<String>, Str
|
||||
.map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("请求上游模型列表失败:{error}"))?;
|
||||
@@ -182,7 +178,7 @@ fn parse_upstream_model_names(payload: &serde_json::Value) -> Result<Vec<String>
|
||||
.ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?;
|
||||
let models = data
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str))
|
||||
.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()) {
|
||||
@@ -262,18 +258,18 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn upstream_model_names_come_from_openai_compatible_data_array() {
|
||||
fn upstream_model_names_come_from_pricing_data_array() {
|
||||
let payload = json!({
|
||||
"object": "list",
|
||||
"auto_groups": ["default"],
|
||||
"data": [
|
||||
{"id": "gpt-5.6-sol", "object": "model"},
|
||||
{"id": "gpt-5.6-terra", "object": "model"},
|
||||
{"object": "model"}
|
||||
{"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!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()]
|
||||
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -281,7 +277,7 @@ mod tests {
|
||||
"上游模型列表为空"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_upstream_model_names(&json!({"data": [{"id": " "}]})).unwrap_err(),
|
||||
parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(),
|
||||
"上游模型列表为空"
|
||||
);
|
||||
assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err());
|
||||
@@ -345,26 +341,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn model_list_state(base_url: &str, admin_token: Option<&str>) -> AppState {
|
||||
fn model_list_state(base_url: &str) -> AppState {
|
||||
AppState::new(crate::config::AppConfig {
|
||||
llm_router_base_url: base_url.to_string(),
|
||||
llm_router_admin_token: admin_token.map(str::to_string),
|
||||
..crate::config::AppConfig::default()
|
||||
})
|
||||
.expect("state should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_upstream_model_names_uses_admin_token_and_parses_openai_list() {
|
||||
async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() {
|
||||
let server = spawn_mock_model_list_server(
|
||||
"200 OK",
|
||||
&json!({"object": "list", "data": [{"id": "model-a"}, {"id": "model-b"}]}).to_string(),
|
||||
&json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]})
|
||||
.to_string(),
|
||||
);
|
||||
let state = model_list_state(&server.base_url, Some("admin-token"));
|
||||
let state = model_list_state(&server.base_url);
|
||||
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap(),
|
||||
vec!["model-a".to_string(), "model-b".to_string()]
|
||||
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
|
||||
);
|
||||
|
||||
let request = server
|
||||
@@ -373,37 +369,39 @@ mod tests {
|
||||
.expect("captured lock")
|
||||
.clone()
|
||||
.expect("mock server should capture request");
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}");
|
||||
// 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。
|
||||
assert!(
|
||||
request
|
||||
.lines()
|
||||
.any(|line| line.eq_ignore_ascii_case("authorization: Bearer admin-token")),
|
||||
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_without_token_or_models() {
|
||||
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, Some("admin-token"));
|
||||
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, Some("admin-token"));
|
||||
let state = model_list_state(&empty.base_url);
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap_err(),
|
||||
"上游模型列表为空"
|
||||
);
|
||||
|
||||
let state = model_list_state("http://127.0.0.1:1/v1", None);
|
||||
assert!(
|
||||
fetch_upstream_model_names(&state)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("管理员 Token")
|
||||
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| {
|
||||
|
||||
@@ -28,23 +28,25 @@ pub struct AgcModelCatalog {
|
||||
}
|
||||
|
||||
impl AgcModelCatalog {
|
||||
/// 按上游模型列表建目录:剔除空白项、去重保序、全部启用,默认项取首个模型。
|
||||
/// 按上游模型列表建目录:剔除空白项、按模型名字典序去重排序、全部启用,默认项取第一个。
|
||||
///
|
||||
/// 上游返回顺序不稳定(分组定价列表每次顺序都可能不同),排序后目录只取决于模型集合,
|
||||
/// 因此重复同步得到完全一致的目录与默认项。
|
||||
pub fn from_upstream_models(
|
||||
models: impl IntoIterator<Item = String>,
|
||||
revision: u64,
|
||||
) -> Result<Self, String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut entries = Vec::new();
|
||||
for model in models {
|
||||
let model = model.trim();
|
||||
if model.is_empty() || !seen.insert(model.to_string()) {
|
||||
continue;
|
||||
}
|
||||
entries.push(AgcModel {
|
||||
model: model.to_string(),
|
||||
let mut entries = models
|
||||
.into_iter()
|
||||
.map(|model| model.trim().to_string())
|
||||
.filter(|model| !model.is_empty())
|
||||
.map(|model| AgcModel {
|
||||
model,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by(|left, right| left.model.cmp(&right.model));
|
||||
entries.dedup_by(|left, right| left.model == right.model);
|
||||
let default_model = entries
|
||||
.first()
|
||||
.map(|entry| entry.model.clone())
|
||||
@@ -118,28 +120,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_builds_from_upstream_models_in_upstream_order() {
|
||||
fn catalog_builds_from_upstream_model_names_sorted_and_deduped() {
|
||||
let catalog = AgcModelCatalog::from_upstream_models(
|
||||
upstream(&[" gpt-5.6-sol ", "gpt-5.6-terra", "gpt-5.6-sol", ""]),
|
||||
upstream(&[" model-b ", "model-c", "model-a", "model-b", ""]),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(catalog.revision, 3);
|
||||
assert_eq!(catalog.default_model, "gpt-5.6-sol");
|
||||
// 默认项与顺序只取决于模型集合,与上游返回顺序无关。
|
||||
assert_eq!(catalog.default_model, "model-a");
|
||||
assert_eq!(
|
||||
catalog.models,
|
||||
vec![
|
||||
AgcModel {
|
||||
model: "gpt-5.6-sol".into(),
|
||||
model: "model-a".into(),
|
||||
enabled: true,
|
||||
},
|
||||
AgcModel {
|
||||
model: "gpt-5.6-terra".into(),
|
||||
model: "model-b".into(),
|
||||
enabled: true,
|
||||
},
|
||||
AgcModel {
|
||||
model: "model-c".into(),
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(catalog.validate().is_ok());
|
||||
assert_eq!(
|
||||
AgcModelCatalog::from_upstream_models(upstream(&["model-c", "model-b", "model-a"]), 3)
|
||||
.unwrap(),
|
||||
catalog
|
||||
);
|
||||
assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user