@@ -90,19 +90,34 @@ function collapseConstUnion(node: Record<string, unknown>): void {
9090 node . enum = values ;
9191}
9292
93+ /**
94+ * Collapse a root-level object union into one object schema.
95+ *
96+ * A tool's root parameters schema must be a plain `{"type":"object"}` schema:
97+ * OpenAI-compatible gateways reject a root that only carries `anyOf`/`oneOf`
98+ * with `tools.function.parameters.type is required and must be "object"`. The
99+ * merge is intentionally lossy in the direction of permissiveness — branch
100+ * exclusivity becomes advisory — but it must never lose a declared parameter,
101+ * so the root's own `properties` and `required` are merged with the branches'
102+ * rather than replaced by them.
103+ */
93104function mergeRootObjectUnion ( schema : Record < string , unknown > ) : Record < string , unknown > | undefined {
94105 const branches = Array . isArray ( schema . anyOf ) ? schema . anyOf : Array . isArray ( schema . oneOf ) ? schema . oneOf : undefined ;
95106 if ( branches === undefined || branches . length === 0 ) return undefined ;
107+ if ( schema . properties !== undefined && ! isJsonObject ( schema . properties ) ) return undefined ;
108+ if ( schema . required !== undefined && ! Array . isArray ( schema . required ) ) return undefined ;
96109
97110 const objectBranches : Record < string , unknown > [ ] = [ ] ;
98111 for ( const branch of branches ) {
99- if ( ! isJsonObject ( branch ) || branch . type !== "object" ) return undefined ;
112+ // An untyped branch is a constraint-only variant (e.g. `{ required: [...] }`)
113+ // over the root's own properties, so it merges like an object branch.
114+ if ( ! isJsonObject ( branch ) || ( branch . type !== "object" && branch . type !== undefined ) ) return undefined ;
100115 if ( branch . properties !== undefined && ! isJsonObject ( branch . properties ) ) return undefined ;
101116 if ( branch . required !== undefined && ! Array . isArray ( branch . required ) ) return undefined ;
102117 objectBranches . push ( branch ) ;
103118 }
104119
105- const properties : Record < string , unknown > = { } ;
120+ const properties : Record < string , unknown > = isJsonObject ( schema . properties ) ? { ... schema . properties } : { } ;
106121 for ( const branch of objectBranches ) {
107122 if ( ! isJsonObject ( branch . properties ) ) continue ;
108123 for ( const [ name , propertySchema ] of Object . entries ( branch . properties ) ) {
@@ -114,29 +129,36 @@ function mergeRootObjectUnion(schema: Record<string, unknown>): Record<string, u
114129 }
115130 }
116131
117- const firstRequired = objectBranches [ 0 ] ?. required ;
118- let commonRequired = Array . isArray ( firstRequired )
119- ? firstRequired . filter ( ( name ) : name is string => typeof name === "string" )
132+ // Only names required by EVERY branch stay required; a name required by one
133+ // branch alone would reject payloads the union accepts. Root-level `required`
134+ // applies to all branches, so it is unioned back in.
135+ const rootRequired = Array . isArray ( schema . required )
136+ ? schema . required . filter ( ( name ) : name is string => typeof name === "string" )
120137 : [ ] ;
121- for ( const branch of objectBranches . slice ( 1 ) ) {
122- const branchRequired = new Set (
123- Array . isArray ( branch . required )
124- ? branch . required . filter ( ( name ) : name is string => typeof name === "string" )
125- : [ ] ,
126- ) ;
127- commonRequired = commonRequired . filter ( ( name ) => branchRequired . has ( name ) ) ;
128- }
138+ const branchRequiredSets = objectBranches . map (
139+ ( branch ) =>
140+ new Set (
141+ Array . isArray ( branch . required )
142+ ? branch . required . filter ( ( name ) : name is string => typeof name === "string" )
143+ : [ ] ,
144+ ) ,
145+ ) ;
146+ const firstBranchRequired = branchRequiredSets [ 0 ] ;
147+ const commonBranchRequired = firstBranchRequired
148+ ? [ ...firstBranchRequired ] . filter ( ( name ) => branchRequiredSets . every ( ( names ) => names . has ( name ) ) )
149+ : [ ] ;
150+ const required = [ ...new Set ( [ ...rootRequired , ...commonBranchRequired ] ) ] ;
129151
130152 const { anyOf : _anyOf , oneOf : _oneOf , ...rest } = schema ;
131153 return {
132154 ...rest ,
133155 type : "object" ,
134156 properties,
135- ...( commonRequired . length > 0 ? { required : commonRequired } : { } ) ,
157+ ...( required . length > 0 ? { required } : { } ) ,
136158 } ;
137159}
138160
139- function normalizeNode ( node : unknown ) : unknown {
161+ function normalizeNode ( node : unknown , isRoot = false ) : unknown {
140162 if ( Array . isArray ( node ) ) {
141163 return node . map ( ( child ) => normalizeNode ( child ) ) ;
142164 }
@@ -146,7 +168,9 @@ function normalizeNode(node: unknown): unknown {
146168 }
147169
148170 const hasCombiner = COMBINER_KEYS . some ( ( key ) => Array . isArray ( node [ key ] ) ) ;
149- if ( hasCombiner ) {
171+ // The root of a tool's parameters must keep `type: "object"`; hoisting it into
172+ // the branches leaves a typeless root that gateways reject outright.
173+ if ( hasCombiner && ! isRoot ) {
150174 moveTypeIntoCombinerBranches ( node ) ;
151175 }
152176
@@ -183,16 +207,46 @@ function normalizeNode(node: unknown): unknown {
183207 * for OpenAI-compatible Chat Completions backends.
184208 */
185209export function normalizeToolParametersForOpenAICompat ( schema : Record < string , unknown > ) : Record < string , unknown > {
186- return normalizeNode ( structuredClone ( schema ) ) as Record < string , unknown > ;
210+ const normalized = normalizeNode ( structuredClone ( schema ) , true ) as Record < string , unknown > ;
211+ return ensureRootObjectSchema ( normalized ) ;
212+ }
213+
214+ /**
215+ * Guarantee the wire shape every OpenAI-compatible backend requires for tool
216+ * parameters: a root object schema. A root union of object shapes is merged into
217+ * one object schema; a root that merely lost its `type` gets it restored.
218+ *
219+ * A root whose branches are not object shapes is left alone: forcing
220+ * `type: "object"` onto a scalar union would assert something the schema
221+ * contradicts, which is worse than the missing keyword. Tool parameters are
222+ * objects in practice, so this only guards against corrupting an exotic schema.
223+ */
224+ function ensureRootObjectSchema ( schema : Record < string , unknown > ) : Record < string , unknown > {
225+ const merged = mergeRootObjectUnion ( schema ) ;
226+ if ( merged ) return merged ;
227+ if ( schema . type !== undefined ) return schema ;
228+ const hasCombiner = COMBINER_KEYS . some ( ( key ) => Array . isArray ( schema [ key ] ) ) ;
229+ if ( hasCombiner ) return schema ;
230+ return { ...schema , type : "object" } ;
187231}
188232
189233/**
190234 * Moonshot-flavored JSON Schema subset: in addition to the OpenAI-compatible
191235 * normalization, drop non-structural annotation keywords that Moonshot rejects.
192236 */
193237export function normalizeToolParametersForMoonshot ( schema : Record < string , unknown > ) : Record < string , unknown > {
194- const normalized = normalizeToolParametersForOpenAICompat ( schema ) ;
195- return stripMoonshotAnnotations ( mergeRootObjectUnion ( normalized ) ?? normalized ) ;
238+ return stripMoonshotAnnotations ( normalizeToolParametersForOpenAICompat ( schema ) ) ;
239+ }
240+
241+ /**
242+ * Resolve a tool's root parameters into a single object schema, without the
243+ * OpenAI-specific rewrites. Wire formats that read a tool's parameters from
244+ * top-level `properties`/`required` need this: a root union carries neither, so
245+ * they would otherwise describe the tool to the model as taking no arguments.
246+ * Schemas that are already plain objects are returned untouched.
247+ */
248+ export function resolveRootObjectSchema ( schema : Record < string , unknown > ) : Record < string , unknown > {
249+ return mergeRootObjectUnion ( structuredClone ( schema ) ) ?? schema ;
196250}
197251
198252function stripMoonshotAnnotations ( node : unknown ) : Record < string , unknown > {
0 commit comments