diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
index 41bf69552..bb88b9de6 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx
@@ -50,5 +50,7 @@ test('edits alias and upstream model without changing the stable identifier or r
models: [{ ...catalog.models[0], alias: '精细创作' }],
}),
);
- await screen.findByText('已保存');
+ await waitFor(() => {
+ expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
+ });
});
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
index a9affc116..2a3618fc1 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
@@ -1,4 +1,4 @@
-import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
+import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
@@ -74,10 +74,47 @@ export function AdminAgcModelsPage({
}
return (
-
-
- AGC 模型
+
+
+
AGC 模型
+
管理客户端可用模型与用户看到的名称
+
+
+ 版本 v{catalog?.revision ?? '-'}
+
+
+
+
+ 已启用
+
+ {catalog?.models.filter((model) => model.enabled).length ?? 0}
+
+
+
+ 默认模型
+
+ {catalog
+ ? (catalog.models.find(
+ (model) => model.id === catalog.defaultModelId,
+ )?.alias ?? '未设置')
+ : '未设置'}
+
+
+
+ 发布状态
+ {busy ? '处理中' : saved ? '已保存' : '待修改'}
+
+
+
+
+
+
模型目录
+ 客户端仅显示别名,实际模型名仅在这里维护
+
+
+
+
-
- {error ? {error}
: null}
- {saved ? 已保存
: null}
- {busy ? 正在处理
: null}
-
-
-
-
- | 别名 |
- 实际模型名 |
- 启用 |
- 默认 |
- 操作 |
-
-
-
- {catalog?.models.map((model, index) => (
-
- |
-
- update(model.id, { alias: e.target.value })
- }
- />
- |
-
-
- update(model.id, { modelId: e.target.value })
- }
- />
- |
-
-
- update(model.id, { enabled: e.target.checked })
- }
- />
- |
-
- {
- setSaved(false);
- setCatalog({ ...catalog, defaultModelId: model.id });
- }}
- />
- |
-
-
- |
+ {error ? {error}
: null}
+ {saved ? 已保存
: null}
+ {busy ? 正在处理
: null}
+
+
+
+
+ | 别名 |
+ 实际模型名 |
+ 启用 |
+ 默认 |
+ 操作 |
- ))}
-
-
-
+
+
+ {catalog?.models.map((model, index) => (
+
+ |
+
+ update(model.id, { alias: e.target.value })
+ }
+ />
+ |
+
+
+ update(model.id, { modelId: e.target.value })
+ }
+ />
+ |
+
+
+ update(model.id, { enabled: e.target.checked })
+ }
+ />
+ |
+
+ {
+ setSaved(false);
+ setCatalog({ ...catalog, defaultModelId: model.id });
+ }}
+ />
+ |
+
+
+ |
+
+ ))}
+
+
+
+
{confirmDialog}
);
diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css
index e500b7abd..61c02e4d6 100644
--- a/apps/admin-web/src/styles/admin.css
+++ b/apps/admin-web/src/styles/admin.css
@@ -3110,37 +3110,123 @@ button:disabled {
.admin-agc-models {
min-width: 0;
}
-.admin-agc-models > header {
- display: flex;
- align-items: center;
- justify-content: space-between;
+.admin-agc-models-revision {
+ padding: 6px 10px;
+ border: 1px solid #e7d9cc;
+ border-radius: 999px;
+ background: #fffaf6;
+ color: #9a8170;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+.admin-agc-models-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
-.admin-agc-models > header > div {
- display: flex;
- gap: 8px;
+.admin-agc-models-summary > div {
+ display: grid;
+ gap: 6px;
+ padding: 16px 18px;
+ border: 1px solid #eadfd6;
+ border-radius: 10px;
+ background: #fffdfa;
}
-.admin-agc-models button {
+.admin-agc-models-summary span,
+.admin-agc-models-panel > .admin-panel-heading span {
+ color: #9a8170;
+ font-size: 12px;
+}
+.admin-agc-models-summary strong {
+ overflow: hidden;
+ color: #3d2a20;
+ font-size: 20px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.admin-agc-models-panel {
+ border: 1px solid #eadfd6;
+ border-radius: 10px;
+ background: #fffdfa;
+ box-shadow: 0 10px 30px rgb(78 48 28 / 6%);
+}
+.admin-agc-models-panel > .admin-panel-heading > div {
+ display: grid;
+ gap: 4px;
+}
+.admin-agc-models-panel > .admin-panel-heading > svg {
+ color: #b9947a;
+}
+.admin-agc-models-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 4px 0 8px;
+}
+.admin-agc-models-toolbar button,
+.admin-agc-models-table-grid button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
- min-height: 32px;
+ min-height: 34px;
+ padding: 0 11px;
+ border: 1px solid #e2d3c7;
+ border-radius: 8px;
+ background: #fffaf6;
+ color: #684d3d;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
}
-.admin-agc-models-table {
- overflow-x: auto;
+.admin-agc-models-toolbar button:hover,
+.admin-agc-models-toolbar button:focus-visible,
+.admin-agc-models-table-grid button:hover,
+.admin-agc-models-table-grid button:focus-visible {
+ border-color: #c99d80;
+ background: #fff;
+ outline: none;
}
-.admin-agc-models table {
- width: 100%;
- border-collapse: collapse;
-}
-.admin-agc-models th,
-.admin-agc-models td {
- padding: 8px;
- text-align: left;
-}
-.admin-agc-models td input:not([type]) {
- min-width: 150px;
+.admin-agc-models-toolbar button:last-child {
+ border-color: #a96442;
+ background: #a96442;
+ color: #fff;
+}
+.admin-agc-models-table-grid {
+ min-width: 720px;
+}
+.admin-agc-models-table-grid th {
+ padding-top: 12px;
+ padding-bottom: 12px;
+ background: #fcf7f2;
+}
+.admin-agc-models-table-grid td {
+ padding-top: 14px;
+ padding-bottom: 14px;
+}
+.admin-agc-models-table-grid td input:not([type]) {
width: 100%;
+ min-width: 180px;
box-sizing: border-box;
+ padding: 9px 10px;
+ border: 1px solid #e1d3c8;
+ border-radius: 7px;
+ background: #fff;
+ color: #3d2a20;
+}
+.admin-agc-models-table-grid td input:not([type]):focus-visible {
+ border-color: #b97854;
+ outline: none;
+ box-shadow: 0 0 0 3px rgb(185 120 84 / 14%);
+}
+.admin-agc-models-table-grid td:has(input[type='checkbox']),
+.admin-agc-models-table-grid td:has(input[type='radio']) {
+ width: 72px;
+ text-align: center;
+ vertical-align: middle;
+}
+@media (max-width: 680px) {
+ .admin-agc-models-summary {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
index 60ed9f736..92d10d2a2 100644
--- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
+++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx
@@ -1,4 +1,4 @@
-import { RefreshCcw } from 'lucide-react';
+import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
@@ -19,6 +19,7 @@ export function ConversationModelSelect({
const [selected, setSelected] = useState('');
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
+ const [open, setOpen] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError('');
@@ -81,30 +82,61 @@ export function ConversationModelSelect({
return (
{error ?
{error} : null}
-
+ {open ? (
+
+ {models.map((model) => (
+
+ ))}
+
+
+
+ ) : null}
);
}
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index 1e354340d..02de332a1 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -8397,32 +8397,201 @@ iframe.preview-frame {
max-width: calc(100% - 64px);
pointer-events: auto;
}
-.project-supervisor-surface.is-direct-codex
- .project-supervisor-composer
- .conversation-model-select
- select {
+.project-supervisor-surface.is-direct-codex .conversation-model-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
min-width: 0;
- max-width: 160px;
- width: 130px;
+ max-width: 180px;
height: 30px;
padding: 0 6px;
- border: 1px solid var(--platform-surface-border, #d1d5db);
- border-radius: 6px;
- background: var(--platform-input-fill, #fff);
- color: var(--platform-text-strong, #171717);
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--platform-text-soft, #6b7280);
font: inherit;
font-size: 12px;
+ cursor: pointer;
+}
+.conversation-model-trigger:hover,
+.conversation-model-trigger:focus-visible,
+.conversation-model-trigger[aria-expanded='true'] {
+ background: color-mix(
+ in srgb,
+ var(--platform-button-ghost-fill, #f3f4f6) 80%,
+ transparent
+ );
+ color: var(--platform-text-strong, #30343b);
+ outline: none;
+}
+.conversation-model-trigger-status {
+ width: 7px;
+ height: 7px;
+ flex: 0 0 auto;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ opacity: 0.65;
+}
+.conversation-model-trigger-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.conversation-model-menu {
+ position: absolute;
+ right: 46px;
+ bottom: 43px;
+ z-index: 20;
+ display: grid;
+ min-width: 150px;
+ max-width: 190px;
+ padding: 5px;
+ border: 1px solid var(--platform-surface-border, #e5e7eb);
+ border-radius: 10px;
+ background: var(--platform-input-fill, #fff) !important;
+ box-shadow:
+ 0 14px 30px rgb(31 41 55 / 13%),
+ 0 2px 6px rgb(31 41 55 / 5%);
+ pointer-events: auto;
+}
+.conversation-model-menu button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ min-width: 0;
+ min-height: 32px;
+ padding: 0 9px;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--platform-text-base, #374151);
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ cursor: pointer;
+}
+.conversation-model-menu button:hover,
+.conversation-model-menu button:focus-visible,
+.conversation-model-menu button[aria-selected='true'] {
+ background: var(--platform-button-ghost-fill, #f3f4f6);
+ color: var(--platform-text-strong, #111827);
+ outline: none;
+}
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-menu
+ button {
+ position: static !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ width: 100% !important;
+ min-width: 0 !important;
+ min-height: 32px !important;
+ height: 32px !important;
+ margin: 0 !important;
+ padding: 0 9px !important;
+ border: 0 !important;
+ border-radius: 6px !important;
+ background: transparent !important;
+ color: var(--platform-text-base, #374151) !important;
+ box-shadow: none !important;
+ font-size: 12px !important;
+ font-weight: 500 !important;
+ white-space: nowrap !important;
+}
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-menu
+ button:hover,
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-menu
+ button:focus-visible,
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-menu
+ button[aria-selected='true'] {
+ background: #f3f1ee !important;
+ color: var(--platform-text-strong, #1f2937) !important;
+ outline: none !important;
+}
+.conversation-model-menu-divider {
+ height: 1px;
+ margin: 4px 5px;
+ background: var(--platform-line-soft, #ece9e5);
+}
+.conversation-model-menu-refresh {
+ justify-content: flex-start !important;
+ gap: 7px !important;
+ color: var(--platform-text-soft, #6b7280) !important;
+}
+.conversation-model-menu-refresh:hover,
+.conversation-model-menu-refresh:focus-visible {
+ color: var(--platform-text-strong, #1f2937) !important;
+}
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-trigger {
+ position: static !important;
+ display: inline-flex !important;
+ width: auto !important;
+ min-width: 0 !important;
+ min-height: 30px !important;
+ height: 30px !important;
+ padding: 0 7px !important;
+ border: 0 !important;
+ border-radius: 7px !important;
+ background: transparent !important;
+ color: var(--platform-text-soft, #6b7280) !important;
+ box-shadow: none !important;
+ font-size: 12px !important;
+ font-weight: 500 !important;
+}
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-trigger:hover,
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-trigger:focus-visible,
+.game-workbench-chat
+ .project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-select
+ .conversation-model-trigger[aria-expanded='true'] {
+ background: var(--platform-button-ghost-fill, #f3f4f6) !important;
+ color: var(--platform-text-strong, #30343b) !important;
+}
+.project-supervisor-surface.is-direct-codex
+ .project-supervisor-composer
+ .conversation-model-trigger {
+ min-width: 0;
+ max-width: 180px;
+ height: 30px;
}
.project-supervisor-surface.is-direct-codex
.project-supervisor-composer
.conversation-model-select
+ .conversation-model-menu
button {
position: static;
- width: 28px;
- min-width: 28px;
- min-height: 28px;
- height: 28px;
- padding: 0;
+ width: auto;
+ min-height: 32px;
}
.conversation-model-select [role='alert'] {
position: absolute;
diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
index aeaf794bd..fd7e49b36 100644
--- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
+++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx
@@ -38,20 +38,19 @@ afterEach(cleanup);
test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn();
render();
- await screen.findByRole('option', { name: '高质量' });
+ await screen.findByRole('button', { name: '对话模型' });
expect(screen.queryByText('gpt-6-astra')).toBeNull();
- fireEvent.change(screen.getByRole('combobox', { name: '对话模型' }), {
- target: { value: 'fast' },
- });
+ fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
+ fireEvent.click(screen.getByRole('option', { name: '快速' }));
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
modelId: 'fast',
}),
);
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
- expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
- 'fast',
- );
+ expect(
+ screen.getByRole('button', { name: '对话模型' }).textContent,
+ ).toContain('快速');
});
test('does not mark a removed selection ready or expose the old identifier', async () => {
@@ -71,6 +70,7 @@ test('failed catalog can be refreshed without enabling submission', async () =>
render();
await screen.findByText('模型列表加载失败');
expect(onReady).toHaveBeenLastCalledWith(false);
+ fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
});
@@ -82,8 +82,9 @@ test('a failed save keeps submission unavailable', async () => {
});
const onReady = vi.fn();
render();
- await screen.findByRole('option', { name: '快速' });
- fireEvent.change(screen.getByRole('combobox'), { target: { value: 'fast' } });
+ await screen.findByRole('button', { name: '对话模型' });
+ fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
+ fireEvent.click(screen.getByRole('option', { name: '快速' }));
await screen.findByText('模型选择保存失败');
expect(onReady).toHaveBeenLastCalledWith(false);
});
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index b24eccb48..763d0eca0 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -4983,9 +4983,11 @@
- 原因:Tauri Windows bundler 执行自己的 `\NSIS\makensis.exe`,默认位于当前用户 `%LOCALAPPDATA%\tauri`,不使用 PATH 中预装的 `makensis.exe`;Jenkins LocalSystem/systemprofile 的 AppData 可能无法启动该缓存程序。
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
+
## AGC 前端等待超时与 worker 端口冲突
- `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。
- AGC Vite 在配套后端全部就绪后才启动。Tauri 的前端等待超时及随后的 `code=143` 可能是 worker 先失败导致的连带退出,应先检查 `.app/dev-stack.json` 各服务状态和监听进程,不能直接归因于 Vite 或数据库。
- 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
- CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version` 和 `spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
+- Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index c1d3e12b3..69b9c9325 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -964,3 +964,4 @@ node scripts/rebind-orphan-work-owners.mjs --in --out
## 维护页目标文件安全边界(2026-08-05)
`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。`page_temp` 与 `marker_temp` 必须在 `set -u` 下安全初始化,清理 trap 必须在首次 `mktemp` 前生效;任一失败退出都不得在目标同级遗留 `page.html.tmp.*` 或 `enabled.tmp.*`,成功替换后则清空临时路径并解除 trap,不能误删已安装目标。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh` 与 `npm run check:maintenance-page`。
+api-server 启动时会硬校验 AGC 官方 Router 配置:API/All 角色必须使用官方 HTTPS 地址和固定模型,并配置 provisioning secret、Router 管理员 Token,以及专用加密 secret 或有效 JWT secret;缺失或不匹配直接拒绝启动。`test` 环境仅允许 loopback fixture,worker-only 角色不执行 Router 配置校验。
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index 1205fe61a..f40e6309e 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -153,6 +153,7 @@ fn main() -> Result<(), io::Error> {
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?;
+ validate_llm_router_config_for_startup(&config).map_err(io::Error::other)?;
init_tracing(
&config.log_filter,
OtelConfig {
@@ -174,6 +175,69 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
run_http_role(config).await
}
+fn validate_llm_router_config_for_startup(config: &AppConfig) -> Result<(), String> {
+ if !matches!(config.process_role, ProcessRole::Api | ProcessRole::All) {
+ return Ok(());
+ }
+ let base_url = config.llm_router_base_url.trim_end_matches('/');
+ let url =
+ reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?;
+ let host = url
+ .host_str()
+ .ok_or_else(|| "LLM Router 地址缺少主机名".to_string())?;
+ let is_loopback = host.eq_ignore_ascii_case("localhost")
+ || host
+ .parse::()
+ .is_ok_and(|address| address.is_loopback());
+
+ if config.is_test_environment() {
+ if !is_loopback || !matches!(url.scheme(), "http" | "https") {
+ return Err("test 环境的 LLM Router 必须是 HTTP/HTTPS loopback 地址".to_string());
+ }
+ return Ok(());
+ }
+
+ if base_url != OFFICIAL_LLM_ROUTER_BASE_URL {
+ return Err(format!(
+ "LLM Router 必须使用官方固定地址 {OFFICIAL_LLM_ROUTER_BASE_URL}"
+ ));
+ }
+ if config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
+ return Err(format!(
+ "LLM Router 必须使用官方固定模型 {OFFICIAL_LLM_ROUTER_MODEL}"
+ ));
+ }
+ if url.scheme() != "https" {
+ return Err("官方 LLM Router 必须使用 HTTPS".to_string());
+ }
+ if config
+ .llm_router_provisioning_secret
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ {
+ return Err(
+ "缺少 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET 或对应 secret file".to_string(),
+ );
+ }
+ if config
+ .llm_router_admin_token
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ {
+ return Err("缺少 GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN 或对应 secret file".to_string());
+ }
+ if config
+ .effective_llm_router_api_key_encryption_secret()
+ .is_none()
+ {
+ return Err(
+ "缺少 GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET,且无法从 JWT secret 派生"
+ .to_string(),
+ );
+ }
+ Ok(())
+}
+
fn log_llm_router_startup_warnings(config: &AppConfig) {
let base_url = config.llm_router_base_url.trim_end_matches('/');
let is_loopback = reqwest::Url::parse(base_url)
@@ -778,7 +842,7 @@ mod tests {
should_initialize_editor_generation_pricing_for_startup,
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
should_validate_bgfilter_internal_token_for_startup, strip_env_value,
- validate_bgfilter_internal_token_for_startup,
+ validate_bgfilter_internal_token_for_startup, validate_llm_router_config_for_startup,
};
use crate::config::{AppConfig, ProcessRole};
@@ -898,6 +962,45 @@ mod tests {
assert!(validate_bgfilter_internal_token_for_startup(&missing_config).is_ok());
}
+ #[test]
+ fn llm_router_config_startup_validation_requires_all_production_inputs() {
+ let mut config = AppConfig::default();
+ config.process_role = ProcessRole::Api;
+ config.environment = "production".to_string();
+ assert!(
+ validate_llm_router_config_for_startup(&config)
+ .unwrap_err()
+ .contains("PROVISIONING_SECRET")
+ );
+
+ config.llm_router_provisioning_secret = Some("provisioning".to_string());
+ assert!(
+ validate_llm_router_config_for_startup(&config)
+ .unwrap_err()
+ .contains("ADMIN_TOKEN")
+ );
+
+ config.llm_router_admin_token = Some("admin".to_string());
+ config.jwt_secret = "jwt-secret".to_string();
+ assert!(validate_llm_router_config_for_startup(&config).is_ok());
+ }
+
+ #[test]
+ fn llm_router_config_startup_validation_allows_test_loopback_fixture() {
+ let mut config = AppConfig::default();
+ config.process_role = ProcessRole::Api;
+ config.environment = "test".to_string();
+ config.llm_router_base_url = "http://127.0.0.1:43125/v1".to_string();
+ assert!(validate_llm_router_config_for_startup(&config).is_ok());
+ }
+
+ #[test]
+ fn llm_router_config_startup_validation_skips_non_api_roles() {
+ let mut config = AppConfig::default();
+ config.process_role = ProcessRole::BgfilterWorker;
+ assert!(validate_llm_router_config_for_startup(&config).is_ok());
+ }
+
#[test]
fn auth_store_startup_restore_is_limited_to_http_roles() {
assert!(should_restore_auth_store_for_startup(ProcessRole::Api));