From ea0549b8bb368b3366e3070dcc2ab02d55bfe9f7 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Wed, 22 Jul 2026 18:53:39 +0800 Subject: [PATCH] fix(llm): omit empty tools and keep tool_choice off the wire (#21) Co-authored-by: Alice Co-authored-by: Claude Fable 5 --- packages/core/src/llm/generative-model.ts | 18 +++++++--- packages/core/test/llm.test.ts | 15 ++++++++- packages/server/test/models.test.ts | 40 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/core/src/llm/generative-model.ts b/packages/core/src/llm/generative-model.ts index 58ba081..fa6661f 100644 --- a/packages/core/src/llm/generative-model.ts +++ b/packages/core/src/llm/generative-model.ts @@ -1095,11 +1095,21 @@ export function toolDefinitionsToSchemas(tools: ToolDefinition[]): ToolSchema[] })); } -/** Pre-builds UniConfig from GenerativeModelConfig (called once at construction time). */ +/** + * Pre-builds UniConfig from GenerativeModelConfig (called once at construction time). + * + * When the tool list is empty (connectivity probe, bare/meta LLM, vision describer), `tools` + * is omitted entirely instead of set to `[]`: strict OpenAI-compatible servers (e.g. vLLM) + * reject an empty array with a 400 ("tools must not be an empty array"), and omission is the + * protocol equivalent. `tool_choice` is likewise never set — AgentHub only puts it on the wire + * when UniConfig defines it, and leaving it off preserves the protocol default ("auto" when + * tools are present). + */ export function buildUniConfig(config: GenerativeModelConfig): UniConfig { - const uniConfig: UniConfig = { - tools: toolDefinitionsToSchemas(config.tools), - }; + const uniConfig: UniConfig = {}; + if (config.tools.length > 0) { + uniConfig.tools = toolDefinitionsToSchemas(config.tools); + } if (config.systemPrompt !== undefined) { uniConfig.system_prompt = config.systemPrompt; } diff --git a/packages/core/test/llm.test.ts b/packages/core/test/llm.test.ts index 9237f13..ed3225a 100644 --- a/packages/core/test/llm.test.ts +++ b/packages/core/test/llm.test.ts @@ -1048,11 +1048,24 @@ describe("config helpers", () => { expect(cfg.tools).toEqual([{ name: "t", description: "d" }]); const minimal = buildUniConfig({ modelId: "m", tools: [] }); - expect(minimal.tools).toEqual([]); + expect("tools" in minimal).toBe(false); expect("system_prompt" in minimal).toBe(false); expect("max_tokens" in minimal).toBe(false); expect("thinking_level" in minimal).toBe(false); }); + + it("omits tools when empty and never sets tool_choice (strict endpoints reject both)", () => { + // Empty tool list (connectivity probe, bare/meta LLM, vision describer): the `tools` key + // must be absent, not `[]` — AgentHub forwards any defined array verbatim, and strict + // OpenAI-compatible servers (e.g. vLLM) reject `tools: []` with a 400. + const empty = buildUniConfig({ modelId: "m", tools: [] }); + expect("tools" in empty).toBe(false); + // `tool_choice` must never be set: AgentHub only emits it on the wire when UniConfig + // defines it, and leaving it off preserves the protocol default. + expect("tool_choice" in empty).toBe(false); + const withTools = buildUniConfig({ modelId: "m", tools: [{ name: "t", description: "d" }] }); + expect("tool_choice" in withTools).toBe(false); + }); }); describe("isRetryableError", () => { diff --git a/packages/server/test/models.test.ts b/packages/server/test/models.test.ts index 8eca08e..a21b366 100644 --- a/packages/server/test/models.test.ts +++ b/packages/server/test/models.test.ts @@ -430,6 +430,46 @@ describe("模型引用改键与连通性测试", () => { } }); + it("连通性测试请求体不含 tools 与 tool_choice(空工具列表整体省略,vLLM 等严格端点不再 400)", async () => { + // The probe runs with an empty tool list. The wire body must omit `tools` entirely — + // `tools: []` is rejected by strict OpenAI-compatible servers (vLLM: "tools must not be an + // empty array") — and must never carry `tool_choice`. + const bodies: Record[] = []; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", (chunk: Buffer) => (raw += chunk.toString("utf8"))); + req.on("end", () => { + try { + bodies.push(JSON.parse(raw) as Record); + } catch { + bodies.push({}); + } + res.statusCode = 401; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: { message: "test-reject", type: "invalid_request" } })); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + try { + const res = await api.post(testUrl(), { + provider: "custom", + modelId: "probe-wire-model", + clientType: "openai", + apiKey: "sk-test-local", + baseUrl: `http://127.0.0.1:${port}/v1`, + }); + expect(res.status).toBe(200); + expect(bodies.length).toBeGreaterThan(0); + for (const body of bodies) { + expect("tools" in body).toBe(false); + expect("tool_choice" in body).toBe(false); + } + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("连通性测试:已保存的模型与**尚未保存**的自定义模型都可测(LLM 层不抛异常,一律收敛)", async () => { await api.put(url(), { models: [{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-invalid-key-for-test" }],