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([]); }); });