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) => ( - - - - - - + {error ?

{error}

: null} + {saved ?

已保存

: null} + {busy ?

正在处理

: null} +
+
别名实际模型名启用默认操作
- - 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 }); - }} - /> - - -
+ + + + + + + - ))} - -
别名实际模型名启用默认操作
-
+ + + {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));