@tanstack/openai-base 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +2 -2
  2. package/dist/esm/adapters/chat-completions-text.d.ts +9 -1
  3. package/dist/esm/adapters/chat-completions-text.js +16 -5
  4. package/dist/esm/adapters/chat-completions-text.js.map +1 -1
  5. package/dist/esm/adapters/chat-completions-tool-converter.test.d.ts +1 -0
  6. package/dist/esm/adapters/responses-text.d.ts +9 -1
  7. package/dist/esm/adapters/responses-text.js +41 -6
  8. package/dist/esm/adapters/responses-text.js.map +1 -1
  9. package/dist/esm/adapters/responses-tool-converter.test.d.ts +1 -0
  10. package/dist/esm/index.d.ts +1 -1
  11. package/dist/esm/index.js +2 -2
  12. package/dist/esm/tools/function-tool.test.d.ts +1 -0
  13. package/dist/esm/utils/schema-converter.d.ts +14 -0
  14. package/dist/esm/utils/schema-converter.js +106 -18
  15. package/dist/esm/utils/schema-converter.js.map +1 -1
  16. package/dist/esm/utils/tool-input-normalizer.d.ts +12 -0
  17. package/dist/esm/utils/tool-input-normalizer.js +36 -0
  18. package/dist/esm/utils/tool-input-normalizer.js.map +1 -0
  19. package/dist/esm/utils/tool-input-normalizer.test.d.ts +1 -0
  20. package/package.json +3 -3
  21. package/src/adapters/chat-completions-text.ts +32 -6
  22. package/src/adapters/chat-completions-tool-converter.test.ts +64 -0
  23. package/src/adapters/responses-text.ts +81 -8
  24. package/src/adapters/responses-tool-converter.test.ts +69 -0
  25. package/src/index.ts +4 -1
  26. package/src/tools/function-tool.test.ts +62 -0
  27. package/src/utils/schema-converter.ts +165 -20
  28. package/src/utils/tool-input-normalizer.test.ts +146 -0
  29. package/src/utils/tool-input-normalizer.ts +55 -0
@@ -6,7 +6,9 @@ import {
6
6
  } from '@tanstack/ai/adapter-internals'
7
7
  import { generateId } from '@tanstack/ai-utils'
8
8
  import { extractRequestOptions } from '../utils/request-options'
9
- import { makeStructuredOutputCompatible } from '../utils/schema-converter'
9
+ import { makeStructuredOutputCompatibleWithMap } from '../utils/schema-converter'
10
+ import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
11
+ import type { StructuredOutputCompatibility } from '../utils/schema-converter'
10
12
  import { buildResponsesUsage } from '../usage'
11
13
  import { convertToolsToResponsesFormat } from './responses-tool-converter'
12
14
  import type OpenAI from 'openai'
@@ -674,15 +676,29 @@ export abstract class OpenAIBaseResponsesTextAdapter<
674
676
  )
675
677
  }
676
678
 
679
+ /**
680
+ * Strict conversion plus the inverse null-widening map for this request.
681
+ * Override this when schema conversion changes, so tool-input undo matches
682
+ * the wire schema.
683
+ */
684
+ protected makeStructuredOutputCompatibleWithMap(
685
+ schema: Record<string, any>,
686
+ originalRequired?: Array<string>,
687
+ ): StructuredOutputCompatibility {
688
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired)
689
+ }
690
+
677
691
  /**
678
692
  * Applies provider-specific transformations for structured output compatibility.
679
- * Override this in subclasses to handle provider-specific quirks.
693
+ * Override `makeStructuredOutputCompatibleWithMap` when you need the inverse map
694
+ * to match the wire schema.
680
695
  */
681
696
  protected makeStructuredOutputCompatible(
682
697
  schema: Record<string, any>,
683
698
  originalRequired?: Array<string>,
684
699
  ): Record<string, any> {
685
- return makeStructuredOutputCompatible(schema, originalRequired)
700
+ return this.makeStructuredOutputCompatibleWithMap(schema, originalRequired)
701
+ .schema
686
702
  }
687
703
 
