transform.mjs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. export function toStrictJsonSchema(schema) {
  2. if (schema.type !== 'object') {
  3. throw new Error(`Root schema must have type: 'object' but got type: ${schema.type ? `'${schema.type}'` : 'undefined'}`);
  4. }
  5. const schemaCopy = structuredClone(schema);
  6. return ensureStrictJsonSchema(schemaCopy, [], schemaCopy);
  7. }
  8. function isNullable(schema) {
  9. if (typeof schema === 'boolean') {
  10. return false;
  11. }
  12. if (schema.type === 'null') {
  13. return true;
  14. }
  15. for (const oneOfVariant of schema.oneOf ?? []) {
  16. if (isNullable(oneOfVariant)) {
  17. return true;
  18. }
  19. }
  20. for (const allOfVariant of schema.anyOf ?? []) {
  21. if (isNullable(allOfVariant)) {
  22. return true;
  23. }
  24. }
  25. return false;
  26. }
  27. /**
  28. * Mutates the given JSON schema to ensure it conforms to the `strict` standard
  29. * that the API expects.
  30. */
  31. function ensureStrictJsonSchema(jsonSchema, path, root) {
  32. if (typeof jsonSchema === 'boolean') {
  33. throw new TypeError(`Expected object schema but got boolean; path=${path.join('/')}`);
  34. }
  35. if (!isObject(jsonSchema)) {
  36. throw new TypeError(`Expected ${JSON.stringify(jsonSchema)} to be an object; path=${path.join('/')}`);
  37. }
  38. // Handle $defs (non-standard but sometimes used)
  39. const defs = jsonSchema.$defs;
  40. if (isObject(defs)) {
  41. for (const [defName, defSchema] of Object.entries(defs)) {
  42. ensureStrictJsonSchema(defSchema, [...path, '$defs', defName], root);
  43. }
  44. }
  45. // Handle definitions (draft-04 style, deprecated in draft-07 but still used)
  46. const definitions = jsonSchema.definitions;
  47. if (isObject(definitions)) {
  48. for (const [definitionName, definitionSchema] of Object.entries(definitions)) {
  49. ensureStrictJsonSchema(definitionSchema, [...path, 'definitions', definitionName], root);
  50. }
  51. }
  52. // Add additionalProperties: false to object types
  53. const typ = jsonSchema.type;
  54. if (typ === 'object' && !('additionalProperties' in jsonSchema)) {
  55. jsonSchema.additionalProperties = false;
  56. }
  57. const required = jsonSchema.required ?? [];
  58. // Handle object properties
  59. const properties = jsonSchema.properties;
  60. if (isObject(properties)) {
  61. for (const [key, value] of Object.entries(properties)) {
  62. if (!isNullable(value) && !required.includes(key)) {
  63. throw new Error(`Zod field at \`${[...path, 'properties', key].join('/')}\` uses \`.optional()\` without \`.nullable()\` which is not supported by the API. See: https://platform.openai.com/docs/guides/structured-outputs?api-mode=responses#all-fields-must-be-required`);
  64. }
  65. }
  66. jsonSchema.required = Object.keys(properties);
  67. jsonSchema.properties = Object.fromEntries(Object.entries(properties).map(([key, propSchema]) => [
  68. key,
  69. ensureStrictJsonSchema(propSchema, [...path, 'properties', key], root),
  70. ]));
  71. }
  72. // Handle arrays
  73. const items = jsonSchema.items;
  74. if (isObject(items)) {
  75. jsonSchema.items = ensureStrictJsonSchema(items, [...path, 'items'], root);
  76. }
  77. // Handle unions (anyOf)
  78. const anyOf = jsonSchema.anyOf;
  79. if (Array.isArray(anyOf)) {
  80. jsonSchema.anyOf = anyOf.map((variant, i) => ensureStrictJsonSchema(variant, [...path, 'anyOf', String(i)], root));
  81. }
  82. // Handle intersections (allOf)
  83. const allOf = jsonSchema.allOf;
  84. if (Array.isArray(allOf)) {
  85. if (allOf.length === 1) {
  86. const resolved = ensureStrictJsonSchema(allOf[0], [...path, 'allOf', '0'], root);
  87. Object.assign(jsonSchema, resolved);
  88. delete jsonSchema.allOf;
  89. }
  90. else {
  91. jsonSchema.allOf = allOf.map((entry, i) => ensureStrictJsonSchema(entry, [...path, 'allOf', String(i)], root));
  92. }
  93. }
  94. // Strip `null` defaults as there's no meaningful distinction
  95. if (jsonSchema.default === null) {
  96. delete jsonSchema.default;
  97. }
  98. // Handle $ref with additional properties
  99. const ref = jsonSchema.$ref;
  100. if (ref && hasMoreThanNKeys(jsonSchema, 1)) {
  101. if (typeof ref !== 'string') {
  102. throw new TypeError(`Received non-string $ref - ${ref}; path=${path.join('/')}`);
  103. }
  104. const resolved = resolveRef(root, ref);
  105. if (typeof resolved === 'boolean') {
  106. throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object schema but got boolean`);
  107. }
  108. if (!isObject(resolved)) {
  109. throw new Error(`Expected \`$ref: ${ref}\` to resolve to an object but got ${JSON.stringify(resolved)}`);
  110. }
  111. // Properties from the json schema take priority over the ones on the `$ref`
  112. Object.assign(jsonSchema, { ...resolved, ...jsonSchema });
  113. delete jsonSchema.$ref;
  114. // Since the schema expanded from `$ref` might not have `additionalProperties: false` applied,
  115. // we call `ensureStrictJsonSchema` again to fix the inlined schema and ensure it's valid.
  116. return ensureStrictJsonSchema(jsonSchema, path, root);
  117. }
  118. return jsonSchema;
  119. }
  120. function resolveRef(root, ref) {
  121. if (!ref.startsWith('#/')) {
  122. throw new Error(`Unexpected $ref format ${JSON.stringify(ref)}; Does not start with #/`);
  123. }
  124. const pathParts = ref.slice(2).split('/');
  125. let resolved = root;
  126. for (const key of pathParts) {
  127. if (!isObject(resolved)) {
  128. throw new Error(`encountered non-object entry while resolving ${ref} - ${JSON.stringify(resolved)}`);
  129. }
  130. const value = resolved[key];
  131. if (value === undefined) {
  132. throw new Error(`Key ${key} not found while resolving ${ref}`);
  133. }
  134. resolved = value;
  135. }
  136. return resolved;
  137. }
  138. function isObject(obj) {
  139. return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
  140. }
  141. function hasMoreThanNKeys(obj, n) {
  142. let i = 0;
  143. for (const _ in obj) {
  144. i++;
  145. if (i > n) {
  146. return true;
  147. }
  148. }
  149. return false;
  150. }
  151. //# sourceMappingURL=transform.mjs.map