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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
141
packages/ai/test/openai-completions-empty-tools.test.ts
Normal file
141
packages/ai/test/openai-completions-empty-tools.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user