Skip to content

Commit 070839a

Browse files
lidge-junIngwannu
andcommitted
fix(anthropic): normalize tool input schemas (cherry-pick PR #76)
Cherry-pick of dae62a9 from ingw/fix-anthropic-tool-schema with review fixes: - Translate Korean Decision Log comment to English - Add known-limitation comment for oneOf/anyOf property collision Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com>
1 parent 8db9f27 commit 070839a

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

src/adapters/anthropic.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,11 +482,72 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
482482
const converted = tools.map(t => ({
483483
name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
484484
description: t.description,
485-
input_schema: t.parameters,
485+
input_schema: normalizeAnthropicInputSchema(t.parameters),
486486
}));
487487
return converted;
488488
}
489489

490+
function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
491+
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
492+
? schema as Record<string, unknown>
493+
: {};
494+
// Anthropic rejects root-level missing type and oneOf/anyOf/allOf in input_schema.
495+
// Normalize the root only: ensure type:"object" + properties, flatten root composition
496+
// while preserving nested schemas. Mirrors kiro-tools.ts ensureRootObjectType.
497+
// Known limitation: Object.assign on branch properties means later branches overwrite
498+
// earlier ones when the same property name appears with different schemas.
499+
const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
500+
const hasRootComposition = compositionKeys.some(key => Array.isArray(obj[key]));
501+
const type = obj.type;
502+
const rootObjectType = type === "object" || (Array.isArray(type) && type.includes("object"));
503+
504+
if (!hasRootComposition) {
505+
const normalized: Record<string, unknown> = rootObjectType && type === "object"
506+
? { ...obj }
507+
: { ...obj, type: "object" };
508+
if (normalized.properties === undefined || normalized.properties === null) {
509+
normalized.properties = {};
510+
}
511+
return normalized;
512+
}
513+
514+
const properties: Record<string, unknown> = {};
515+
const required = new Set<string>();
516+
if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
517+
Object.assign(properties, obj.properties as Record<string, unknown>);
518+
}
519+
if (Array.isArray(obj.required)) {
520+
for (const item of obj.required) if (typeof item === "string") required.add(item);
521+
}
522+
523+
for (const key of compositionKeys) {
524+
const variants = obj[key];
525+
if (!Array.isArray(variants)) continue;
526+
const mergeRequired = key === "allOf";
527+
for (const variant of variants) {
528+
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
529+
const v = variant as Record<string, unknown>;
530+
if (v.properties && typeof v.properties === "object" && !Array.isArray(v.properties)) {
531+
Object.assign(properties, v.properties as Record<string, unknown>);
532+
}
533+
if (mergeRequired && Array.isArray(v.required)) {
534+
for (const item of v.required) if (typeof item === "string") required.add(item);
535+
}
536+
}
537+
}
538+
539+
const normalized: Record<string, unknown> = {};
540+
for (const [key, value] of Object.entries(obj)) {
541+
if (key === "oneOf" || key === "anyOf" || key === "allOf") continue;
542+
if (key === "type" || key === "properties" || key === "required") continue;
543+
normalized[key] = value;
544+
}
545+
normalized.type = "object";
546+
normalized.properties = properties;
547+
if (required.size > 0) normalized.required = [...required];
548+
return normalized;
549+
}
550+
490551
export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long"): ProviderAdapter {
491552
const isOAuth = provider.authMode === "oauth";
492553
const toolNames = buildToolNameTransforms(provider);
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createAnthropicAdapter } from "../src/adapters/anthropic";
3+
import type { OcxParsedRequest, OcxProviderConfig } from "../src/types";
4+
5+
const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", apiKey: "sk-x", authMode: "apiKey" } as unknown as OcxProviderConfig;
6+
7+
function toolSchema(parameters: unknown): Record<string, unknown> {
8+
const { body } = createAnthropicAdapter(provider).buildRequest({
9+
modelId: "anthropic/claude-opus-4.1",
10+
stream: false,
11+
options: {},
12+
context: {
13+
messages: [{ role: "user", content: "use the tool", timestamp: 0 }],
14+
tools: [{ name: "sample_tool", description: "Sample", parameters }],
15+
},
16+
} as unknown as OcxParsedRequest);
17+
const parsed = JSON.parse(body as string) as { tools: Array<{ input_schema: Record<string, unknown> }> };
18+
return parsed.tools[0].input_schema;
19+
}
20+
21+
describe("anthropic tool input_schema normalization", () => {
22+
test("missing root type becomes an object schema with properties", () => {
23+
expect(toolSchema({ properties: { query: { type: "string" } }, required: ["query"] })).toEqual({
24+
type: "object",
25+
properties: { query: { type: "string" } },
26+
required: ["query"],
27+
});
28+
expect(toolSchema({})).toEqual({ type: "object", properties: {} });
29+
});
30+
31+
test("root oneOf and anyOf are flattened without promoting branch required fields", () => {
32+
const anyOf = toolSchema({
33+
anyOf: [
34+
{ type: "object", properties: { a: { type: "string" } }, required: ["a"] },
35+
{ type: "object", properties: { b: { type: "number" } }, required: ["b"] },
36+
],
37+
});
38+
expect(anyOf.anyOf).toBeUndefined();
39+
expect(anyOf.oneOf).toBeUndefined();
40+
expect(anyOf.allOf).toBeUndefined();
41+
expect(anyOf).toEqual({
42+
type: "object",
43+
properties: { a: { type: "string" }, b: { type: "number" } },
44+
});
45+
46+
const oneOf = toolSchema({
47+
oneOf: [
48+
{ type: "object", properties: { path: { type: "string" } }, required: ["path"] },
49+
],
50+
});
51+
expect(oneOf).toEqual({
52+
type: "object",
53+
properties: { path: { type: "string" } },
54+
});
55+
});
56+
57+
test("root allOf merges required fields and preserves sibling schema metadata", () => {
58+
expect(toolSchema({
59+
title: "Search options",
60+
additionalProperties: false,
61+
properties: { existing: { type: "string" } },
62+
required: ["existing"],
63+
allOf: [
64+
{ type: "object", properties: { limit: { type: "number" } }, required: ["limit"] },
65+
{ type: "object", properties: { sort: { type: "string" } } },
66+
],
67+
})).toEqual({
68+
title: "Search options",
69+
additionalProperties: false,
70+
type: "object",
71+
properties: {
72+
existing: { type: "string" },
73+
limit: { type: "number" },
74+
sort: { type: "string" },
75+
},
76+
required: ["existing", "limit"],
77+
});
78+
});
79+
80+
test("nested composition under properties is preserved", () => {
81+
expect(toolSchema({
82+
properties: {
83+
value: {
84+
anyOf: [{ type: "string" }, { type: "number" }],
85+
},
86+
},
87+
})).toEqual({
88+
type: "object",
89+
properties: {
90+
value: {
91+
anyOf: [{ type: "string" }, { type: "number" }],
92+
},
93+
},
94+
});
95+
});
96+
});

0 commit comments

Comments
 (0)