diff --git a/frontend/web/src/admin/modules/Llm.tsx b/frontend/web/src/admin/modules/Llm.tsx index 1d494b1..2534743 100644 --- a/frontend/web/src/admin/modules/Llm.tsx +++ b/frontend/web/src/admin/modules/Llm.tsx @@ -10,7 +10,7 @@ import { CatalogPanel } from "./llm/LlmCatalogPanel"; import { LlmProviderForm } from "./llm/LlmProviderForm"; import type { CatalogHint } from "./llm/LlmProviderRouting"; -/** 一个供应商分组行:身份 + token 预算 + 推理形状 + 路由 + 测试/编辑动作。 */ +/** 一个供应商分组行:身份 + token 预算 + 上下文窗口 + 推理形状 + 路由 + 测试/编辑动作。 */ function ProviderRow({ p, busy, @@ -31,6 +31,9 @@ function ProviderRow({ {p.maxTokens ? `maxTokens ${p.maxTokens}` : "maxTokens 默认"} + + {p.contextWindow ? `窗口 ${p.contextWindow}` : "窗口未声明"} + {`推理 ${p.reasoning?.kind || "未声明"}`} @@ -259,7 +262,7 @@ export function LlmModule(): React.ReactNode { - 每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。 + 每组的 reasoning.kind(推理开关形状)按端点选:形状属于端点而不属于模型,选错会 400,因此面板只给出「未声明 / auto」与后端下发的几种形状。按角色路由(persona → 模型 / 协议)与降级链由后端 loop 真接线;面板保存即写入 models.json 并热更新运行中的 LLM provider(无需重启,design/04 §3.1),.env 为兜底。maxTokens 留空 = 保持原值 / 未设时回落 LLM_FALLBACK_MAX_TOKENS。contextWindow(模型上下文窗口,token)留空 = 未声明:后端按 128 Ki token 保守估算并记一条 WARN,填该模型的真实窗口才能吃到按窗口推导的上下文预算(该推导只在启动时算一次 ⇒ 重启后生效)。multimodal 只表示主模型自己能否收图;图片/OCR 工具用的模型走 .env 的 LLM_VISION_MODEL / LLM_OCR_MODEL。目录候选来自 models.dev,由后端缓存成快照(离线仍可用),绝不进模型运行时热路径。 ); diff --git a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx index d7f975d..5985f45 100644 --- a/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx +++ b/frontend/web/src/admin/modules/llm/LlmCatalogPanel.tsx @@ -158,8 +158,9 @@ function protocolFromCatalog(p: CatalogProvider): string { } /** 目录候选 → 表单草稿 + 能力建议。分组 id 取目录 id,重复填入即幂等更新同名 - * 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 建议值取 - * 模型的输出上限(目录真值,没有任何猜测成分)——两者都只是预填,用户可推翻。 */ + * 分组;multimodal 只在目录**明确标注**接受图片输入时预填,maxTokens 与 + * contextWindow 的建议值取目录的**输出上限 / 上下文窗口**(目录真值,没有任何 + * 猜测成分)——都只是预填,用户可推翻。 */ function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLlmProvider; hint: CatalogHint } { return { draft: { @@ -171,6 +172,7 @@ function draftFromCatalog(p: CatalogProvider, m: CatalogModel): { draft: AdminLl multimodal: m.attachment === true, enabled: false, maxTokens: m.outputLimit || undefined, + contextWindow: m.contextLimit || undefined, }, hint: { provider: p.id, @@ -199,6 +201,7 @@ function CatalogModelRow({ {model.reasoning === true ? 推理 : null} {model.toolCall ? 工具 : null} {model.outputLimit ? {`输出上限 ${model.outputLimit}`} : null} + {model.contextLimit ? {`上下文窗口 ${model.contextLimit}`} : null} {provider.api ? null : 目录未标 baseURL} diff --git a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx index 667fee6..c265b88 100644 --- a/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx +++ b/frontend/web/src/admin/modules/llm/LlmProviderForm.tsx @@ -4,6 +4,8 @@ import type { AdminLlmProvider, LlmProviderRequest } from "../../../lib/types"; import { useAuditStore } from "../../audit"; import { Button, Surface } from "../../../components/primitives"; import { + ApiKeyAndBudgetField, + ContextWindowField, MultimodalField, PersonaRoutesEditor, ReasoningField, @@ -16,7 +18,8 @@ import type { CatalogHint, PersonaRouteDraft } from "./LlmProviderRouting"; /** Add/edit form for one LLM provider group. The group list hands in an * `initial` group to edit (remounted via key) or null to add a new one. The * backend merges the request over the stored group, so blank optional fields - * (apiKey / maxTokens) are simply omitted and keep their stored value. */ + * (apiKey / maxTokens / contextWindow) are simply omitted and keep their + * stored value. */ interface FormState { name: string; @@ -26,6 +29,8 @@ interface FormState { apiKey: string; multimodal: boolean; maxTokens: string; + /** 模型上下文窗口(token);空串 = 未声明(不提交该键,保留已存窗口)。 */ + contextWindow: string; /** 推理开关形状;空串 = 未声明(等同 auto)。 */ reasoning: string; /** per-persona 路由行(persona → 模型 / 协议覆盖)。 */ @@ -43,11 +48,59 @@ function initState(initial: AdminLlmProvider | null): FormState { apiKey: "", multimodal: initial?.multimodal ?? false, maxTokens: initial?.maxTokens ? String(initial.maxTokens) : "", + contextWindow: initial?.contextWindow ? String(initial.contextWindow) : "", reasoning: initial?.reasoning?.kind ?? "", personaRoutes: initPersonaRoutes(initial), }; } +/** 把表单状态变成一个 provider 保存请求 —— 模块级纯函数,因此「保存」这条路径的 + * 语义不依赖组件内部状态。提交前的本地校验(两个数字字段的非负整数、按角色路由 + * 的行)都在这里发生:不通过就通过 onError 报红字并返回 null(一个请求都不发)。 */ +function buildRequest( + form: FormState, + editing: boolean, + initial: AdminLlmProvider | null, + onError: (msg: string | null) => void, +): LlmProviderRequest | null { + const budget = form.maxTokens.trim(); + if (budget !== "" && !/^\d+$/.test(budget)) { + onError("maxTokens 必须为非负整数(留空 = 保持/默认)"); + return null; + } + const window = form.contextWindow.trim(); + if (window !== "" && !/^\d+$/.test(window)) { + onError("contextWindow 必须为非负整数(留空 = 未声明,按 128 Ki token 保守估算)"); + return null; + } + const problem = personaRouteProblem(form.personaRoutes); + if (problem) { + onError(problem); + return null; + } + const { personaModels, personaProviders } = routesToMaps(form.personaRoutes); + const req: LlmProviderRequest = { + provider: form.provider, + baseUrl: form.baseUrl, + model: form.model, + multimodal: form.multimodal, + // reasoning 整块给出:kind 空串 = 显式回到「未声明」,否则面板无法把 + // 已存形状改回去(缺省字段意味着「保留原值」)。 + reasoning: { kind: form.reasoning }, + // 两张路由表整表提交:面板删掉一行后要真的能从存储里删掉。 + personaModels, + personaProviders, + }; + if (editing && initial) req.id = initial.id; + else if (form.name.trim()) req.id = form.name.trim(); + if (form.apiKey.trim()) req.apiKey = form.apiKey.trim(); + if (budget !== "") req.maxTokens = Number(budget); + // 空串 = 不提交该键(缺省 = 保留已存窗口);显式 `0` 与省略同义(都表示 + // 未声明),因此面板只在此处如实转发用户真的填了的那个数。 + if (window !== "") req.contextWindow = Number(window); + return req; +} + /** The form fields; state lives in the form component below. */ function ProviderFields({ form, @@ -99,24 +152,14 @@ function ProviderFields({ set("baseUrl", e.target.value)} placeholder="https://api.example.com/v1" className={inputCls} /> - - - - API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"}) - - set("apiKey", e.target.value)} placeholder="sk-…" className={inputCls} /> - - - maxTokens(该组预算,留空 = 保持/默认) - set("maxTokens", e.target.value)} - inputMode="numeric" - placeholder="8192" - className={inputCls} - /> - - + set("apiKey", v)} + onMaxTokens={(v) => set("maxTokens", v)} + /> + set("contextWindow", v)} /> set("multimodal", v)} /> s.log); const set: FieldSetter = (key, value) => setForm((prev) => ({ ...prev, [key]: value })); - const buildRequest = (): LlmProviderRequest | null => { - const budget = form.maxTokens.trim(); - if (budget !== "" && !/^\d+$/.test(budget)) { - onError("maxTokens 必须为非负整数(留空 = 保持/默认)"); - return null; - } - const problem = personaRouteProblem(form.personaRoutes); - if (problem) { - onError(problem); - return null; - } - const { personaModels, personaProviders } = routesToMaps(form.personaRoutes); - const req: LlmProviderRequest = { - provider: form.provider, - baseUrl: form.baseUrl, - model: form.model, - multimodal: form.multimodal, - // reasoning 整块给出:kind 空串 = 显式回到「未声明」,否则面板无法把 - // 已存形状改回去(缺省字段意味着「保留原值」)。 - reasoning: { kind: form.reasoning }, - // 两张路由表整表提交:面板删掉一行后要真的能从存储里删掉。 - personaModels, - personaProviders, - }; - if (editing && initial) req.id = initial.id; - else if (form.name.trim()) req.id = form.name.trim(); - if (form.apiKey.trim()) req.apiKey = form.apiKey.trim(); - if (budget !== "") req.maxTokens = Number(budget); - return req; - }; - const save = () => { - const req = buildRequest(); + const req = buildRequest(form, editing, initial, onError); if (!req) return; setBusy(true); onError(null); @@ -216,7 +228,8 @@ export function LlmProviderForm({ .then((r) => { log("llm.addProvider", "ok"); const budgetNote = r.provider.maxTokens ? ` · maxTokens ${r.provider.maxTokens}` : ""; - onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${r.path ? `(${r.path})` : ""}`); + const windowNote = r.provider.contextWindow ? ` · contextWindow ${r.provider.contextWindow}` : ""; + onResult(`已保存到 models.json 并热更新:${r.provider.name} · ${r.provider.model}${budgetNote}${windowNote}${r.path ? `(${r.path})` : ""}`); onSaved(); if (editing) onCancel(); else setForm(initState(null)); diff --git a/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx b/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx index 012c072..bfb2cc1 100644 --- a/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx +++ b/frontend/web/src/admin/modules/llm/LlmProviderRouting.tsx @@ -2,9 +2,10 @@ import type { AdminLlmProvider } from "../../../lib/types"; import { Button, Switch, Tag } from "../../../components/primitives"; /* --------------------------------------------------------------------------- - * 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)的编辑界面。 - * 取值域一律由后端下发(GET /admin/llm 的 reasoningKinds / personaProviderOptions), - * 本文件只提供展示文案与本地校验,不硬编码第二套枚举,也不拿任何示例值顶替。 + * 阶段 5 新增的字段(multimodal / reasoning.kind / per-persona 路由)与上下文 + * 窗口(contextWindow)的编辑界面。取值域一律由后端下发(GET /admin/llm 的 + * reasoningKinds / personaProviderOptions),本文件只提供展示文案与本地校验, + * 不硬编码第二套枚举,也不拿任何示例值顶替。 * -------------------------------------------------------------------------*/ /** 一个 persona 路由行的编辑草稿。 */ @@ -107,6 +108,70 @@ export function MultimodalField({ ); } +/** API key 与 maxTokens 一行(同排布的两列)。抽出来只为让 ProviderFields 守住 + * ≤80 行:字段、文案与交互与抽取前逐字一致(编辑态下钥匙的提示语照旧跟着切换)。 */ +export function ApiKeyAndBudgetField({ + apiKey, + maxTokens, + editing, + onApiKey, + onMaxTokens, +}: { + apiKey: string; + maxTokens: string; + editing: boolean; + onApiKey: (next: string) => void; + onMaxTokens: (next: string) => void; +}): React.ReactNode { + return ( + + + + API key({editing ? "留空 = 保留原 key" : "写入 models.json,不回显"}) + + onApiKey(e.target.value)} placeholder="sk-…" className={`${inputCls} w-full`} /> + + + maxTokens(该组预算,留空 = 保持/默认) + onMaxTokens(e.target.value)} + inputMode="numeric" + placeholder="8192" + className={`${inputCls} w-full`} + /> + + + ); +} + +/** 模型上下文窗口(token)输入行 —— 与 maxTokens 同排布同交互:留空 = 不提交 + * 该键(缺省 = 保留已存窗口),填了才作为一个明确声明提交。窗口只有部署者知道 + * (本仓没有 provider 侧发现代码),因此面板既不预填猜测值、也不把它当成必填。 */ +export function ContextWindowField({ + value, + onChange, +}: { + value: string; + onChange: (next: string) => void; +}): React.ReactNode { + return ( + + 上下文窗口 contextWindow(token,留空 = 未声明) + onChange(e.target.value)} + inputMode="numeric" + placeholder="1048576" + className={`${inputCls} w-full`} + /> + + 留空 = 未声明(按 128 Ki token 保守估算);填该模型真实窗口(如 1048576)才能吃到按窗口推导的上下文预算(重启后生效)。 + + + ); +} + /** reasoning.kind 的展示文案:键是后端下发的取值,值是**形状说明**。 * 后端将来新增的取值没有文案时原样显示,不编造含义。 */ const REASONING_LABEL: Record = { diff --git a/frontend/web/src/lib/types/admin.ts b/frontend/web/src/lib/types/admin.ts index c7ff1cd..791b7ad 100644 --- a/frontend/web/src/lib/types/admin.ts +++ b/frontend/web/src/lib/types/admin.ts @@ -146,6 +146,10 @@ export interface AdminLlmProvider { /** The group's token budget. Absent/0 = unset (the runtime falls back to * LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */ maxTokens?: number; + /** 该组声明的**模型上下文窗口**(token)。缺失/0 = **未声明**:后端按回落窗口 + * (128 Ki token)保守估算并记一条 WARN。只有填了该模型的真实窗口(如 + * `1048576`)才能吃到按窗口推导的上下文预算。 */ + contextWindow?: number; /** 该组的推理开关形状。后端始终下发(kind 可能为空串),旧构建则整个字段缺失。 */ reasoning?: AdminLlmReasoning; /** persona → 模型名的路由表(裁决 03.4.1);缺失 = 该组没有 per-persona 绑定。 */ diff --git a/frontend/web/src/lib/types/adminWrite.ts b/frontend/web/src/lib/types/adminWrite.ts index 787e119..d0944c9 100644 --- a/frontend/web/src/lib/types/adminWrite.ts +++ b/frontend/web/src/lib/types/adminWrite.ts @@ -40,6 +40,9 @@ export interface LlmProviderRequest { fallback?: string; /** Absent = keep the stored budget (or leave unset for a new group). */ maxTokens?: number; + /** 该组声明的模型上下文窗口(token)。**Absent = 保留已存窗口**;显式 `0` 与 + * absent 同义(都表示未声明、按回落窗口推导);负数后端回 400。 */ + contextWindow?: number; /** 推理开关形状:**缺省 = 保留已存形状**;给出(哪怕 `{}`)= 用其中的 kind * 覆盖,kind 缺省/空串 = 回到「未声明」(等同 auto)。取值见 * AdminLlmConfig.reasoningKinds —— 非法 kind 后端回 400。 */ diff --git a/internal/runtime/history/document.go b/internal/runtime/history/document.go index 390c2eb..23b4aee 100644 --- a/internal/runtime/history/document.go +++ b/internal/runtime/history/document.go @@ -12,9 +12,9 @@ import ( // 本文件归属聊天消息携带的**文档引用**(design/24 §2.4),以及 chat_messages // 上「建表之后才引入的列」的前滚聚合(client_msg_id 与 document)。 // -// document 列只存「引用 + 抽取统计」——存储名、嗅探所得 MIME、字节数、抽取 -// 字符数、是否截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → 每页 -// 历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了 +// document 列只存「引用 + 本回合注入统计」——存储名、嗅探所得 MIME、字节数、注入 +// 字符数、注入是否被截断、抽取状态;**绝不存抽取正文**。G3(base64 原样落库 → +// 每页历史背着重传)与 H1(每回合重算)两条教训说的都是同一件事:正文一旦进了 // 历史,每读一页都要为它付一次代价,而且它是提示词注入面(design/11 §10)。 // // 列的内容是**引用数组**(≤maxDocumentsPerMessage 条),因为一个回合可以挂 @@ -61,10 +61,14 @@ type DocumentRef struct { MIME string `json:"mime"` // Size 是该文档的字节数。 Size int64 `json:"size"` - // Chars 是本次抽取进上下文的字符数(已截断者填截断后的值)。 + // Chars 是**本回合注入**上下文的字符数(被注入预算截断时填截断后的值, + // 即真正进了模型可见视图的那个数)——不是抽取侧的正文长度。 + // 全文长度只有读侧出口能看到(document-read 的 totalChars)。 Chars int `json:"chars,omitempty"` - // Truncated 报告抽取正文是否被预算截断(design/24 §4.3:截断必须诚实 - // 标注,绝不静默)。 + // Truncated 报告**本回合的注入**是否被预算截断(design/24 §4.3:截断必须 + // 诚实标注,绝不静默)——不是「文件是否被截断」。抽取期自己撞上 sidecar + // 安全上限(documents.MaxSidecarBytes)是另一回事,由 document-read 的 + // extractTruncated 专指。 Truncated bool `json:"truncated,omitempty"` // Status 是抽取状态,取值见 DocumentStatus* 常量。 Status string `json:"status,omitempty"` diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index bdab047..2ce2c37 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -67,6 +67,11 @@ type AdminLlmProvider struct { // 在最后一个 fallback 步骤回退到 LLM_FALLBACK_MAX_TOKENS, // 再回退到适配器默认值)。 MaxTokens int `json:"maxTokens,omitempty"` + // ContextWindow 是该分组声明的**模型上下文窗口**(token)。0(= 键被省略, + // 与 MaxTokens 同形)表示未声明:运行时按回落窗口保守推导并记一条 WARN, + // 只有声明了真实窗口才能吃到按窗口推导的上下文预算。它是 .env 侧 + // LLM_CONTEXT_WINDOW 的对偶(per-group 播种)。 + ContextWindow int `json:"contextWindow,omitempty"` // Reasoning 是该组的推理开关形状(裁决 03.4.2 / 08.4.1)。始终下发 // (kind 可能为空串),使面板能把「未声明」当成一种明确状态回填。 Reasoning *AdminLlmReasoning `json:"reasoning"` diff --git a/internal/services/gateway/admin_llm_context_window_test.go b/internal/services/gateway/admin_llm_context_window_test.go new file mode 100644 index 0000000..834161d --- /dev/null +++ b/internal/services/gateway/admin_llm_context_window_test.go @@ -0,0 +1,172 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 组级 `contextWindow`(切片 T1 落库的声明式模型窗口)在管理面板读写面上的锁定: +// 读侧如实回填(否则面板一保存就把窗口抹掉)、写侧真的落进 models.json、 +// 0/省略 = 未声明、负数 400 且不落盘。线上的形状与语义逐处对齐 `maxTokens`。 + +// TestAdminAddLlmProviderPersistsContextWindow 钉住写侧落库且两侧都回填该键。 +func TestAdminAddLlmProviderPersistsContextWindow(t *testing.T) { + t.Parallel() + modelsPath, ts, tok := llmTestEnv(t) + + resp, body := postLlmProvider(t, ts, tok, map[string]any{ + "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", + "apiKey": "sk-win", "model": "long-context-model", "contextWindow": 1048576, + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) + } + var res llmAddResult + if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { + t.Fatalf("decode add: %v", err) + } + if res.Provider.ContextWindow != 1048576 { + t.Fatalf("保存响应回显的窗口 = %d, want 1048576", res.Provider.ContextWindow) + } + + // models.json 里真的落到该组(不只是内存栈里对)。 + groups, raw := storedGroups(t, modelsPath) + if g := findStored(t, groups, "win-group"); g.ContextWindow != 1048576 { + t.Fatalf("models.json 里的窗口 = %d, want 1048576", g.ContextWindow) + } + if !strings.Contains(raw, `"contextWindow"`) { + t.Fatalf("models.json 缺 contextWindow 键:%s", raw) + } + + // GET /admin/llm 同样回填(面板靠它播种表单)。 + gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) + if gResp.StatusCode != http.StatusOK { + t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) + } + var cfg AdminLlmConfig + if err := json.Unmarshal(decodeBody(t, gBody)["data"], &cfg); err != nil { + t.Fatalf("decode get: %v", err) + } + if got, ok := findDTO(cfg.Providers, "win-group"); !ok || got.ContextWindow != 1048576 { + t.Fatalf("GET providers = %+v, want win-group 带 contextWindow 1048576", cfg.Providers) + } +} + +// TestAdminLlmProviderOmitsUndeclaredContextWindow 钉住 0 = 未声明的线上形状: +// DTO 与 models.json 都**不出现**该键(omitempty,与 maxTokens 同形)。面板因此 +// 把「未声明」当成一种明确状态回填,而不是读到一个 0 再去猜它是什么意思。 +func TestAdminLlmProviderOmitsUndeclaredContextWindow(t *testing.T) { + t.Parallel() + modelsPath, ts, tok := llmTestEnv(t) + + resp, body := postLlmProvider(t, ts, tok, map[string]any{ + "id": "nowin", "provider": "openai", "baseUrl": "https://nowin.example/v1", + "apiKey": "sk-nowin", "model": "m1", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) + } + if _, raw := storedGroups(t, modelsPath); strings.Contains(raw, "contextWindow") { + t.Fatalf("未声明的窗口不该落进 models.json:%s", raw) + } + + gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok) + if gResp.StatusCode != http.StatusOK { + t.Fatalf("get status = %d, want 200 (body=%s)", gResp.StatusCode, gBody) + } + if strings.Contains(string(gBody), "contextWindow") { + t.Fatalf("未声明的窗口在读 DTO 里应是「键省略」而不是 0:%s", gBody) + } +} + +// TestAdminAddLlmProviderKeepsContextWindowOnPartialEdit 钉住 P0 级不变量:面板 +// 只提交用户碰过的字段,**省略该键绝不清掉已存窗口**。 +func TestAdminAddLlmProviderKeepsContextWindowOnPartialEdit(t *testing.T) { + t.Parallel() + modelsPath, ts, tok := llmTestEnv(t) + + seed, body := postLlmProvider(t, ts, tok, map[string]any{ + "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", + "apiKey": "sk-win", "model": "m1", "contextWindow": 32768, + }) + if seed.StatusCode != http.StatusCreated { + t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) + } + + // 只改 model:请求里没有 contextWindow,必须原样保留。 + resp, body2 := postLlmProvider(t, ts, tok, map[string]any{"id": "win-group", "model": "m2"}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("edit status = %d, want 201 (body=%s)", resp.StatusCode, body2) + } + groups, _ := storedGroups(t, modelsPath) + if g := findStored(t, groups, "win-group"); g.Model != "m2" || g.ContextWindow != 32768 { + t.Fatalf("stored group = %+v, want model m2 且窗口 32768 保留", g) + } + + // 显式 0 与省略同义(都是「未声明」,与 Group.Validate 同口径),因此它会把 + // 已存窗口清回未声明——面板只在用户真的填了 0 时才发送它。 + resp, body3 := postLlmProvider(t, ts, tok, map[string]any{"id": "win-group", "contextWindow": 0}) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("clear status = %d, want 201 (body=%s)", resp.StatusCode, body3) + } + groups, raw := storedGroups(t, modelsPath) + if g := findStored(t, groups, "win-group"); g.ContextWindow != 0 { + t.Fatalf("显式 0 后的窗口 = %d, want 0(未声明)", g.ContextWindow) + } + if strings.Contains(raw, "contextWindow") { + t.Fatalf("未声明的窗口不该留在 models.json:%s", raw) + } +} + +// TestAdminAddLlmProviderRejectsNegativeContextWindow 钉住负数窗口 400、错误信封 +// 点名字段、且**不落盘**(新组不建、已存组的窗口不动)。 +func TestAdminAddLlmProviderRejectsNegativeContextWindow(t *testing.T) { + t.Parallel() + modelsPath, ts, tok := llmTestEnv(t) + + seed, body := postLlmProvider(t, ts, tok, map[string]any{ + "id": "win-group", "provider": "openai", "baseUrl": "https://window.example/v1", + "apiKey": "sk-win", "model": "m1", "contextWindow": 32768, + }) + if seed.StatusCode != http.StatusCreated { + t.Fatalf("seed status = %d, want 201 (body=%s)", seed.StatusCode, body) + } + + cases := []struct { + name string + body map[string]any + }{ + {"已存组改成负数", map[string]any{"id": "win-group", "contextWindow": -1}}, + {"新组直接给负数", map[string]any{ + "id": "neg-group", "provider": "openai", "baseUrl": "https://neg.example/v1", + "model": "m1", "contextWindow": -4096, + }}, + } + for _, tc := range cases { + resp, respBody := postLlmProvider(t, ts, tok, tc.body) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, respBody) + } + if errObj := decodeBody(t, respBody)["error"]; len(errObj) == 0 { + t.Fatalf("%s: 400 没有 error 信封(body=%s)", tc.name, respBody) + } + if !strings.Contains(string(respBody), "contextWindow") { + t.Fatalf("%s: 错误消息没点名字段(body=%s)", tc.name, respBody) + } + } + + groups, raw := storedGroups(t, modelsPath) + if g := findStored(t, groups, "win-group"); g.ContextWindow != 32768 { + t.Fatalf("被拒的请求改了已存窗口:%+v", g) + } + if _, ok := runtimeconfig.Find(groups, "neg-group"); ok { + t.Fatalf("被拒的组被落盘了:%+v", groups) + } + if strings.Contains(raw, "-1") || strings.Contains(raw, "-4096") { + t.Fatalf("被拒的值漏进了 models.json:%s", raw) + } +} diff --git a/internal/services/gateway/admin_llm_provider.go b/internal/services/gateway/admin_llm_provider.go index 92755fe..9eedbc8 100644 --- a/internal/services/gateway/admin_llm_provider.go +++ b/internal/services/gateway/admin_llm_provider.go @@ -25,6 +25,7 @@ func adminLlmProviderDTO(g runtimeconfig.Group, name string, enabled bool) Admin Enabled: enabled, Fallback: g.Fallback, MaxTokens: g.MaxTokens, + ContextWindow: g.ContextWindow, Reasoning: &AdminLlmReasoning{Kind: strings.TrimSpace(g.Reasoning.Kind)}, PersonaModels: cloneStringMap(g.PersonaModels), PersonaProviders: cloneStringMap(g.PersonaProviders), diff --git a/internal/services/gateway/admin_write_llm_channels.go b/internal/services/gateway/admin_write_llm_channels.go index bcb8115..b327d56 100644 --- a/internal/services/gateway/admin_write_llm_channels.go +++ b/internal/services/gateway/admin_write_llm_channels.go @@ -47,8 +47,8 @@ type llmReasoningRequest struct { // 「缺失」(保留已存值)与「显式零值」(覆盖):来自面板的局部 // 编辑绝不清除它未触碰的字段。Name 是客户端侧 // 显示标签(不持久化 —— models.json 结构含 id/provider/ -// baseUrl/apiKey/model/fallback/multimodal/maxTokens/reasoning/personaModels/ -// personaProviders)。 +// baseUrl/apiKey/model/fallback/multimodal/maxTokens/contextWindow/reasoning/ +// personaModels/personaProviders)。 type llmProviderRequest struct { ID *string `json:"id"` Name *string `json:"name"` @@ -61,6 +61,9 @@ type llmProviderRequest struct { // MaxTokens 是该分组自身的 token 预算。缺失则保留已存 // 预算(新分组 0 = 未设置)。负值被拒绝。 MaxTokens *int `json:"maxTokens"` + // ContextWindow 是该分组声明的模型上下文窗口(token)。缺失则保留已存窗口 + // (新分组 0 = 未声明);显式 0 与缺失同义(都按回落窗口推导);负值被拒绝。 + ContextWindow *int `json:"contextWindow"` // Reasoning 缺失 = 保留已存形状;给出(含 `{}`)= 用其中的 kind 覆盖 // (kind 缺失/空串 = 回到「未声明」,等同 auto)。 Reasoning *llmReasoningRequest `json:"reasoning"` @@ -74,7 +77,7 @@ type llmProviderRequest struct { // adminAddLlmProvider 把 provider 分组保存到 models.json(read-modify-write, // 并将其提升为活动分组),再热重载运行中的 LLM 栈,使 // 新的 provider/model 无需重启即生效(design/04 §3.1)。客户端省略的 -// 字段保持其已存值,因此局部编辑绝不复位预算或 +// 字段保持其已存值,因此局部编辑绝不复位预算、窗口或 // key。API key 被持久化进被 gitignore 的文件,但绝不返回/记日志。 func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { var req llmProviderRequest @@ -87,6 +90,14 @@ func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0")) return } + // 窗口没有负数语义(与 runtimeconfig.Group.Validate 同一口径:只拒绝负数, + // 0 与缺失都是「未声明」)。这里提前报错是为了给出面板能直接显示的字段名, + // 规则本身不在网关另立一套。 + if req.ContextWindow != nil && *req.ContextWindow < 0 { + s.auditClaims(r, "llm.addProvider", "error") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "contextWindow must be >= 0")) + return + } if s.llm == nil { s.auditClaims(r, "llm.addProvider", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入")) @@ -168,6 +179,9 @@ func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (run if req.MaxTokens != nil { g.MaxTokens = *req.MaxTokens } + if req.ContextWindow != nil { + g.ContextWindow = *req.ContextWindow + } if req.Reasoning != nil { g.Reasoning = runtimeconfig.ReasoningOptions{Kind: reasoningKindOf(req.Reasoning)} }