688
704
  /**
@@ -783,6 +799,11 @@ export abstract class OpenAIBaseResponsesTextAdapter<
783
799
  hasEmittedRunStarted: boolean
784
800
  },
785
801
  ): AsyncIterable<StreamChunk> {
802
+ const normalizeToolInput = createToolInputNormalizer(
803
+ options.tools,
804
+ (schema, required) =>
805
+ this.makeStructuredOutputCompatibleWithMap(schema, required),
806
+ )
786
807
  let accumulatedContent = ''
787
808
  let accumulatedReasoning = ''
788
809
 
@@ -1069,6 +1090,17 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1069
1090
  // handle content_part added events for text, reasoning and refusals
1070
1091
  if (chunk.type === 'response.content_part.added') {
1071
1092
  const contentPart = chunk.part
1093
+ // The Responses API can announce a text part with an empty
1094
+ // placeholder before putting the actual text only on the completed
1095
+ // response. An empty placeholder is not streamed content and must
1096
+ // not suppress the completion backstop below.
1097
+ if (
1098
+ (contentPart.type === 'output_text' ||
1099
+ contentPart.type === 'reasoning_text') &&
1100
+ !contentPart.text
1101
+ ) {
1102
+ continue
1103
+ }
1072
1104
  // Emit TEXT_MESSAGE_START if this is text content
1073
1105
  if (
1074
1106
  contentPart.type === 'output_text' &&
@@ -1303,7 +1335,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1303
1335
  if (chunk.arguments) {
1304
1336
  try {
1305
1337
  const parsed = JSON.parse(chunk.arguments)
1306
- parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
1338
+ parsedInput = normalizeToolInput(
1339
+ name,
1340
+ parsed && typeof parsed === 'object' ? parsed : {},
1341
+ )
1307
1342
  } catch (parseError) {
1308
1343
  options.logger.errors(
1309
1344
  `${this.name}.processStreamChunks tool-args JSON parse failed`,
@@ -1383,8 +1418,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1383
1418
  if (rawArgs) {
1384
1419
  try {
1385
1420
  const parsed = JSON.parse(rawArgs)
1386
- parsedInput =
1387
- parsed && typeof parsed === 'object' ? parsed : {}
1421
+ parsedInput = normalizeToolInput(
1422
+ name,
1423
+ parsed && typeof parsed === 'object' ? parsed : {},
1424
+ )
1388
1425
  } catch (parseError) {
1389
1426
  options.logger.errors(
1390
1427
  `${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
@@ -1419,6 +1456,40 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1419
1456
  }
1420
1457
 
1421
1458
  if (chunk.type === 'response.completed') {
1459
+ // Some Responses API streams, notably reasoning-model responses,
1460
+ // can omit text deltas and carry the successful final text only in
1461
+ // response.completed.output. Recover that text so consumers never
1462
+ // observe an empty result for a successful response.
1463
+ const completedText = chunk.response.output
1464
+ .flatMap((item) => (item.type === 'message' ? item.content : []))
1465
+ .filter((part) => part.type === 'output_text')
1466
+ .map((part) => part.text)
1467
+ .join('')
1468
+
1469
+ if (accumulatedContent.length === 0 && completedText.length > 0) {
1470
+ if (!hasEmittedTextMessageStart) {
1471
+ hasEmittedTextMessageStart = true
1472
+ yield {
1473
+ type: EventType.TEXT_MESSAGE_START,
1474
+ messageId: aguiState.messageId,
1475
+ model: model || options.model,
1476
+ timestamp: Date.now(),
1477
+ role: 'assistant',
1478
+ }
1479
+ }
1480
+
1481
+ accumulatedContent = completedText
1482
+ hasStreamedContentDeltas = true
1483
+ yield {
1484
+ type: EventType.TEXT_MESSAGE_CONTENT,
1485
+ messageId: aguiState.messageId,
1486
+ model: model || options.model,
1487
+ timestamp: Date.now(),
1488
+ delta: completedText,
1489
+ content: accumulatedContent,
1490
+ }
1491
+ }
1492
+
1422
1493
  // Final backstop for function_call lifecycle: if a function_call
1423
1494
  // appears in `response.output[]` but was never matched by an
1424
1495
  // output_item.added/done with a name, recover the missing START
@@ -1466,8 +1537,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
1466
1537
  if (rawArgs) {
1467
1538
  try {
1468
1539
  const parsed = JSON.parse(rawArgs)
1469
- parsedInput =
1470
- parsed && typeof parsed === 'object' ? parsed : {}
1540
+ parsedInput = normalizeToolInput(
1541
+ name,
1542
+ parsed && typeof parsed === 'object' ? parsed : {},
1543
+ )
1471
1544
  } catch (parseError) {
1472
1545
  options.logger.errors(
1473
1546
  `${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convertFunctionToolToResponsesFormat } from './responses-tool-converter'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('responses tool converter', () => {
6
+ it('falls back from strict mode when an anyOf variant needs null widening', () => {
7
+ const out = convertFunctionToolToResponsesFormat(anyOfOptionalVariantTool)
8
+
9
+ expect(out.strict).toBe(false)
10
+ expect(out.parameters).toMatchObject({
11
+ properties: {
12
+ value: {
13
+ anyOf: [{ required: ['kind'] }, { required: ['kind', 'note'] }],
14
+ },
15
+ },
16
+ })
17
+ })
18
+
19
+ it('falls back from strict mode for boolean schema nodes', () => {
20
+ const out = convertFunctionToolToResponsesFormat(booleanSchemaTool)
21
+
22
+ expect(out.strict).toBe(false)
23
+ expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
24
+ })
25
+ })
26
+
27
+ const booleanSchemaInput = {
28
+ type: 'object',
29
+ properties: {},
30
+ required: [],
31
+ }
32
+ Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)
33
+
34
+ const booleanSchemaTool = {
35
+ name: 'accept_anything',
36
+ description: 'Accept any value',
37
+ inputSchema: booleanSchemaInput,
38
+ } satisfies Tool
39
+
40
+ const anyOfOptionalVariantTool: Tool = {
41
+ name: 'store_variant',
42
+ description: 'Store a union variant',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ value: {
47
+ anyOf: [
48
+ {
49
+ type: 'object',
50
+ properties: {
51
+ kind: { const: 'optional' },
52
+ note: { type: 'string' },
53
+ },
54
+ required: ['kind'],
55
+ },
56
+ {
57
+ type: 'object',
58
+ properties: {
59
+ kind: { const: 'nullable' },
60
+ note: { type: ['string', 'null'] },
61
+ },
62
+ required: ['kind', 'note'],
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ required: ['value'],
68
+ },
69
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- export { makeStructuredOutputCompatible } from './utils/schema-converter'
1
+ export {
2
+ makeStructuredOutputCompatible,
3
+ makeStructuredOutputCompatibleWithMap,
4
+ } from './utils/schema-converter'
2
5
  export {
3
6
  buildChatCompletionsUsage,
4
7
  buildResponsesUsage,
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { convertFunctionToolToAdapterFormat } from './function-tool'
3
+ import type { Tool } from '@tanstack/ai'
4
+
5
+ describe('function-tool adapter converter', () => {
6
+ it('falls back from strict mode when an anyOf variant needs null widening', () => {
7
+ const out = convertFunctionToolToAdapterFormat(anyOfOptionalVariantTool)
8
+
9
+ expect(out.strict).toBe(false)
10
+ })
11
+
12
+ it('falls back from strict mode for boolean schema nodes', () => {
13
+ const out = convertFunctionToolToAdapterFormat(booleanSchemaTool)
14
+
15
+ expect(out.strict).toBe(false)
16
+ expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
17
+ })
18
+ })
19
+
20
+ const booleanSchemaInput = {
21
+ type: 'object',
22
+ properties: {},
23
+ required: [],
24
+ }
25
+ Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)
26
+
27
+ const booleanSchemaTool = {
28
+ name: 'accept_anything',
29
+ description: 'Accept any value',
30
+ inputSchema: booleanSchemaInput,
31
+ } satisfies Tool
32
+
33
+ const anyOfOptionalVariantTool: Tool = {
34
+ name: 'store_variant',
35
+ description: 'Store a union variant',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ value: {
40
+ anyOf: [
41
+ {
42
+ type: 'object',
43
+ properties: {
44
+ kind: { const: 'optional' },
45
+ note: { type: 'string' },
46
+ },
47
+ required: ['kind'],
48
+ },
49
+ {
50
+ type: 'object',
51
+ properties: {
52
+ kind: { const: 'nullable' },
53
+ note: { type: ['string', 'null'] },
54
+ },
55
+ required: ['kind', 'note'],
56
+ },
57
+ ],
58
+ },
59
+ },
60
+ required: ['value'],
61
+ },
62
+ }
@@ -1,3 +1,5 @@
1
+ import type { NullWideningMap } from '@tanstack/ai-utils'
2
+
1
3
  /**
2
4
  * String `format` values accepted by OpenAI's strict Structured Outputs subset.
3
5
  * Any other format (e.g. "uri", "uri-reference", "regex") causes the API to
@@ -61,7 +63,35 @@ export function makeStructuredOutputCompatible(
61
63
  schema: Record<string, any>,
62
64
  originalRequired?: Array<string>,
63
65
  ): Record<string, any> {
64
- return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))
66
+ return makeStructuredOutputCompatibleWithMap(schema, originalRequired).schema
67
+ }
68
+
69
+ export interface StructuredOutputCompatibility {
70
+ schema: Record<string, any>
71
+ nullWideningMap: NullWideningMap | undefined
72
+ }
73
+
74
+ interface CoercedStrictSchema extends StructuredOutputCompatibility {
75
+ hasUntrackableAnyOfWidening: boolean
76
+ }
77
+
78
+ /**
79
+ * Strict-schema conversion plus an exact map of the nullability introduced by
80
+ * that conversion. Consumers can pass provider output through
81
+ * `undoNullWidening` before validating it against the original schema.
82
+ */
83
+ export function makeStructuredOutputCompatibleWithMap(
84
+ schema: Record<string, any>,
85
+ originalRequired?: Array<string>,
86
+ ): StructuredOutputCompatibility {
87
+ const { schema: strictSchema, nullWideningMap } = coerceStrictSchema(
88
+ schema,
89
+ originalRequired,
90
+ )
91
+ return {
92
+ schema: stripUnsupportedFormats(strictSchema),
93
+ nullWideningMap,
94
+ }
65
95
  }
