From 3e0ee69b5ead441e4669b123b2f98635fee7da30 Mon Sep 17 00:00:00 2001 From: HQidea Date: Fri, 24 Apr 2026 20:24:31 +0800 Subject: [PATCH] fix(ai): omit tools field instead of sending empty array (#3650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashScope / Aliyun Qwen (OpenAI-compatible) rejects `tools: []` with HTTP 400 `"[] is too short - 'tools'"`. Five providers used a truthy check (`if (context.tools)`) that treated an empty array as "send tools", so `pi --no-tools` produced `tools: []` in the request body. Matching the Google provider's pattern, we now guard on `context.tools.length > 0`: - openai-completions.ts - openai-responses.ts - openai-codex-responses.ts - azure-openai-responses.ts - anthropic.ts The openai-completions fallback that emits `tools: []` when the conversation has tool history (required by LiteLLM / Anthropic proxies) is preserved via the existing `else if (hasToolHistory)` branch. closes #3649 Co-authored-by: 槐聚 Co-authored-by: Claude Opus 4.7 Co-authored-by: Mario Zechner --- packages/ai/CHANGELOG.md | 3 + packages/ai/src/providers/anthropic.ts | 2 +- .../src/providers/azure-openai-responses.ts | 2 +- .../src/providers/openai-codex-responses.ts | 2 +- .../ai/src/providers/openai-completions.ts | 2 +- packages/ai/src/providers/openai-responses.ts | 2 +- .../openai-completions-empty-tools.test.ts | 141 ++++++++++++++++++ 7 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 packages/ai/test/openai-completions-empty-tools.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a5e2e05f..fe6cf4b3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Stopped sending `tools: []` on OpenAI-compatible, Anthropic, OpenAI Responses, OpenAI Codex Responses, and Azure OpenAI Responses requests when no tools are active (e.g. `pi --no-tools`). DashScope/Aliyun Qwen (OpenAI-compatible) rejects empty tools arrays with `"[] is too short - 'tools'"` (HTTP 400); the field is now omitted unless the conversation has tool history (the existing LiteLLM/Anthropic-proxy workaround). ## [0.70.2] - 2026-04-24 ### Fixed diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 4173f20a..72d82d40 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -877,7 +877,7 @@ function buildParams( params.temperature = options.temperature; } - if (context.tools) { + if (context.tools && context.tools.length > 0) { params.tools = convertTools( context.tools, isOAuthToken, diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 418e2099..155001b2 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -236,7 +236,7 @@ function buildParams( params.temperature = options?.temperature; } - if (context.tools) { + if (context.tools && context.tools.length > 0) { params.tools = convertResponsesTools(context.tools); } diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index a581748a..74b90d1c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -340,7 +340,7 @@ function buildRequestBody( body.service_tier = options.serviceTier; } - if (context.tools) { + if (context.tools && context.tools.length > 0) { body.tools = convertResponsesTools(context.tools, { strict: null }); } diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 5404bb8c..e9a9cd17 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -504,7 +504,7 @@ function buildParams( params.temperature = options.temperature; } - if (context.tools) { + if (context.tools && context.tools.length > 0) { params.tools = convertTools(context.tools, compat); if (compat.zaiToolStream) { (params as any).tool_stream = true; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 1cb68ac5..4266fe1b 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -230,7 +230,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.service_tier = options.serviceTier; } - if (context.tools) { + if (context.tools && context.tools.length > 0) { params.tools = convertResponsesTools(context.tools); } diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts new file mode 100644 index 00000000..627d1b35 --- /dev/null +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getModel } from "../src/models.js"; +import { streamSimple } from "../src/stream.js"; + +// Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible +// backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with +// `"[] is too short - 'tools'"` (HTTP 400) when `--no-tools` produces an empty array. +// Regression for https://github.com/badlogic/pi-mono/issues/ + +const mockState = vi.hoisted(() => ({ + lastParams: undefined as unknown, +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: (params: unknown) => { + mockState.lastParams = params; + const stream = { + async *[Symbol.asyncIterator]() { + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + } + + return { default: FakeOpenAI }; +}); + +describe("openai-completions empty tools handling", () => { + beforeEach(() => { + mockState.lastParams = undefined; + }); + + it("omits tools field when context.tools is an empty array", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + tools: [], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { tools?: unknown }; + expect("tools" in (params as object)).toBe(false); + }); + + it("omits tools field when context.tools is undefined", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + + await streamSimple( + model, + { + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { tools?: unknown }; + expect("tools" in (params as object)).toBe(false); + }); + + it("still emits tools: [] for Anthropic/LiteLLM proxy when conversation has tool history", async () => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + + await streamSimple( + model, + { + messages: [ + { role: "user", content: "use the tool", timestamp: Date.now() }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "t1", + name: "noop", + arguments: {}, + }, + ], + stopReason: "toolUse", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + api: "openai-completions", + provider: "openai", + model: "gpt-4o-mini", + timestamp: Date.now(), + }, + { + role: "toolResult", + toolCallId: "t1", + toolName: "noop", + content: [{ type: "text", text: "done" }], + isError: false, + timestamp: Date.now(), + }, + ], + tools: [], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { tools?: unknown[] }; + expect(Array.isArray(params.tools)).toBe(true); + expect(params.tools).toEqual([]); + }); +});