helper.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * Type definition for a JSON Schema object
  3. */
  4. export interface JsonSchemaObject {
  5. $ref?: string;
  6. $defs?: Record<string, JsonSchemaObject>;
  7. type?: string;
  8. properties?: Record<string, JsonSchemaObject>;
  9. items?: JsonSchemaObject;
  10. anyOf?: JsonSchemaObject[];
  11. title?: string;
  12. description?: string;
  13. required?: string[];
  14. default?: unknown;
  15. additionalProperties?: boolean;
  16. [key: string]: unknown;
  17. }
  18. /**
  19. * Dereferences all $ref fields in a JSON schema by replacing them with the actual referenced schema
  20. *
  21. * @param schema The JSON schema to dereference
  22. * @returns A new JSON schema with all references resolved
  23. */
  24. export function dereferenceJsonSchema(schema: JsonSchemaObject): JsonSchemaObject {
  25. // Create a deep copy of the schema to avoid modifying the original
  26. const clonedSchema = JSON.parse(JSON.stringify(schema));
  27. // Extract definitions to use for resolving references
  28. const definitions = clonedSchema.$defs || {};
  29. // Process the schema
  30. const result = processSchemaNode(clonedSchema, definitions);
  31. // Create a new object without $defs
  32. const resultWithoutDefs: JsonSchemaObject = {};
  33. // Copy all properties except $defs
  34. for (const [key, value] of Object.entries(result)) {
  35. if (key !== '$defs') {
  36. resultWithoutDefs[key] = value;
  37. }
  38. }
  39. return resultWithoutDefs;
  40. }
  41. /**
  42. * Process a schema node, resolving all references
  43. */
  44. function processSchemaNode(node: JsonSchemaObject, definitions: Record<string, JsonSchemaObject>): JsonSchemaObject {
  45. // If it's not an object or is null, return as is
  46. if (typeof node !== 'object' || node === null) {
  47. return node;
  48. }
  49. // If it's a reference, resolve it
  50. if (node.$ref) {
  51. const refPath = node.$ref.replace('#/$defs/', '');
  52. const definition = definitions[refPath];
  53. if (definition) {
  54. // Process the definition to resolve any nested references
  55. const processedDefinition = processSchemaNode(definition, definitions);
  56. // Create a new object that preserves properties from the original node (except $ref)
  57. const result: JsonSchemaObject = {};
  58. // First copy properties from the original node except $ref
  59. for (const [key, value] of Object.entries(node)) {
  60. if (key !== '$ref') {
  61. result[key] = value;
  62. }
  63. }
  64. // Then copy properties from the processed definition
  65. // Don't override any existing properties in the original node
  66. for (const [key, value] of Object.entries(processedDefinition)) {
  67. if (result[key] === undefined) {
  68. result[key] = value;
  69. }
  70. }
  71. return result;
  72. }
  73. }
  74. // Handle anyOf for references
  75. if (node.anyOf) {
  76. // Process each item in anyOf
  77. const processedAnyOf = node.anyOf.map(item => processSchemaNode(item, definitions));
  78. // If anyOf contains a reference and a null type, merge them
  79. const nonNullTypes = processedAnyOf.filter(item => item.type !== 'null');
  80. const hasNullType = processedAnyOf.some(item => item.type === 'null');
  81. if (nonNullTypes.length === 1 && hasNullType) {
  82. // Create a result that preserves all properties from the original node
  83. const result: JsonSchemaObject = {};
  84. // Copy all properties from original node except anyOf
  85. for (const [key, value] of Object.entries(node)) {
  86. if (key !== 'anyOf') {
  87. result[key] = value;
  88. }
  89. }
  90. // Merge in properties from the non-null type
  91. for (const [key, value] of Object.entries(nonNullTypes[0])) {
  92. // Don't override properties that were in the original node
  93. if (result[key] === undefined) {
  94. result[key] = value;
  95. }
  96. }
  97. result.nullable = true;
  98. return result;
  99. }
  100. // Otherwise, keep the anyOf structure but with processed items
  101. return {
  102. ...node,
  103. anyOf: processedAnyOf,
  104. };
  105. }
  106. // Create a new node with processed properties
  107. const result: JsonSchemaObject = {};
  108. // Copy all properties except $ref
  109. for (const [key, value] of Object.entries(node)) {
  110. if (key !== '$ref') {
  111. if (key === 'properties' && typeof value === 'object' && value !== null) {
  112. // Process properties
  113. result.properties = {};
  114. for (const [propKey, propValue] of Object.entries(value)) {
  115. result.properties[propKey] = processSchemaNode(propValue as JsonSchemaObject, definitions);
  116. }
  117. } else if (key === 'items' && typeof value === 'object' && value !== null) {
  118. // Process items for arrays
  119. result.items = processSchemaNode(value as JsonSchemaObject, definitions);
  120. } else {
  121. // Copy other properties as is
  122. result[key] = value;
  123. }
  124. }
  125. }
  126. return result;
  127. }
  128. /**
  129. * Converts an OpenAI format JSON schema to a Google Gemini compatible schema
  130. *
  131. * Key differences handled:
  132. * 1. OpenAI accepts $defs and $ref for references, Gemini only accepts inline definitions
  133. * 2. Different structure for nullable properties
  134. * 3. Gemini has a flatter structure for defining properties
  135. * 4. https://ai.google.dev/api/caching#Schema
  136. * 5. https://ai.google.dev/gemini-api/docs/structured-output?lang=node#json-schemas
  137. *
  138. * @param openaiSchema The OpenAI format JSON schema to convert
  139. * @param ensureOrder If true, adds the propertyOrdering field for consistent ordering
  140. * @returns A Google Gemini compatible JSON schema
  141. */
  142. export function convertOpenAISchemaToGemini(openaiSchema: JsonSchemaObject, ensureOrder = false): JsonSchemaObject {
  143. // First flatten the schema with dereferenceJsonSchema
  144. const flattenedSchema = dereferenceJsonSchema(openaiSchema);
  145. // Create a new schema object
  146. const geminiSchema: JsonSchemaObject = {
  147. type: flattenedSchema.type,
  148. properties: {},
  149. required: flattenedSchema.required || [],
  150. };
  151. // Process properties
  152. if (flattenedSchema.properties) {
  153. geminiSchema.properties = processPropertiesForGemini(flattenedSchema.properties, ensureOrder);
  154. // Add propertyOrdering for top-level properties if ensureOrder is true
  155. if (ensureOrder && geminiSchema.properties) {
  156. geminiSchema.propertyOrdering = Object.keys(flattenedSchema.properties);
  157. }
  158. }
  159. // Copy other Gemini-compatible fields
  160. if (flattenedSchema.description) {
  161. geminiSchema.description = flattenedSchema.description;
  162. }
  163. if (flattenedSchema.format) {
  164. geminiSchema.format = flattenedSchema.format;
  165. }
  166. if (flattenedSchema.enum) {
  167. geminiSchema.enum = flattenedSchema.enum;
  168. }
  169. if (flattenedSchema.nullable) {
  170. geminiSchema.nullable = flattenedSchema.nullable;
  171. }
  172. // Handle array items
  173. if (flattenedSchema.type === 'array' && flattenedSchema.items) {
  174. geminiSchema.items = processPropertyForGemini(flattenedSchema.items);
  175. if (flattenedSchema.minItems !== undefined) {
  176. geminiSchema.minItems = flattenedSchema.minItems;
  177. }
  178. if (flattenedSchema.maxItems !== undefined) {
  179. geminiSchema.maxItems = flattenedSchema.maxItems;
  180. }
  181. }
  182. return geminiSchema;
  183. }
  184. /**
  185. * Process properties recursively, converting to Gemini format
  186. */
  187. function processPropertiesForGemini(
  188. properties: Record<string, JsonSchemaObject>,
  189. addPropertyOrdering: boolean = false,
  190. ): Record<string, JsonSchemaObject> {
  191. const result: Record<string, JsonSchemaObject> = {};
  192. for (const [key, value] of Object.entries(properties)) {
  193. if (typeof value !== 'object' || value === null) continue;
  194. result[key] = processPropertyForGemini(value, addPropertyOrdering);
  195. }
  196. return result;
  197. }
  198. /**
  199. * Process a single property, converting to Gemini format
  200. *
  201. * @param property The property to process
  202. * @param addPropertyOrdering Whether to add property ordering for object properties
  203. */
  204. function processPropertyForGemini(property: JsonSchemaObject, addPropertyOrdering = false): JsonSchemaObject {
  205. // Create a new property object
  206. const result: JsonSchemaObject = {
  207. type: property.type,
  208. };
  209. // Copy description if it exists
  210. if (property.description) {
  211. result.description = property.description;
  212. }
  213. // Copy format if it exists
  214. if (property.format) {
  215. result.format = property.format;
  216. }
  217. // Copy enum if it exists
  218. if (property.enum) {
  219. result.enum = property.enum;
  220. }
  221. // Copy nullable if it exists
  222. if (property.nullable) {
  223. result.nullable = property.nullable;
  224. }
  225. // Process nested properties for objects
  226. if (property.type === 'object' && property.properties) {
  227. result.properties = processPropertiesForGemini(property.properties, addPropertyOrdering);
  228. // Copy required fields
  229. if (property.required) {
  230. result.required = property.required;
  231. }
  232. // Add propertyOrdering for nested object if needed
  233. if (addPropertyOrdering && property.properties) {
  234. result.propertyOrdering = Object.keys(property.properties);
  235. }
  236. // Copy propertyOrdering if it already exists
  237. else if (property.propertyOrdering) {
  238. result.propertyOrdering = property.propertyOrdering;
  239. }
  240. }
  241. // Handle arrays
  242. if (property.type === 'array' && property.items) {
  243. result.items = processPropertyForGemini(property.items, addPropertyOrdering);
  244. if (property.minItems !== undefined) {
  245. result.minItems = property.minItems;
  246. }
  247. if (property.maxItems !== undefined) {
  248. result.maxItems = property.maxItems;
  249. }
  250. }
  251. return result;
  252. }
  253. export type JSONSchemaType = JsonSchemaObject | JSONSchemaType[];
  254. // Custom stringify function
  255. export function stringifyCustom(value: JSONSchemaType, indent = '', baseIndent = ' '): string {
  256. const currentIndent = indent + baseIndent;
  257. if (value === null) {
  258. return 'null';
  259. }
  260. switch (typeof value) {
  261. case 'string':
  262. // Escape single quotes within the string if necessary
  263. return `'${(value as string).replace(/'/g, "\\\\'")}'`;
  264. case 'number':
  265. case 'boolean':
  266. return String(value);
  267. case 'object': {
  268. if (Array.isArray(value)) {
  269. if (value.length === 0) {
  270. return '[]';
  271. }
  272. const items = value.map(item => `${currentIndent}${stringifyCustom(item, currentIndent, baseIndent)}`);
  273. return `[\n${items.join(',\n')}\n${indent}]`;
  274. }
  275. const keys = Object.keys(value);
  276. if (keys.length === 0) {
  277. return '{}';
  278. }
  279. const properties = keys.map(key => {
  280. // Assume keys are valid JS identifiers and don't need quotes
  281. const formattedKey = key;
  282. const formattedValue = stringifyCustom(value[key] as JSONSchemaType, currentIndent, baseIndent);
  283. return `${currentIndent}${formattedKey}: ${formattedValue}`;
  284. });
  285. return `{\n${properties.join(',\n')}\n${indent}}`;
  286. }
  287. default:
  288. // Handle undefined, etc.
  289. return 'undefined';
  290. }
  291. }