AGC 官方 LLM Router 账号链路与流式联网输出 #242

Merged
kdletters merged 59 commits from feat/agc-llm-router-official-chain into master 2026-09-06 16:49:13 +08:00
9 changed files with 594 additions and 161 deletions
Showing only changes of commit 34e3f70409 - Show all commits
@@ -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);
});
});
+130 -93
View File
@@ -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 (
<section className="admin-agc-models">
<header>
<h2>AGC </h2>
<section className="admin-page admin-page-wide admin-agc-models">
<div className="admin-page-heading">
<div>
<h2>AGC </h2>
<p></p>
</div>
<span className="admin-agc-models-revision">
v{catalog?.revision ?? '-'}
</span>
</div>
<div className="admin-agc-models-summary">
<div>
<span></span>
<strong>
{catalog?.models.filter((model) => model.enabled).length ?? 0}
</strong>
</div>
<div>
<span></span>
<strong>
{catalog
? (catalog.models.find(
(model) => model.id === catalog.defaultModelId,
)?.alias ?? '未设置')
: '未设置'}
</strong>
</div>
<div>
<span></span>
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
</div>
</div>
<section className="admin-panel admin-agc-models-panel">
<div className="admin-panel-heading">
<div>
<h3></h3>
<span></span>
</div>
<CircleHelp size={17} aria-label="模型目录帮助" />
</div>
<div className="admin-agc-models-toolbar">
<button
type="button"
title="重新读取"
@@ -122,96 +159,96 @@ export function AdminAgcModelsPage({
</button>
</div>
</header>
{error ? <p role="alert">{error}</p> : null}
{saved ? <p role="status"></p> : null}
{busy ? <p role="status"></p> : null}
<div className="admin-agc-models-table">
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{catalog?.models.map((model, index) => (
<tr key={model.id}>
<td>
<input
aria-label={`模型 ${index + 1} 别名`}
maxLength={40}
required
value={model.alias}
disabled={busy}
onChange={(e) =>
update(model.id, { alias: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 实际模型名`}
maxLength={200}
required
value={model.modelId}
disabled={busy}
onChange={(e) =>
update(model.id, { modelId: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
type="checkbox"
checked={model.enabled}
disabled={busy || model.id === catalog.defaultModelId}
onChange={(e) =>
update(model.id, { enabled: e.target.checked })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 默认`}
name="agc-default-model"
type="radio"
checked={model.id === catalog.defaultModelId}
disabled={busy || !model.enabled}
onChange={() => {
setSaved(false);
setCatalog({ ...catalog, defaultModelId: model.id });
}}
/>
</td>
<td>
<button
type="button"
title="删除模型"
aria-label={`删除模型 ${index + 1}`}
disabled={busy || model.id === catalog.defaultModelId}
onClick={() => {
setSaved(false);
setCatalog({
...catalog,
models: catalog.models.filter(
(candidate) => candidate.id !== model.id,
),
});
}}
>
<Trash2 size={16} />
</button>
</td>
{error ? <p role="alert">{error}</p> : null}
{saved ? <p role="status"></p> : null}
{busy ? <p role="status"></p> : null}
<div className="admin-table-wrap admin-agc-models-table">
<table className="admin-table admin-agc-models-table-grid">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{catalog?.models.map((model, index) => (
<tr key={model.id}>
<td>
<input
aria-label={`模型 ${index + 1} 别名`}
maxLength={40}
required
value={model.alias}
disabled={busy}
onChange={(e) =>
update(model.id, { alias: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 实际模型名`}
maxLength={200}
required
value={model.modelId}
disabled={busy}
onChange={(e) =>
update(model.id, { modelId: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
type="checkbox"
checked={model.enabled}
disabled={busy || model.id === catalog.defaultModelId}
onChange={(e) =>
update(model.id, { enabled: e.target.checked })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 默认`}
name="agc-default-model"
type="radio"
checked={model.id === catalog.defaultModelId}
disabled={busy || !model.enabled}
onChange={() => {
setSaved(false);
setCatalog({ ...catalog, defaultModelId: model.id });
}}
/>
</td>
<td>
<button
type="button"
title="删除模型"
aria-label={`删除模型 ${index + 1}`}
disabled={busy || model.id === catalog.defaultModelId}
onClick={() => {
setSaved(false);
setCatalog({
...catalog,
models: catalog.models.filter(
(candidate) => candidate.id !== model.id,
),
});
}}
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{confirmDialog}
</section>
);
+108 -22
View File
@@ -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;
}
}
@@ -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 (
<div className="conversation-model-select">
{error ? <span role="alert">{error}</span> : null}
<select
aria-label="对话模型"
disabled={disabled || busy || models.length === 0}
value={models.some((model) => model.id === selected) ? selected : ''}
onChange={(event) => void select(event.currentTarget.value)}
>
<option value="" disabled>
{busy ? '正在读取模型' : '选择模型'}
</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.displayName}
</option>
))}
</select>
<button
type="button"
aria-label="刷新模型列表"
title="刷新模型列表"
className="conversation-model-trigger"
aria-label="对话模型"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || busy}
onClick={() => void refresh()}
onClick={() => setOpen((current) => !current)}
>
<RefreshCcw size={14} />
<span
className="conversation-model-trigger-status"
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">
{models.find((model) => model.id === selected)?.displayName ??
(busy ? '正在读取模型' : '选择模型')}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
{open ? (
<div
className="conversation-model-menu"
role="listbox"
aria-label="对话模型"
>
{models.map((model) => (
<button
key={model.id}
type="button"
role="option"
aria-selected={model.id === selected}
onClick={() => {
setOpen(false);
void select(model.id);
}}
>
<span>{model.displayName}</span>
{model.id === selected ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
<div className="conversation-model-menu-divider" />
<button
type="button"
className="conversation-model-menu-refresh"
aria-label="刷新模型列表"
disabled={disabled || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={13} aria-hidden="true" />
<span></span>
</button>
</div>
) : null}
</div>
);
}
+184 -15
View File
@@ -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;
@@ -38,20 +38,19 @@ afterEach(cleanup);
test('only displays aliases and persists selection through the native command', async () => {
const onReady = vi.fn();
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
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(<ConversationModelSelect disabled={false} onReady={onReady} />);
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(<ConversationModelSelect disabled={false} onReady={onReady} />);
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);
});
@@ -4983,9 +4983,11 @@
- 原因:Tauri Windows bundler 执行自己的 `<tauri_tools_path>\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/模型调用才延迟失败。
@@ -964,3 +964,4 @@ node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --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 fixtureworker-only 角色不执行 Router 配置校验。
+104 -1
View File
@@ -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::<std::net::IpAddr>()
.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));