Skip to content

Commit 82892ec

Browse files
committed
fix(providers): skip unreadable Mistral tool schemas
1 parent a6d0841 commit 82892ec

2 files changed

Lines changed: 134 additions & 12 deletions

File tree

src/llm/providers/mistral.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock("@mistralai/mistralai", () => ({
1616
},
1717
}));
1818

19-
import { streamSimpleMistral } from "./mistral.js";
19+
import { streamMistral, streamSimpleMistral } from "./mistral.js";
2020

2121
function makeMistralModel(): Model<"mistral-conversations"> {
2222
return {
@@ -37,6 +37,24 @@ const context = {
3737
messages: [{ role: "user", content: "hello", timestamp: 0 }],
3838
} satisfies Context;
3939

40+
function makeUnreadableParameterTool() {
41+
const tool = {
42+
name: "broken_tool",
43+
description: "broken tool",
44+
parameters: { type: "object", properties: {} },
45+
async execute() {
46+
return { content: [{ type: "text", text: "broken" }] };
47+
},
48+
};
49+
Object.defineProperty(tool, "parameters", {
50+
enumerable: true,
51+
get() {
52+
throw new Error("fuzzplugin parameters getter exploded");
53+
},
54+
});
55+
return tool;
56+
}
57+
4058
describe("Mistral provider", () => {
4159
beforeEach(() => {
4260
mistralMockState.payloads = [];
@@ -53,4 +71,82 @@ describe("Mistral provider", () => {
5371
expect(result.stopReason).toBe("error");
5472
expect((mistralMockState.payloads[0] as { stop?: unknown }).stop).toEqual(["STOP"]);
5573
});
74+
75+
it("skips unreadable tool schemas while preserving healthy Mistral tools", async () => {
76+
const stream = streamMistral(
77+
makeMistralModel(),
78+
{
79+
...context,
80+
tools: [
81+
makeUnreadableParameterTool(),
82+
{
83+
name: "healthy_tool",
84+
description: "healthy tool",
85+
parameters: {
86+
type: "object",
87+
properties: {
88+
query: { type: "string" },
89+
},
90+
},
91+
async execute() {
92+
return { content: [{ type: "text", text: "ok" }] };
93+
},
94+
},
95+
] as never,
96+
},
97+
{
98+
apiKey: "sk-mistral-provider",
99+
},
100+
);
101+
102+
const result = await stream.result();
103+
104+
expect(result.stopReason).toBe("error");
105+
expect((mistralMockState.payloads[0] as { tools?: unknown[] }).tools).toEqual([
106+
{
107+
type: "function",
108+
function: {
109+
name: "healthy_tool",
110+
description: "healthy tool",
111+
parameters: {
112+
type: "object",
113+
properties: {
114+
query: { type: "string" },
115+
},
116+
},
117+
strict: false,
118+
},
119+
},
120+
]);
121+
});
122+
123+
it("fails locally when a pinned Mistral tool choice is skipped", async () => {
124+
const stream = streamMistral(
125+
makeMistralModel(),
126+
{
127+
...context,
128+
tools: [
129+
makeUnreadableParameterTool(),
130+
{
131+
name: "healthy_tool",
132+
description: "healthy tool",
133+
parameters: { type: "object", properties: {} },
134+
async execute() {
135+
return { content: [{ type: "text", text: "ok" }] };
136+
},
137+
},
138+
] as never,
139+
},
140+
{
141+
apiKey: "sk-mistral-provider",
142+
toolChoice: { type: "function", function: { name: "broken_tool" } },
143+
},
144+
);
145+
146+
const result = await stream.result();
147+
148+
expect(result.stopReason).toBe("error");
149+
expect(result.errorMessage).toContain('Mistral tool_choice requested unavailable tool "broken_tool"');
150+
expect(mistralMockState.payloads).toHaveLength(0);
151+
});
56152
});

src/llm/providers/mistral.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,14 @@ function buildChatPayload(
274274
stream: true,
275275
messages: toChatMessages(messages, model.input.includes("image")),
276276
};
277+
let convertedToolNames: Set<string> | undefined;
277278

278279
if (context.tools?.length) {
279-
payload.tools = toFunctionTools(context.tools);
280+
const tools = toFunctionTools(context.tools);
281+
convertedToolNames = new Set(tools.map((tool) => tool.function.name));
282+
if (tools.length > 0) {
283+
payload.tools = tools;
284+
}
280285
}
281286
if (options?.temperature !== undefined) {
282287
payload.temperature = options.temperature;
@@ -288,7 +293,10 @@ function buildChatPayload(
288293
payload.stop = options.stop;
289294
}
290295
if (options?.toolChoice) {
291-
payload.toolChoice = mapToolChoice(options.toolChoice);
296+
const toolChoice = mapToolChoice(options.toolChoice, convertedToolNames);
297+
if (toolChoice) {
298+
payload.toolChoice = toolChoice;
299+
}
292300
}
293301
if (options?.promptMode) {
294302
payload.promptMode = options.promptMode;
@@ -506,15 +514,21 @@ async function consumeChatStream(
506514
}
507515

508516
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
509-
return tools.map((tool) => ({
510-
type: "function",
511-
function: {
512-
name: tool.name,
513-
description: tool.description,
514-
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
515-
strict: false,
516-
},
517-
}));
517+
return tools.flatMap((tool) => {
518+
try {
519+
return {
520+
type: "function",
521+
function: {
522+
name: tool.name,
523+
description: tool.description,
524+
parameters: stripSymbolKeys(tool.parameters) as Record<string, unknown>,
525+
strict: false,
526+
},
527+
};
528+
} catch {
529+
return [];
530+
}
531+
});
518532
}
519533

520534
function stripSymbolKeys(value: unknown): unknown {
@@ -687,6 +701,7 @@ function mapReasoningEffort(
687701

688702
function mapToolChoice(
689703
choice: MistralOptions["toolChoice"],
704+
convertedToolNames?: ReadonlySet<string>,
690705
):
691706
| "auto"
692707
| "none"
@@ -697,9 +712,20 @@ function mapToolChoice(
697712
if (!choice) {
698713
return undefined;
699714
}
715+
if (convertedToolNames && convertedToolNames.size === 0) {
716+
if (choice === "none" || choice === "auto") {
717+
return choice === "none" ? "none" : undefined;
718+
}
719+
throw new Error("Mistral tool_choice requires a tool, but no tools survived schema conversion");
720+
}
700721
if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") {
701722
return choice;
702723
}
724+
if (convertedToolNames && !convertedToolNames.has(choice.function.name)) {
725+
throw new Error(
726+
`Mistral tool_choice requested unavailable tool "${choice.function.name}" after schema conversion`,
727+
);
728+
}
703729
return {
704730
type: "function",
705731
function: { name: choice.function.name },

0 commit comments

Comments
 (0)