fix(llm): omit empty tools and keep tool_choice off the wire (#21)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
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<string, unknown>);
|
||||
} 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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("连通性测试:已保存的模型与**尚未保存**的自定义模型都可测(LLM 层不抛异常,一律收敛)", async () => {
|
||||
await api.put(url(), {
|
||||
models: [{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-invalid-key-for-test" }],
|
||||
|
||||
Reference in New Issue
Block a user