fix(ai): omit tools field instead of sending empty array (#3650)

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: 槐聚 <huaiju@zbyte-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
HQidea
2026-04-24 20:24:31 +08:00
committed by GitHub
parent 6160892626
commit 3e0ee69b5e
7 changed files with 149 additions and 5 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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 });
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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/<issue-number>
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<typeof stream> & {
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([]);
});
});