Skip to content

Commit 30d404a

Browse files
committed
fix(providers): skip unreadable Anthropic payload tool schemas
1 parent 045145c commit 30d404a

2 files changed

Lines changed: 147 additions & 12 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
2+
import type { Model } from "openclaw/plugin-sdk/llm";
3+
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
4+
import { describe, expect, it } from "vitest";
5+
import { createOpenAIAnthropicToolPayloadCompatibilityWrapper } from "./anthropic-family-tool-payload-compat.js";
6+
7+
const model = {
8+
api: "anthropic-messages",
9+
provider: "openai-compatible-anthropic",
10+
id: "claude-compatible",
11+
compat: { requiresOpenAiAnthropicToolPayload: true },
12+
} as unknown as Model<"anthropic-messages">;
13+
14+
describe("createOpenAIAnthropicToolPayloadCompatibilityWrapper", () => {
15+
it("skips unreadable tool schemas while preserving healthy payload tools", () => {
16+
const payloads: Array<Record<string, unknown>> = [];
17+
const baseStreamFn: StreamFn = (nextModel, context, options) => {
18+
const payload: Record<string, unknown> = {
19+
model: nextModel.id,
20+
tools: [
21+
{
22+
name: "bad_schema",
23+
description: "Bad schema",
24+
get parameters(): never {
25+
throw new Error("parameters getter exploded");
26+
},
27+
},
28+
{
29+
name: "lookup",
30+
description: "Lookup",
31+
parameters: {
32+
type: "object",
33+
properties: { query: { type: "string" } },
34+
},
35+
},
36+
],
37+
};
38+
options?.onPayload?.(payload, nextModel);
39+
payloads.push(structuredClone(payload));
40+
return createAssistantMessageEventStream();
41+
};
42+
43+
const wrapped = createOpenAIAnthropicToolPayloadCompatibilityWrapper(baseStreamFn);
44+
45+
expect(() => void wrapped(model, { messages: [] }, {})).not.toThrow();
46+
expect(payloads[0]?.tools).toEqual([
47+
{
48+
type: "function",
49+
function: {
50+
name: "lookup",
51+
description: "Lookup",
52+
parameters: {
53+
type: "object",
54+
properties: { query: { type: "string" } },
55+
},
56+
},
57+
},
58+
]);
59+
});
60+
61+
it("uses Anthropic input_schema without reading a poisoned parameters fallback", () => {
62+
const payloads: Array<Record<string, unknown>> = [];
63+
const baseStreamFn: StreamFn = (nextModel, context, options) => {
64+
const payload: Record<string, unknown> = {
65+
model: nextModel.id,
66+
tools: [
67+
{
68+
name: "lookup",
69+
input_schema: {
70+
type: "object",
71+
properties: { query: { type: "string" } },
72+
},
73+
get parameters(): never {
74+
throw new Error("parameters fallback getter exploded");
75+
},
76+
},
77+
],
78+
};
79+
options?.onPayload?.(payload, nextModel);
80+
payloads.push(structuredClone(payload));
81+
return createAssistantMessageEventStream();
82+
};
83+
84+
const wrapped = createOpenAIAnthropicToolPayloadCompatibilityWrapper(baseStreamFn);
85+
86+
expect(() => void wrapped(model, { messages: [] }, {})).not.toThrow();
87+
expect(payloads[0]?.tools).toEqual([
88+
{
89+
type: "function",
90+
function: {
91+
name: "lookup",
92+
parameters: {
93+
type: "object",
94+
properties: { query: { type: "string" } },
95+
},
96+
},
97+
},
98+
]);
99+
});
100+
});

src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type AnthropicToolPayloadCompatibilityOptions = {
88
toolSchemaMode?: AnthropicToolSchemaMode;
99
toolChoiceMode?: AnthropicToolChoiceMode;
1010
};
11+
type PayloadFieldRead = { ok: true; value: unknown } | { ok: false };
1112

1213
function hasOpenAiAnthropicToolPayloadCompatFlag(model: { compat?: unknown }): boolean {
1314
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
@@ -67,30 +68,56 @@ function normalizeOpenAiFunctionAnthropicToolDefinition(
6768
}
6869

6970
const toolObj = tool as Record<string, unknown>;
70-
if (toolObj.function && typeof toolObj.function === "object") {
71+
const functionField = readPayloadField(toolObj, "function");
72+
if (!functionField.ok) {
73+
return undefined;
74+
}
75+
if (functionField.value && typeof functionField.value === "object") {
7176
return toolObj;
7277
}
7378

74-
const rawName = normalizeOptionalString(toolObj.name) ?? "";
79+
const nameField = readPayloadField(toolObj, "name");
80+
if (!nameField.ok) {
81+
return undefined;
82+
}
83+
const rawName = normalizeOptionalString(nameField.value) ?? "";
7584
if (!rawName) {
7685
return toolObj;
7786
}
7887

88+
const inputSchemaField = readPayloadField(toolObj, "input_schema");
89+
if (!inputSchemaField.ok) {
90+
return undefined;
91+
}
92+
const inputSchema = inputSchemaField.value;
93+
let parameters: unknown = { type: "object", properties: {} };
94+
if (inputSchema && typeof inputSchema === "object") {
95+
parameters = inputSchema;
96+
} else {
97+
const parametersField = readPayloadField(toolObj, "parameters");
98+
if (!parametersField.ok) {
99+
return undefined;
100+
}
101+
if (parametersField.value && typeof parametersField.value === "object") {
102+
parameters = parametersField.value;
103+
}
104+
}
79105
const functionSpec: Record<string, unknown> = {
80106
name: rawName,
81-
parameters:
82-
toolObj.input_schema && typeof toolObj.input_schema === "object"
83-
? toolObj.input_schema
84-
: toolObj.parameters && typeof toolObj.parameters === "object"
85-
? toolObj.parameters
86-
: { type: "object", properties: {} },
107+
parameters,
87108
};
88109

89-
if (typeof toolObj.description === "string" && toolObj.description.trim()) {
90-
functionSpec.description = toolObj.description;
110+
const descriptionField = readPayloadField(toolObj, "description");
111+
if (
112+
descriptionField.ok &&
113+
typeof descriptionField.value === "string" &&
114+
descriptionField.value.trim()
115+
) {
116+
functionSpec.description = descriptionField.value;
91117
}
92-
if (typeof toolObj.strict === "boolean") {
93-
functionSpec.strict = toolObj.strict;
118+
const strictField = readPayloadField(toolObj, "strict");
119+
if (strictField.ok && typeof strictField.value === "boolean") {
120+
functionSpec.strict = strictField.value;
94121
}
95122

96123
return {
@@ -99,6 +126,14 @@ function normalizeOpenAiFunctionAnthropicToolDefinition(
99126
};
100127
}
101128

129+
function readPayloadField(record: Record<string, unknown>, field: string): PayloadFieldRead {
130+
try {
131+
return { ok: true, value: Reflect.get(record, field) };
132+
} catch {
133+
return { ok: false };
134+
}
135+
}
136+
102137
function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown {
103138
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
104139
return toolChoice;

0 commit comments

Comments
 (0)