diff --git a/integrations/dsh/docs/environment.md b/integrations/dsh/docs/environment.md index d8962d0b..fbbdb05d 100644 --- a/integrations/dsh/docs/environment.md +++ b/integrations/dsh/docs/environment.md @@ -136,7 +136,8 @@ DSH settings. backend, exactly as ReMe would start it. - A known backend written in the wrong case (`LLM_BACKEND=OpenAI`) is reported as invalid, with the spelling to use: ReMe's backend registry is case-sensitive and refuses that name. A backend name the plugin has never heard - of is still probed tolerantly — the list here is a snapshot, and ReMe may know more than the plugin does. + of is still probed tolerantly — the list here is a snapshot, and ReMe may know more than the plugin does. That + holds however the name looks: only a backend the plugin actually knows can be reported as a wrong spelling. - The draft, the save message and the LLM-test result belong to the workspace they were produced for: switching the workspace disposes the section's state, so neither a key typed for one workspace nor a success reported for it can be carried into another. diff --git a/integrations/dsh/docs/environment_ZH.md b/integrations/dsh/docs/environment_ZH.md index 4ce50c81..49394855 100644 --- a/integrations/dsh/docs/environment_ZH.md +++ b/integrations/dsh/docs/environment_ZH.md @@ -108,7 +108,8 @@ ReMe 在服务启动时读取 workspace 目录下的 `.env`;标签页从不把 任何请求:此时 ReMe 启动时并没有任何后端,探测不能去测试服务根本无法使用的配置。若 `.env` 里没有这个键, 探测会与 ReMe 一样使用默认后端。 - 已知后端名写错了大小写(如 `LLM_BACKEND=OpenAI`)会被报告为无效,并给出正确写法:ReMe 的后端注册表区分大小写, - 会拒绝这个名字。而插件从未听说过的后端名仍被宽容地探测——这里的列表只是一份快照,ReMe 可能认识得更多。 + 会拒绝这个名字。而插件从未听说过的后端名仍被宽容地探测——这里的列表只是一份快照,ReMe 可能认识得更多。这条规则与名字 + 长什么样无关:只有插件确实认识的后端才会被判定为大小写写错。 - 草稿、保存提示与 LLM 测试结果都属于产生它们的 workspace:切换 workspace 会随旧区块一并丢弃,因此为某个 workspace 输入的密钥、或属于它的一次成功提示,都不会被带到另一个。 diff --git a/integrations/dsh/src/environment/host/workspace.ts b/integrations/dsh/src/environment/host/workspace.ts index ef7e622b..7aa3592b 100644 --- a/integrations/dsh/src/environment/host/workspace.ts +++ b/integrations/dsh/src/environment/host/workspace.ts @@ -278,6 +278,17 @@ export const LLM_PROTOCOLS: Record = { /** The backend ReMe itself falls back to when `.env` names none. */ export { DEFAULT_LLM_BACKEND }; +/** + * The protocol entry the table holds for `key`, if it holds one at all. + * + * Only an own entry counts: the table inherits from `Object.prototype`, so a + * plain lookup would hand out `Object` for a backend named `constructor` and + * build a malformed probe from it. + */ +function ownProtocol(key: string): LlmProtocol | undefined { + return Object.hasOwn(LLM_PROTOCOLS, key) ? LLM_PROTOCOLS[key] : undefined; +} + /** * The protocol a backend speaks; an unknown backend uses the OpenAI-style * request. @@ -287,7 +298,7 @@ export { DEFAULT_LLM_BACKEND }; * refused. */ export function llmProtocolFor(backend: string): LlmProtocol { - return LLM_PROTOCOLS[llmBackendOf(backend)] ?? OPENAI_PROTOCOL; + return ownProtocol(llmBackendOf(backend)) ?? OPENAI_PROTOCOL; } /** @@ -299,9 +310,12 @@ export function llmProtocolFor(backend: string): LlmProtocol { * alone: its list is a snapshot, and ReMe may know more than the plugin does. */ export function backendSpellingIsWrong(name: string): boolean { + // Own entries only. An inherited member is not a known backend: `constructor` + // and `__proto__` would otherwise be reported as known backends written in + // the wrong case, and the message would name a spelling ReMe refuses too. return ( - LLM_PROTOCOLS[name] === undefined && - LLM_PROTOCOLS[name.toLowerCase()] !== undefined + ownProtocol(name) === undefined && + ownProtocol(name.toLowerCase()) !== undefined ); } diff --git a/integrations/dsh/tests/env-workspace.test.mjs b/integrations/dsh/tests/env-workspace.test.mjs index af00c43d..d38466ef 100644 --- a/integrations/dsh/tests/env-workspace.test.mjs +++ b/integrations/dsh/tests/env-workspace.test.mjs @@ -526,6 +526,64 @@ test("the probe refuses a known backend written in the wrong case", async (t) => assert.equal(calls.length, 0, "the snippet is not run"); }); +test("a backend name the plugin does not know is probed tolerantly, whatever it looks like", async (t) => { + // The protocol table is a plain object, so it inherits from + // `Object.prototype`: `constructor` and `__proto__` are not backends. A name + // the plugin has never heard of must be probed like any other unknown name, + // and must never be reported as a known backend written in the wrong case. + const directory = mkdtempSync(join(tmpdir(), "reme-llm-prototype-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const names = ["constructor", "__proto__", "CONSTRUCTOR", "__PROTO__"]; + for (const name of names) { + writeFileSync( + join(directory, ".env"), + [ + `LLM_BACKEND=${name}`, + "LLM_MODEL_NAME=test-model", + "LLM_API_KEY=sk-test", + "LLM_BASE_URL=https://provider.invalid", + ].join("\n"), + ); + const calls = []; + const result = await testLlm( + { + run: async (exe, args, options) => { + calls.push({ exe, args, options }); + return { + code: 0, + stdout: + '{"ok": true, "status": 200, "latencyMs": 12, "error": ""}\n', + stderr: "", + }; + }, + }, + { workspaceDir: directory, pythonPath: "C:\\env\\python.exe" }, + ); + assert.doesNotMatch( + result.error ?? "", + /lower case/, + `${name} is not reported as a known backend in the wrong case`, + ); + assert.equal(calls.length, 1, `${name} is probed tolerantly`); + const snippet = String(calls[0]?.args[1] ?? ""); + assert.match( + snippet, + /\/chat\/completions/, + `${name} gets the default protocol`, + ); + assert.match( + snippet, + /"Authorization"/, + `${name} gets the default protocol`, + ); + assert.doesNotMatch( + snippet, + /undefined/, + `${name} builds no malformed snippet`, + ); + } +}); + test("a .env without the backend key is probed with the default protocol", async (t) => { // The key is absent, so ReMe applies its default; the probe does the same. const directory = mkdtempSync(join(tmpdir(), "reme-llm-absent-"));