66
96
 
67
97
  /**
@@ -118,6 +148,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
118
148
  * 3. It contains an open object schema. OpenAI strict mode requires objects to
119
149
  * set `additionalProperties: false`, which would change the semantics of a
120
150
  * free-form map rather than merely normalizing it.
151
+ * 4. An `anyOf` variant itself needs null widening. The inverse map is
152
+ * intentionally schema-blind, so it cannot select a variant without risking
153
+ * removal of a genuine nullable value accepted by another variant.
121
154
  *
122
155
  * Conservative by design: for (1) keywords are matched as object keys, so a
123
156
  * property literally named e.g. `oneOf` also trips it. That only costs that one
@@ -128,10 +161,24 @@ export function isStrictModeCompatible(schema: unknown): boolean {
128
161
  return (
129
162
  !containsStrictUnsupportedKeyword(schema) &&
130
163
  !containsTypelessSchema(schema) &&
131
- !containsOpenObject(schema)
164
+ !containsOpenObject(schema) &&
165
+ !containsUntrackableAnyOfWidening(schema)
132
166
  )
133
167
  }
134
168
 
169
+ /**
170
+ * Reports strict conversions whose synthesized nulls cannot be represented by
171
+ * the schema-blind inverse map. Optional `anyOf` wrappers remain supported:
172
+ * only widening introduced inside one of their variants triggers fallback.
173
+ */
174
+ function containsUntrackableAnyOfWidening(schema: unknown): boolean {
175
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
176
+ return false
177
+ }
178
+ return coerceStrictSchema(schema as Record<string, any>)
179
+ .hasUntrackableAnyOfWidening
180
+ }
181
+
135
182
  /**
136
183
  * Reports object schemas that cannot be closed without changing their input
137
184
  * semantics. Objects with `properties` and no explicit
@@ -184,8 +231,10 @@ function containsStrictUnsupportedKeyword(node: unknown): boolean {
184
231
  /** A schema-position node that declares no type and so 400s strict mode. */
