diff --git a/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md index 8f0f3382e..f2b70c79e 100644 --- a/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md +++ b/docs/project-memory/plans/【实施计划】AGC模型目录上游同步-2026-09-24.md @@ -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` 断言跟随。 diff --git a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md index c9955d695..a4a67ee09 100644 --- a/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】AGC模型目录上游同步-2026-09-24.md @@ -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`。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e8bd12241..497191b6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 元数据存在(已与目录解耦,建议单独评估是否移除)。 diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md index 57f234515..c6b9eceb8 100644 --- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md +++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md @@ -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 在后台增删条目或调整启用、默认项。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index b348eaf98..683996671 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -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 项目快照上传目标 diff --git a/server-rs/crates/api-server/src/agc_models.rs b/server-rs/crates/api-server/src/agc_models.rs index 8acf73b5e..a77766917 100644 --- a/server-rs/crates/api-server/src/agc_models.rs +++ b/server-rs/crates/api-server/src/agc_models.rs @@ -121,22 +121,19 @@ fn stored_catalog_revision(json: &str) -> Option { .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, 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, 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 .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::>(); 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" ); } } diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 6e4bc5d8e..f57fb9445 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -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 { +pub(crate) fn router_control_origin(base_url: &str) -> Result { 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| { diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs index faf9802e1..616fed6b6 100644 --- a/server-rs/crates/module-runtime/src/agc_models.rs +++ b/server-rs/crates/module-runtime/src/agc_models.rs @@ -28,23 +28,25 @@ pub struct AgcModelCatalog { } impl AgcModelCatalog { - /// 按上游模型列表建目录:剔除空白项、去重保序、全部启用,默认项取首个模型。 + /// 按上游模型列表建目录:剔除空白项、按模型名字典序去重排序、全部启用,默认项取第一个。 + /// + /// 上游返回顺序不稳定(分组定价列表每次顺序都可能不同),排序后目录只取决于模型集合, + /// 因此重复同步得到完全一致的目录与默认项。 pub fn from_upstream_models( models: impl IntoIterator, revision: u64, ) -> Result { - 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::>(); + 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()); }