* Repro for #1227 — `/btw` (and IRC background replies) fail with a
* `BedrockException` once the session has tool-call history when the model is
* served via LiteLLM → Bedrock.
*
* `AgentSession.runEphemeralTurn` calls into the openai-completions provider
* with `context.tools = []` and `toolChoice: "none"`. The combination
* serializes as `tool_choice: "none"` paired with `tools: []`, which LiteLLM
* translates into a Bedrock `toolConfig` block with no entries. Bedrock then
* rejects the request because the conversation already contains
* `toolUse`/`toolResult` blocks and `toolConfig.tools` must be non-empty.
*
* The fix in `buildParams` drops `tool_choice: "none"` whenever the resolved
* `tools` array is empty (logically redundant — there are no tools to gate).
* The `tools: []` field stays so Anthropic-via-proxy still sees the field it
* requires when tool history is present.
*/
import { describe, expect, it } from "bun:test";
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
import { streamOpenAICompletions } from "@oh-my-pi/pi-ai/providers/openai-completions";
import type { AssistantMessage, Context, Model, Tool } from "@oh-my-pi/pi-ai/types";
import * as z from "zod/v4";
function abortedSignal(): AbortSignal {
const controller = new AbortController();
controller.abort();
return controller.signal;
}
function bedrockModel(): Model<"openai-completions"> {
return {
...getBundledModel("openai", "gpt-4o-mini"),
api: "openai-completions",
id: "bedrock-claude-sonnet-4-6",
name: "Bedrock Claude Sonnet 4.6 (LiteLLM)",
provider: "litellm-bedrock",
baseUrl: "https://example.test/v1",
};
}
async function capturePayload(
context: Context,
opts: Parameters<typeof streamOpenAICompletions>[2],
): Promise<Record<string, unknown>> {
const { promise, resolve } = Promise.withResolvers<unknown>();
streamOpenAICompletions(bedrockModel(), context, {
...opts,
apiKey: "test-key",
signal: abortedSignal(),
onPayload: payload => resolve(payload),
});
return (await promise) as Record<string, unknown>;
}
function assistantWithToolCall(): AssistantMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "search", arguments: { q: "x" } }],
api: "openai-completions",
provider: "litellm-bedrock",
model: "bedrock-claude-sonnet-4-6",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
}
const echoTool: Tool = {
name: "echo",
description: "Echo input",
parameters: z.object({ text: z.string() }),
};
describe("issue #1227 — /btw fails on LiteLLM→Bedrock with tool history", () => {
it("omits both tools and tool_choice when /btw passes empty tools + toolChoice none", async () => {
const body = await capturePayload(
{
messages: [{ role: "user", content: "what is X", timestamp: Date.now() }],
tools: [],
},
{ toolChoice: "none" },
);
expect(body.tools).toBeUndefined();
expect(body.tool_choice).toBeUndefined();
});
it("omits both tools and tool_choice when /btw passes empty tools + has tool history", async () => {
const body = await capturePayload(
{
messages: [
{ role: "user", content: "search now", timestamp: Date.now() },
assistantWithToolCall(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "search",
content: [{ type: "text", text: "result" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "btw what is X", timestamp: Date.now() },
],
tools: [],
},
{ toolChoice: "none" },
);
expect(body.tools).toBeUndefined();
expect(body.tool_choice).toBeUndefined();
});
it("drops tool_choice when tool history is present without explicit tools", async () => {
const body = await capturePayload(
{
messages: [
{ role: "user", content: "search now", timestamp: Date.now() },
assistantWithToolCall(),
{
role: "toolResult",
toolCallId: "call_1",
toolName: "search",
content: [{ type: "text", text: "result" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "follow-up", timestamp: Date.now() },
],
},
{ toolChoice: "none" },
);
expect(body.tools).toEqual([]);
expect(body.tool_choice).toBeUndefined();
});
it("keeps tool_choice none when real tools are present", async () => {
const body = await capturePayload(
{
messages: [{ role: "user", content: "answer in prose", timestamp: Date.now() }],
tools: [echoTool],
},
{ toolChoice: "none" },
);
expect(Array.isArray(body.tools)).toBe(true);
expect((body.tools as unknown[]).length).toBe(1);
expect(body.tool_choice).toBe("none");
});
});