Skip to content

Commit ee5d919

Browse files
fix(providers): set additionalProperties to false in strict schemas
Strict mode requires additionalProperties: false on every object schema. stripUnsupportedStrictKeywords() removed a true or schema value, but it did not set false. convertToolSchemaForOpenAI() does not go into anyOf. Thus an object in anyOf had no additionalProperties value. - Set additionalProperties to false. Keep the removed value in the description. - Do not go into the additionalProperties value. The function always removes a schema value. - Add focused tests for each keyword list, each branch, and the description note.
1 parent 3353b7e commit ee5d919

3 files changed

Lines changed: 219 additions & 8 deletions

File tree

‎src/api/providers/__tests__/base-provider.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,7 +478,7 @@ describe("BaseProvider", () => {
478478
expect(parameters.else.minLength).toBeUndefined()
479479
})
480480

481-
it("removes additionalProperties values other than false in nested schemas", () => {
481+
it("replaces additionalProperties values other than false in nested schemas", () => {
482482
const tools = [
483483
{
484484
type: "function",
@@ -502,8 +502,8 @@ describe("BaseProvider", () => {
502502
const result = provider.testConvertToolsForOpenAI(tools)
503503
const variants = result?.[0].function.parameters.properties.options.anyOf
504504

505-
expect(variants[0].additionalProperties).toBeUndefined()
506-
expect(variants[1].additionalProperties).toBeUndefined()
505+
expect(variants[0].additionalProperties).toBe(false)
506+
expect(variants[1].additionalProperties).toBe(false)
507507
expect(variants[0].description).toBe('Constraints: {"additionalProperties":true}')
508508
expect(variants[1].description).toBe('Constraints: {"additionalProperties":{"type":"string"}}')
509509
})
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// npx vitest run api/transform/__tests__/strict-tool-schema.spec.ts
2+
3+
import { stripUnsupportedStrictKeywords } from "../strict-tool-schema"
4+
5+
type Schema = Record<string, unknown>
6+
7+
function strip(schema: Schema): Schema {
8+
return stripUnsupportedStrictKeywords(schema) as Schema
9+
}
10+
11+
function constraintNote(removed: Schema): string {
12+
return `Constraints: ${JSON.stringify(removed)}`
13+
}
14+
15+
describe("stripUnsupportedStrictKeywords", () => {
16+
it.each([
17+
["minimum", 1],
18+
["maximum", 9],
19+
["exclusiveMinimum", 0],
20+
["exclusiveMaximum", 10],
21+
["multipleOf", 2],
22+
["minLength", 1],
23+
["maxLength", 5],
24+
["maxItems", 4],
25+
["uniqueItems", true],
26+
["contains", { type: "string" }],
27+
["minContains", 1],
28+
["maxContains", 3],
29+
["minProperties", 1],
30+
["maxProperties", 2],
31+
])("removes %s and records it in the description", (keyword, value) => {
32+
expect(strip({ type: "string", [keyword]: value })).toEqual({
33+
type: "string",
34+
description: constraintNote({ [keyword]: value }),
35+
})
36+
})
37+
38+
it.each([0, 1])("keeps minItems %s", (minItems) => {
39+
expect(strip({ type: "array", minItems })).toEqual({ type: "array", minItems })
40+
})
41+
42+
it.each([2, "1", null])("removes unsupported minItems %j", (minItems) => {
43+
expect(strip({ type: "array", minItems })).toEqual({
44+
type: "array",
45+
description: constraintNote({ minItems }),
46+
})
47+
})
48+
49+
it.each(["date-time", "time", "date", "duration", "email", "hostname", "uri", "ipv4", "ipv6", "uuid"])(
50+
"keeps supported format %s",
51+
(format) => {
52+
expect(strip({ type: "string", format })).toEqual({ type: "string", format })
53+
},
54+
)
55+
56+
it.each(["phone", 1, null])("removes unsupported format %j", (format) => {
57+
expect(strip({ type: "string", format })).toEqual({
58+
type: "string",
59+
description: constraintNote({ format }),
60+
})
61+
})
62+
63+
it("keeps additionalProperties false", () => {
64+
const schema = { type: "object", properties: {}, additionalProperties: false }
65+
66+
expect(strip(schema)).toEqual(schema)
67+
})
68+
69+
it.each([true, { type: "string" }])(
70+
"replaces additionalProperties %j with false and records the original value",
71+
(additionalProperties) => {
72+
expect(strip({ type: "object", properties: {}, additionalProperties })).toEqual({
73+
type: "object",
74+
properties: {},
75+
additionalProperties: false,
76+
description: constraintNote({ additionalProperties }),
77+
})
78+
},
79+
)
80+
81+
it("replaces additionalProperties with false in objects inside anyOf", () => {
82+
const result = strip({
83+
anyOf: [
84+
{ type: "object", properties: {}, additionalProperties: true },
85+
{ type: "object", properties: {}, additionalProperties: { type: "string" } },
86+
],
87+
})
88+
89+
expect(result.anyOf).toEqual([
90+
{
91+
type: "object",
92+
properties: {},
93+
additionalProperties: false,
94+
description: constraintNote({ additionalProperties: true }),
95+
},
96+
{
97+
type: "object",
98+
properties: {},
99+
additionalProperties: false,
100+
description: constraintNote({ additionalProperties: { type: "string" } }),
101+
},
102+
])
103+
})
104+
105+
it.each(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"])(
106+
"strips each subschema in the %s map",
107+
(keyword) => {
108+
expect(strip({ [keyword]: { value: { type: "string", maxLength: 5 } } })).toEqual({
109+
[keyword]: { value: { type: "string", description: constraintNote({ maxLength: 5 }) } },
110+
})
111+
},
112+
)
113+
114+
it.each(["anyOf", "oneOf", "allOf", "prefixItems"])("strips each subschema in the %s list", (keyword) => {
115+
expect(strip({ [keyword]: [{ type: "integer", minimum: 0 }, { type: "null" }] })).toEqual({
116+
[keyword]: [{ type: "integer", description: constraintNote({ minimum: 0 }) }, { type: "null" }],
117+
})
118+
})
119+
120+
it.each([
121+
"items",
122+
"additionalItems",
123+
"unevaluatedItems",
124+
"unevaluatedProperties",
125+
"propertyNames",
126+
"not",
127+
"if",
128+
"then",
129+
"else",
130+
])("strips the %s subschema", (keyword) => {
131+
expect(strip({ [keyword]: { type: "string", minLength: 1 } })).toEqual({
132+
[keyword]: { type: "string", description: constraintNote({ minLength: 1 }) },
133+
})
134+
})
135+
136+
it("strips each subschema in tuple-form items", () => {
137+
expect(strip({ items: [{ type: "string", maxLength: 2 }, { type: "integer" }] })).toEqual({
138+
items: [{ type: "string", description: constraintNote({ maxLength: 2 }) }, { type: "integer" }],
139+
})
140+
})
141+
142+
it.each([
143+
["default", { minimum: 1 }],
144+
["const", { maxLength: 2 }],
145+
["examples", [{ maxItems: 3 }]],
146+
["enum", [{ minimum: 0 }]],
147+
["anyOf", { maxLength: 1 }],
148+
["properties", [{ maxLength: 1 }]],
149+
])("does not treat the %s value %j as a schema", (keyword, value) => {
150+
const schema = { [keyword]: value }
151+
152+
expect(strip(schema)).toEqual(schema)
153+
})
154+
155+
it("keeps properties whose names match unsupported keywords", () => {
156+
expect(strip({ properties: { maxItems: { type: "integer" }, minimum: { type: "number" } } })).toEqual({
157+
properties: { maxItems: { type: "integer" }, minimum: { type: "number" } },
158+
})
159+
})
160+
161+
it.each([
162+
["a missing", undefined],
163+
["an empty", ""],
164+
["a non-string", 42],
165+
])("uses only the constraint note for %s description", (_label, description) => {
166+
expect(strip({ type: "integer", description, minimum: 1 }).description).toBe(constraintNote({ minimum: 1 }))
167+
})
168+
169+
it("appends the constraint note to an existing description", () => {
170+
expect(strip({ type: "integer", description: "Count.", minimum: 1 }).description).toBe(
171+
`Count.\n\n${constraintNote({ minimum: 1 })}`,
172+
)
173+
})
174+
175+
it("records all removed constraints of one schema in one note", () => {
176+
expect(strip({ type: "number", minimum: 1, maximum: 9 })).toEqual({
177+
type: "number",
178+
description: constraintNote({ minimum: 1, maximum: 9 }),
179+
})
180+
})
181+
182+
it("does not add a description when nothing is removed", () => {
183+
expect(strip({ type: "string" })).toEqual({ type: "string" })
184+
})
185+
186+
it.each([
187+
["null", null],
188+
["a string", "text"],
189+
["a boolean", true],
190+
["an array", [{ maxLength: 1 }]],
191+
])("returns %s unchanged", (_label, value) => {
192+
expect(stripUnsupportedStrictKeywords(value)).toBe(value)
193+
})
194+
195+
it("does not mutate the input schema", () => {
196+
const schema = {
197+
type: "object",
198+
properties: { name: { type: "string", maxLength: 5 } },
199+
additionalProperties: true,
200+
}
201+
const snapshot = structuredClone(schema)
202+
203+
strip(schema)
204+
205+
expect(schema).toEqual(snapshot)
206+
})
207+
})

‎src/api/transform/strict-tool-schema.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const UNSUPPORTED_STRICT_SCHEMA_KEYWORDS = new Set([
1515
"maxProperties",
1616
])
1717

18-
const SUPPORTED_MIN_ITEMS = new Set([0, 1])
19-
const SUPPORTED_STRING_FORMATS = new Set([
18+
const SUPPORTED_MIN_ITEMS = new Set<unknown>([0, 1])
19+
const SUPPORTED_STRING_FORMATS = new Set<unknown>([
2020
"date-time",
2121
"time",
2222
"date",
@@ -34,7 +34,6 @@ const SCHEMA_LIST_KEYWORDS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"])
3434
const SCHEMA_VALUE_KEYWORDS = new Set([
3535
"items",
3636
"additionalItems",
37-
"additionalProperties",
3837
"unevaluatedItems",
3938
"unevaluatedProperties",
4039
"propertyNames",
@@ -53,10 +52,10 @@ function isUnsupportedKeyword(key: string, value: unknown): boolean {
5352
return true
5453
}
5554
if (key === "minItems") {
56-
return typeof value !== "number" || !SUPPORTED_MIN_ITEMS.has(value)
55+
return !SUPPORTED_MIN_ITEMS.has(value)
5756
}
5857
if (key === "format") {
59-
return typeof value !== "string" || !SUPPORTED_STRING_FORMATS.has(value)
58+
return !SUPPORTED_STRING_FORMATS.has(value)
6059
}
6160
if (key === "additionalProperties") {
6261
return value !== false
@@ -67,6 +66,8 @@ function isUnsupportedKeyword(key: string, value: unknown): boolean {
6766
/**
6867
* Removes constraints that Anthropic and Bedrock reject for strict tool use.
6968
* Removed constraints are retained in descriptions so models can still follow them.
69+
* An unsupported additionalProperties value becomes false, because strict mode
70+
* requires additionalProperties: false on every object schema.
7071
*/
7172
export function stripUnsupportedStrictKeywords(schema: unknown): unknown {
7273
if (!isRecord(schema)) {
@@ -79,6 +80,9 @@ export function stripUnsupportedStrictKeywords(schema: unknown): unknown {
7980
for (const [key, originalValue] of Object.entries(schema)) {
8081
if (isUnsupportedKeyword(key, originalValue)) {
8182
removed[key] = originalValue
83+
if (key === "additionalProperties") {
84+
result[key] = false
85+
}
8286
continue
8387
}
8488

0 commit comments

Comments
 (0)