185
232
  function isTypelessSchema(node: unknown): boolean {
186
233
  if (node === null || typeof node !== 'object' || Array.isArray(node)) {
187
- // boolean schemas (`true`/`false`) and non-objects aren't typeless props.
188
- return false
234
+ // JSON Schema permits bare boolean nodes; malformed inputs may contain
235
+ // other primitives. OpenAI's strict subset requires a declared type, so
236
+ // preserve the containing tool by sending it in non-strict mode.
237
+ return true
189
238
  }
190
239
  return !TYPE_INDICATOR_KEYWORDS.some((key) => key in node)
191
240
  }
@@ -225,11 +274,42 @@ function containsTypelessSchema(node: unknown): boolean {
225
274
  * additionalProperties). Kept private so the public entry point can apply the
226
275
  * format-stripping pass exactly once over the fully-rewritten tree.
227
276
  */
277
+ function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
278
+ return Object.keys(map).length > 0 ? map : undefined
279
+ }
280
+
281
+ function isSchemaObject(schema: unknown): schema is Record<string, any> {
282
+ return typeof schema === 'object' && schema !== null && !Array.isArray(schema)
283
+ }
284
+
285
+ /** Whether every active JSON Schema constraint at this node admits null. */
286
+ function acceptsNull(schema: unknown): boolean {
287
+ if (schema === true) return true
288
+ if (!isSchemaObject(schema)) return false
289
+
290
+ if ('const' in schema && schema.const !== null) return false
291
+ if (Array.isArray(schema.enum) && !schema.enum.includes(null)) return false
292
+
293
+ if (typeof schema.type === 'string' && schema.type !== 'null') return false
294
+ if (Array.isArray(schema.type) && !schema.type.includes('null')) return false
295
+
296
+ if (
297
+ Array.isArray(schema.anyOf) &&
298
+ !schema.anyOf.some((variant: unknown) => acceptsNull(variant))
299
+ ) {
300
+ return false
301
+ }
302
+
303
+ return true
304
+ }
305
+
228
306
  function coerceStrictSchema(
229
307
  schema: Record<string, any>,
230
308
  originalRequired?: Array<string>,
231
- ): Record<string, any> {
309
+ ): CoercedStrictSchema {
232
310
  const result = { ...schema }
311
+ const nullWideningMap: NullWideningMap = {}
312
+ let hasUntrackableAnyOfWidening = false
233
313
  const required =
234
314
  originalRequired ??
235
315
  (Array.isArray(result['required']) ? result['required'] : [])
@@ -237,22 +317,36 @@ function coerceStrictSchema(
237
317
  if (result.type === 'object' && result.properties) {
238
318
  const properties = { ...result.properties }
239
319
  const allPropertyNames = Object.keys(properties)
320
+ const propertyMaps: Record<string, NullWideningMap> = {}
240
321
 
241
322
  for (const propName of allPropertyNames) {
242
323
  let prop = properties[propName]
243
324
  const wasOptional = !required.includes(propName)
325
+ let childMap: NullWideningMap | undefined
326
+ let widenedHere = false
244
327
 
245
328
  // Step 1: Recurse into nested structures
246
- if (prop.type === 'object' && prop.properties) {
247
- prop = coerceStrictSchema(prop, prop.required || [])
248
- } else if (prop.type === 'array' && prop.items) {
329
+ if (isSchemaObject(prop) && prop.type === 'object' && prop.properties) {
330
+ const nested = coerceStrictSchema(prop, prop.required || [])
331
+ prop = nested.schema
332
+ childMap = nested.nullWideningMap
333
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
334
+ } else if (isSchemaObject(prop) && prop.type === 'array' && prop.items) {
335
+ const nested = coerceStrictSchema(prop.items, prop.items.required || [])
249
336
  prop = {
250
337
  ...prop,
251
- items: coerceStrictSchema(prop.items, prop.items.required || []),
338
+ items: nested.schema,
252
339
  }
253
- } else if (prop.anyOf) {
254
- prop = coerceStrictSchema(prop, prop.required || [])
255
- } else if (prop.oneOf) {
340
+ childMap = nested.nullWideningMap
341
+ ? { items: nested.nullWideningMap }
342
+ : undefined
343
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
344
+ } else if (isSchemaObject(prop) && prop.anyOf) {
345
+ const nested = coerceStrictSchema(prop, prop.required || [])
346
+ prop = nested.schema
347
+ childMap = nested.nullWideningMap
348
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
349
+ } else if (isSchemaObject(prop) && prop.oneOf) {
256
350
  throw new Error(
257
351
  'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',
258
352
  )
@@ -260,34 +354,81 @@ function coerceStrictSchema(
260
354
 
261
355
  // Step 2: Apply null-widening for optional properties (after recursion)
262
356
  if (wasOptional) {
263
- if (prop.anyOf) {
264
- // For anyOf, add a null variant if not already present
265
- if (!prop.anyOf.some((v: any) => v.type === 'null')) {
357
+ const originallyAcceptedNull = acceptsNull(prop)
358
+
359
+ // `type: [..., 'null']` alone does not make null valid when an enum or
360
+ // const still excludes it; strict decoding would be forced to emit the
361
+ // original literal instead of the synthetic omission marker.
362
+ if (isSchemaObject(prop) && 'const' in prop && prop.const !== null) {
363
+ const { const: constValue, ...withoutConst } = prop
364
+ prop = { ...withoutConst, enum: [constValue, null] }
365
+ } else if (
366
+ isSchemaObject(prop) &&
367
+ Array.isArray(prop.enum) &&
368
+ !prop.enum.includes(null)
369
+ ) {
370
+ prop = { ...prop, enum: [...prop.enum, null] }
371
+ }
372
+
373
+ if (isSchemaObject(prop) && prop.anyOf) {
374
+ // A genuine null branch can use type, enum, or const. Only add a
375
+ // provider omission marker when the original union rejected null.
376
+ if (!acceptsNull(prop)) {
266
377
  prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] }
267
378
  }
268
- } else if (prop.type && !Array.isArray(prop.type)) {
379
+ } else if (
380
+ isSchemaObject(prop) &&
381
+ prop.type &&
382
+ !Array.isArray(prop.type)
383
+ ) {
269
384
  prop = { ...prop, type: [prop.type, 'null'] }
270
- } else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
385
+ } else if (
386
+ isSchemaObject(prop) &&
387
+ Array.isArray(prop.type) &&
388
+ !prop.type.includes('null')
389
+ ) {
271
390
  prop = { ...prop, type: [...prop.type, 'null'] }
272
391
  }
392
+
393
+ widenedHere = !originallyAcceptedNull && acceptsNull(prop)
273
394
  }
274
395
 
275
396
  properties[propName] = prop
397
+ if (childMap || widenedHere) {
398
+ propertyMaps[propName] = {
399
+ ...(childMap ?? {}),
400
+ ...(widenedHere ? { widened: true } : {}),
401
+ }
402
+ }
276
403
  }
277
404
 
278
405
  result.properties = properties
279
406
  result.required = allPropertyNames
280
407
  result.additionalProperties = false
408
+ if (Object.keys(propertyMaps).length > 0) {
409
+ nullWideningMap.properties = propertyMaps
410
+ }
281
411
  }
282
412
 
283
413
  if (result.type === 'array' && result.items) {
284
- result.items = coerceStrictSchema(result.items, result.items.required || [])
414
+ const nested = coerceStrictSchema(result.items, result.items.required || [])
415
+ result.items = nested.schema
416
+ if (nested.nullWideningMap) {
417
+ nullWideningMap.items = nested.nullWideningMap
418
+ }
419
+ hasUntrackableAnyOfWidening ||= nested.hasUntrackableAnyOfWidening
285
420
  }
286
421
 
287
422
  if (result.anyOf && Array.isArray(result.anyOf)) {
288
- result.anyOf = result.anyOf.map((variant) =>
423
+ const variants = result.anyOf.map((variant) =>
289
424
  coerceStrictSchema(variant, variant.required || []),
290
425
  )
426
+ result.anyOf = variants.map((variant) => variant.schema)
427
+ hasUntrackableAnyOfWidening ||= variants.some(
428
+ (variant) =>
429
+ variant.nullWideningMap !== undefined ||
430
+ variant.hasUntrackableAnyOfWidening,
431
+ )
291
432
  }
292
433
 
293
434
  if (result.oneOf) {
@@ -296,5 +437,9 @@ function coerceStrictSchema(
296
437
  )
297
438
  }
298
439
 
299
- return result
440
+ return {
441
+ schema: result,
442
+ nullWideningMap: pruneMap(nullWideningMap),
443
+ hasUntrackableAnyOfWidening,
444
+ }
300
445
  }