@typia/utils 12.0.0-dev.20260313-2 → 12.0.0-dev.20260314
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.
- package/lib/http/internal/HttpLlmApplicationComposer.mjs +5 -1
- package/lib/http/internal/HttpLlmApplicationComposer.mjs.map +1 -1
- package/lib/index.mjs +9 -9
- package/lib/utils/LlmJson.mjs +9 -2
- package/lib/utils/LlmJson.mjs.map +1 -1
- package/lib/validators/internal/OpenApiOneOfValidator.mjs +5 -1
- package/lib/validators/internal/OpenApiOneOfValidator.mjs.map +1 -1
- package/package.json +2 -2
- package/src/converters/LlmSchemaConverter.ts +617 -617
- package/src/http/internal/HttpLlmApplicationComposer.ts +360 -360
|
@@ -1,617 +1,617 @@
|
|
|
1
|
-
import {
|
|
2
|
-
IJsonSchemaAttribute,
|
|
3
|
-
IJsonSchemaTransformError,
|
|
4
|
-
ILlmSchema,
|
|
5
|
-
IResult,
|
|
6
|
-
OpenApi,
|
|
7
|
-
} from "@typia/interface";
|
|
8
|
-
|
|
9
|
-
import { JsonDescriptor } from "../utils/internal/JsonDescriptor";
|
|
10
|
-
import { LlmTypeChecker } from "../validators/LlmTypeChecker";
|
|
11
|
-
import { OpenApiTypeChecker } from "../validators/OpenApiTypeChecker";
|
|
12
|
-
import { LlmDescriptionInverter } from "./internal/LlmDescriptionInverter";
|
|
13
|
-
import { LlmParametersFinder } from "./internal/LlmParametersComposer";
|
|
14
|
-
import { OpenApiConstraintShifter } from "./internal/OpenApiConstraintShifter";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* OpenAPI to LLM schema converter.
|
|
18
|
-
*
|
|
19
|
-
* `LlmSchemaConverter` converts OpenAPI JSON schemas to LLM-compatible
|
|
20
|
-
* {@link ILlmSchema} format. LLMs don't fully support JSON Schema, so this
|
|
21
|
-
* simplifies schemas by removing unsupported features (tuples, `const`, mixed
|
|
22
|
-
* unions).
|
|
23
|
-
*
|
|
24
|
-
* Main functions:
|
|
25
|
-
*
|
|
26
|
-
* - {@link parameters}: Convert object schema to {@link ILlmSchema.IParameters}
|
|
27
|
-
* - {@link schema}: Convert any schema to {@link ILlmSchema}
|
|
28
|
-
* - {@link invert}: Extract constraints from description back to schema
|
|
29
|
-
*
|
|
30
|
-
* Configuration options ({@link ILlmSchema.IConfig}):
|
|
31
|
-
*
|
|
32
|
-
* - `strict`: OpenAI structured output mode (all properties required)
|
|
33
|
-
*
|
|
34
|
-
* @author Jeongho Nam - https://github.com/samchon
|
|
35
|
-
*/
|
|
36
|
-
export namespace LlmSchemaConverter {
|
|
37
|
-
/**
|
|
38
|
-
* Get configuration with defaults applied.
|
|
39
|
-
*
|
|
40
|
-
* @param config Partial configuration
|
|
41
|
-
* @returns Full configuration with defaults
|
|
42
|
-
*/
|
|
43
|
-
export const getConfig = (
|
|
44
|
-
config?: Partial<ILlmSchema.IConfig> | undefined,
|
|
45
|
-
): ILlmSchema.IConfig => ({
|
|
46
|
-
strict: config?.strict ?? false,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
/* -----------------------------------------------------------
|
|
50
|
-
CONVERTERS
|
|
51
|
-
----------------------------------------------------------- */
|
|
52
|
-
/**
|
|
53
|
-
* Convert OpenAPI object schema to LLM parameters schema.
|
|
54
|
-
*
|
|
55
|
-
* @param props.config Conversion configuration
|
|
56
|
-
* @param props.components OpenAPI components for reference resolution
|
|
57
|
-
* @param props.schema Object or reference schema to convert
|
|
58
|
-
* @param props.accessor Error path accessor
|
|
59
|
-
* @param props.refAccessor Reference path accessor
|
|
60
|
-
* @returns Converted parameters or error
|
|
61
|
-
*/
|
|
62
|
-
export const parameters = (props: {
|
|
63
|
-
config?: Partial<ILlmSchema.IConfig>;
|
|
64
|
-
components: OpenApi.IComponents;
|
|
65
|
-
schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference;
|
|
66
|
-
accessor?: string;
|
|
67
|
-
refAccessor?: string;
|
|
68
|
-
}): IResult<ILlmSchema.IParameters, IJsonSchemaTransformError> => {
|
|
69
|
-
const config: ILlmSchema.IConfig = getConfig(props.config);
|
|
70
|
-
const entity: IResult<
|
|
71
|
-
OpenApi.IJsonSchema.IObject,
|
|
72
|
-
IJsonSchemaTransformError
|
|
73
|
-
> = LlmParametersFinder.parameters({
|
|
74
|
-
...props,
|
|
75
|
-
method: "LlmSchemaConverter.parameters",
|
|
76
|
-
});
|
|
77
|
-
if (entity.success === false) return entity;
|
|
78
|
-
|
|
79
|
-
const $defs: Record<string, ILlmSchema> = {};
|
|
80
|
-
const result: IResult<ILlmSchema, IJsonSchemaTransformError> = transform({
|
|
81
|
-
...props,
|
|
82
|
-
config,
|
|
83
|
-
$defs,
|
|
84
|
-
schema: entity.value,
|
|
85
|
-
});
|
|
86
|
-
if (result.success === false) return result;
|
|
87
|
-
return {
|
|
88
|
-
success: true,
|
|
89
|
-
value: {
|
|
90
|
-
...(result.value as ILlmSchema.IObject),
|
|
91
|
-
additionalProperties: false,
|
|
92
|
-
$defs,
|
|
93
|
-
description: OpenApiTypeChecker.isReference(props.schema)
|
|
94
|
-
? JsonDescriptor.cascade({
|
|
95
|
-
prefix: "#/components/schemas/",
|
|
96
|
-
components: props.components,
|
|
97
|
-
schema: {
|
|
98
|
-
...props.schema,
|
|
99
|
-
description: result.value.description,
|
|
100
|
-
},
|
|
101
|
-
escape: true,
|
|
102
|
-
})
|
|
103
|
-
: result.value.description,
|
|
104
|
-
} satisfies ILlmSchema.IParameters,
|
|
105
|
-
};
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Convert OpenAPI schema to LLM schema.
|
|
110
|
-
*
|
|
111
|
-
* @param props.config Conversion configuration
|
|
112
|
-
* @param props.components OpenAPI components for reference resolution
|
|
113
|
-
* @param props.$defs Definition store (mutated with referenced types)
|
|
114
|
-
* @param props.schema Schema to convert
|
|
115
|
-
* @param props.accessor Error path accessor
|
|
116
|
-
* @param props.refAccessor Reference path accessor
|
|
117
|
-
* @returns Converted schema or error
|
|
118
|
-
*/
|
|
119
|
-
export const schema = (props: {
|
|
120
|
-
config?: Partial<ILlmSchema.IConfig>;
|
|
121
|
-
components: OpenApi.IComponents;
|
|
122
|
-
$defs: Record<string, ILlmSchema>;
|
|
123
|
-
schema: OpenApi.IJsonSchema;
|
|
124
|
-
accessor?: string;
|
|
125
|
-
refAccessor?: string;
|
|
126
|
-
}): IResult<ILlmSchema, IJsonSchemaTransformError> =>
|
|
127
|
-
transform({
|
|
128
|
-
config: getConfig(props.config),
|
|
129
|
-
components: props.components,
|
|
130
|
-
$defs: props.$defs,
|
|
131
|
-
schema: props.schema,
|
|
132
|
-
accessor: props.accessor,
|
|
133
|
-
refAccessor: props.refAccessor,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const transform = (props: {
|
|
137
|
-
config: ILlmSchema.IConfig;
|
|
138
|
-
components: OpenApi.IComponents;
|
|
139
|
-
$defs: Record<string, ILlmSchema>;
|
|
140
|
-
schema: OpenApi.IJsonSchema;
|
|
141
|
-
accessor?: string;
|
|
142
|
-
refAccessor?: string;
|
|
143
|
-
}): IResult<ILlmSchema, IJsonSchemaTransformError> => {
|
|
144
|
-
// PREPARE ASSETS
|
|
145
|
-
const union: Array<ILlmSchema> = [];
|
|
146
|
-
const attribute: IJsonSchemaAttribute = {
|
|
147
|
-
title: props.schema.title,
|
|
148
|
-
description: props.schema.description,
|
|
149
|
-
deprecated: props.schema.deprecated,
|
|
150
|
-
readOnly: props.schema.readOnly,
|
|
151
|
-
writeOnly: props.schema.writeOnly,
|
|
152
|
-
example: props.schema.example,
|
|
153
|
-
examples: props.schema.examples,
|
|
154
|
-
...Object.fromEntries(
|
|
155
|
-
Object.entries(props.schema).filter(
|
|
156
|
-
([key, value]) => key.startsWith("x-") && value !== undefined,
|
|
157
|
-
),
|
|
158
|
-
),
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
// VALIDADTE SCHEMA
|
|
162
|
-
const reasons: IJsonSchemaTransformError.IReason[] = [];
|
|
163
|
-
OpenApiTypeChecker.visit({
|
|
164
|
-
closure: (next, accessor) => {
|
|
165
|
-
if (props.config.strict === true) {
|
|
166
|
-
// STRICT MODE VALIDATION
|
|
167
|
-
reasons.push(...validateStrict(next, accessor));
|
|
168
|
-
}
|
|
169
|
-
if (OpenApiTypeChecker.isTuple(next))
|
|
170
|
-
reasons.push({
|
|
171
|
-
accessor,
|
|
172
|
-
schema: next,
|
|
173
|
-
message: `LLM does not allow tuple type.`,
|
|
174
|
-
});
|
|
175
|
-
else if (OpenApiTypeChecker.isReference(next)) {
|
|
176
|
-
// UNABLE TO FIND MATCHED REFERENCE
|
|
177
|
-
const key: string =
|
|
178
|
-
next.$ref.split("#/components/schemas/")[1] ??
|
|
179
|
-
next.$ref.split("/").at(-1)!;
|
|
180
|
-
if (props.components.schemas?.[key] === undefined)
|
|
181
|
-
reasons.push({
|
|
182
|
-
schema: next,
|
|
183
|
-
accessor: accessor,
|
|
184
|
-
message: `unable to find reference type ${JSON.stringify(key)}.`,
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
components: props.components,
|
|
189
|
-
schema: props.schema,
|
|
190
|
-
accessor: props.accessor,
|
|
191
|
-
refAccessor: props.refAccessor,
|
|
192
|
-
});
|
|
193
|
-
if (reasons.length > 0)
|
|
194
|
-
return {
|
|
195
|
-
success: false,
|
|
196
|
-
error: {
|
|
197
|
-
method: "LlmSchemaConverter.schema",
|
|
198
|
-
message: "Failed to compose LLM schema",
|
|
199
|
-
reasons,
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const visitConstant = (input: OpenApi.IJsonSchema): void => {
|
|
204
|
-
const insert = (value: any): void => {
|
|
205
|
-
const matched:
|
|
206
|
-
| ILlmSchema.IString
|
|
207
|
-
| ILlmSchema.INumber
|
|
208
|
-
| ILlmSchema.IBoolean
|
|
209
|
-
| undefined = union.find(
|
|
210
|
-
(u) =>
|
|
211
|
-
(u as (IJsonSchemaAttribute & { type: string }) | undefined)
|
|
212
|
-
?.type === typeof value,
|
|
213
|
-
) as ILlmSchema.IString | undefined;
|
|
214
|
-
if (matched !== undefined) {
|
|
215
|
-
matched.enum ??= [];
|
|
216
|
-
matched.enum.push(value);
|
|
217
|
-
} else
|
|
218
|
-
union.push({
|
|
219
|
-
type: typeof value as "number",
|
|
220
|
-
enum: [value],
|
|
221
|
-
});
|
|
222
|
-
};
|
|
223
|
-
if (OpenApiTypeChecker.isConstant(input)) insert(input.const);
|
|
224
|
-
else if (OpenApiTypeChecker.isOneOf(input))
|
|
225
|
-
input.oneOf.forEach(visitConstant);
|
|
226
|
-
};
|
|
227
|
-
const visit = (input: OpenApi.IJsonSchema, accessor: string): void => {
|
|
228
|
-
if (OpenApiTypeChecker.isOneOf(input)) {
|
|
229
|
-
// UNION TYPE
|
|
230
|
-
input.oneOf.forEach((s, i) => visit(s, `${accessor}.oneOf[${i}]`));
|
|
231
|
-
} else if (OpenApiTypeChecker.isReference(input)) {
|
|
232
|
-
// REFERENCE TYPE
|
|
233
|
-
const key: string =
|
|
234
|
-
input.$ref.split("#/components/schemas/")[1] ??
|
|
235
|
-
input.$ref.split("/").at(-1)!;
|
|
236
|
-
const target: OpenApi.IJsonSchema | undefined =
|
|
237
|
-
props.components.schemas?.[key];
|
|
238
|
-
if (target === undefined) return;
|
|
239
|
-
else {
|
|
240
|
-
// KEEP THE REFERENCE TYPE
|
|
241
|
-
const out = () => {
|
|
242
|
-
union.push({
|
|
243
|
-
...input,
|
|
244
|
-
$ref: `#/$defs/${key}`,
|
|
245
|
-
});
|
|
246
|
-
};
|
|
247
|
-
if (props.$defs[key] !== undefined) return out();
|
|
248
|
-
|
|
249
|
-
props.$defs[key] = {};
|
|
250
|
-
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
251
|
-
transform({
|
|
252
|
-
config: props.config,
|
|
253
|
-
components: props.components,
|
|
254
|
-
$defs: props.$defs,
|
|
255
|
-
schema: target,
|
|
256
|
-
refAccessor: props.refAccessor,
|
|
257
|
-
accessor: `${props.refAccessor ?? "$def"}[${JSON.stringify(key)}]`,
|
|
258
|
-
});
|
|
259
|
-
if (converted.success === false) return; // UNREACHABLE
|
|
260
|
-
props.$defs[key] = converted.value;
|
|
261
|
-
return out();
|
|
262
|
-
}
|
|
263
|
-
} else if (OpenApiTypeChecker.isObject(input)) {
|
|
264
|
-
// OBJECT TYPE
|
|
265
|
-
const properties: Record<string, ILlmSchema> = Object.fromEntries(
|
|
266
|
-
Object.entries(input.properties ?? {})
|
|
267
|
-
.map(([key, value]) => {
|
|
268
|
-
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
269
|
-
transform({
|
|
270
|
-
config: props.config,
|
|
271
|
-
components: props.components,
|
|
272
|
-
$defs: props.$defs,
|
|
273
|
-
schema: value,
|
|
274
|
-
refAccessor: props.refAccessor,
|
|
275
|
-
accessor: `${props.accessor ?? "$input.schema"}.properties[${JSON.stringify(key)}]`,
|
|
276
|
-
});
|
|
277
|
-
if (converted.success === false) {
|
|
278
|
-
reasons.push(...converted.error.reasons);
|
|
279
|
-
return [key, null];
|
|
280
|
-
}
|
|
281
|
-
return [key, converted.value];
|
|
282
|
-
})
|
|
283
|
-
.filter(([, value]) => value !== null),
|
|
284
|
-
);
|
|
285
|
-
if (Object.values(properties).some((v) => v === null)) return;
|
|
286
|
-
|
|
287
|
-
const additionalProperties: ILlmSchema | boolean | undefined | null =
|
|
288
|
-
(() => {
|
|
289
|
-
if (
|
|
290
|
-
typeof input.additionalProperties === "object" &&
|
|
291
|
-
input.additionalProperties !== null
|
|
292
|
-
) {
|
|
293
|
-
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
294
|
-
transform({
|
|
295
|
-
config: props.config,
|
|
296
|
-
components: props.components,
|
|
297
|
-
$defs: props.$defs,
|
|
298
|
-
schema: input.additionalProperties,
|
|
299
|
-
refAccessor: props.refAccessor,
|
|
300
|
-
accessor: `${accessor}.additionalProperties`,
|
|
301
|
-
});
|
|
302
|
-
if (converted.success === false) {
|
|
303
|
-
reasons.push(...converted.error.reasons);
|
|
304
|
-
return null;
|
|
305
|
-
}
|
|
306
|
-
return converted.value;
|
|
307
|
-
}
|
|
308
|
-
return props.config.strict === true
|
|
309
|
-
? false
|
|
310
|
-
: input.additionalProperties;
|
|
311
|
-
})();
|
|
312
|
-
if (additionalProperties === null) return;
|
|
313
|
-
union.push({
|
|
314
|
-
...input,
|
|
315
|
-
properties,
|
|
316
|
-
additionalProperties,
|
|
317
|
-
required: input.required ?? [],
|
|
318
|
-
description:
|
|
319
|
-
props.config.strict === true
|
|
320
|
-
? JsonDescriptor.take(input)
|
|
321
|
-
: input.description,
|
|
322
|
-
});
|
|
323
|
-
} else if (OpenApiTypeChecker.isArray(input)) {
|
|
324
|
-
// ARRAY TYPE
|
|
325
|
-
const items: IResult<ILlmSchema, IJsonSchemaTransformError> = transform(
|
|
326
|
-
{
|
|
327
|
-
config: props.config,
|
|
328
|
-
components: props.components,
|
|
329
|
-
$defs: props.$defs,
|
|
330
|
-
schema: input.items,
|
|
331
|
-
refAccessor: props.refAccessor,
|
|
332
|
-
accessor: `${accessor}.items`,
|
|
333
|
-
},
|
|
334
|
-
);
|
|
335
|
-
if (items.success === false) {
|
|
336
|
-
reasons.push(...items.error.reasons);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
union.push(
|
|
340
|
-
props.config.strict === true
|
|
341
|
-
? OpenApiConstraintShifter.shiftArray({
|
|
342
|
-
...input,
|
|
343
|
-
items: items.value,
|
|
344
|
-
})
|
|
345
|
-
: {
|
|
346
|
-
...input,
|
|
347
|
-
items: items.value,
|
|
348
|
-
},
|
|
349
|
-
);
|
|
350
|
-
} else if (OpenApiTypeChecker.isString(input))
|
|
351
|
-
union.push(
|
|
352
|
-
props.config.strict === true
|
|
353
|
-
? OpenApiConstraintShifter.shiftString({ ...input })
|
|
354
|
-
: input,
|
|
355
|
-
);
|
|
356
|
-
else if (
|
|
357
|
-
OpenApiTypeChecker.isNumber(input) ||
|
|
358
|
-
OpenApiTypeChecker.isInteger(input)
|
|
359
|
-
)
|
|
360
|
-
union.push(
|
|
361
|
-
props.config.strict === true
|
|
362
|
-
? OpenApiConstraintShifter.shiftNumeric({ ...input })
|
|
363
|
-
: input,
|
|
364
|
-
);
|
|
365
|
-
else if (OpenApiTypeChecker.isTuple(input))
|
|
366
|
-
return; // UNREACHABLE
|
|
367
|
-
else if (OpenApiTypeChecker.isConstant(input) === false)
|
|
368
|
-
union.push({ ...input });
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
visitConstant(props.schema);
|
|
372
|
-
visit(props.schema, props.accessor ?? "$input.schema");
|
|
373
|
-
|
|
374
|
-
if (reasons.length > 0)
|
|
375
|
-
return {
|
|
376
|
-
success: false,
|
|
377
|
-
error: {
|
|
378
|
-
method: "LlmSchemaConverter.schema",
|
|
379
|
-
message: "Failed to compose LLM schema",
|
|
380
|
-
reasons,
|
|
381
|
-
},
|
|
382
|
-
};
|
|
383
|
-
else if (union.length === 0)
|
|
384
|
-
return {
|
|
385
|
-
// unknown type
|
|
386
|
-
success: true,
|
|
387
|
-
value: {
|
|
388
|
-
...attribute,
|
|
389
|
-
type: undefined,
|
|
390
|
-
},
|
|
391
|
-
};
|
|
392
|
-
else if (union.length === 1)
|
|
393
|
-
return {
|
|
394
|
-
// single type
|
|
395
|
-
success: true,
|
|
396
|
-
value: {
|
|
397
|
-
...attribute,
|
|
398
|
-
...union[0],
|
|
399
|
-
description:
|
|
400
|
-
props.config.strict === true &&
|
|
401
|
-
LlmTypeChecker.isReference(union[0]!)
|
|
402
|
-
? undefined
|
|
403
|
-
: (union[0]!.description ?? attribute.description),
|
|
404
|
-
},
|
|
405
|
-
};
|
|
406
|
-
return {
|
|
407
|
-
success: true,
|
|
408
|
-
value: {
|
|
409
|
-
...attribute,
|
|
410
|
-
anyOf: union.map((u) => ({
|
|
411
|
-
...u,
|
|
412
|
-
description:
|
|
413
|
-
props.config.strict === true && LlmTypeChecker.isReference(u)
|
|
414
|
-
? undefined
|
|
415
|
-
: u.description,
|
|
416
|
-
})),
|
|
417
|
-
"x-discriminator":
|
|
418
|
-
OpenApiTypeChecker.isOneOf(props.schema) &&
|
|
419
|
-
props.schema.discriminator !== undefined &&
|
|
420
|
-
props.schema.oneOf.length === union.length &&
|
|
421
|
-
union.every(
|
|
422
|
-
(e) => LlmTypeChecker.isReference(e) || LlmTypeChecker.isNull(e),
|
|
423
|
-
)
|
|
424
|
-
? {
|
|
425
|
-
propertyName: props.schema.discriminator.propertyName,
|
|
426
|
-
mapping:
|
|
427
|
-
props.schema.discriminator.mapping !== undefined
|
|
428
|
-
? Object.fromEntries(
|
|
429
|
-
Object.entries(props.schema.discriminator.mapping).map(
|
|
430
|
-
([key, value]) => [
|
|
431
|
-
key,
|
|
432
|
-
`#/$defs/${value.split("/").at(-1)}`,
|
|
433
|
-
],
|
|
434
|
-
),
|
|
435
|
-
)
|
|
436
|
-
: undefined,
|
|
437
|
-
}
|
|
438
|
-
: undefined,
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
};
|
|
442
|
-
|
|
443
|
-
/* -----------------------------------------------------------
|
|
444
|
-
INVERTERS
|
|
445
|
-
----------------------------------------------------------- */
|
|
446
|
-
/**
|
|
447
|
-
* Convert LLM schema back to OpenAPI schema.
|
|
448
|
-
*
|
|
449
|
-
* Restores constraint information from description tags and converts `$defs`
|
|
450
|
-
* references to `#/components/schemas`.
|
|
451
|
-
*
|
|
452
|
-
* @param props.components Target components (mutated with definitions)
|
|
453
|
-
* @param props.schema LLM schema to invert
|
|
454
|
-
* @param props.$defs LLM schema definitions
|
|
455
|
-
* @returns OpenAPI JSON schema
|
|
456
|
-
*/
|
|
457
|
-
export const invert = (props: {
|
|
458
|
-
components: OpenApi.IComponents;
|
|
459
|
-
schema: ILlmSchema;
|
|
460
|
-
$defs: Record<string, ILlmSchema>;
|
|
461
|
-
}): OpenApi.IJsonSchema => {
|
|
462
|
-
const union: OpenApi.IJsonSchema[] = [];
|
|
463
|
-
const attribute: IJsonSchemaAttribute = {
|
|
464
|
-
title: props.schema.title,
|
|
465
|
-
description: props.schema.description,
|
|
466
|
-
deprecated: props.schema.deprecated,
|
|
467
|
-
readOnly: props.schema.readOnly,
|
|
468
|
-
writeOnly: props.schema.writeOnly,
|
|
469
|
-
example: props.schema.example,
|
|
470
|
-
examples: props.schema.examples,
|
|
471
|
-
...Object.fromEntries(
|
|
472
|
-
Object.entries(props.schema).filter(
|
|
473
|
-
([key, value]) => key.startsWith("x-") && value !== undefined,
|
|
474
|
-
),
|
|
475
|
-
),
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const next = (schema: ILlmSchema): OpenApi.IJsonSchema =>
|
|
479
|
-
invert({
|
|
480
|
-
components: props.components,
|
|
481
|
-
$defs: props.$defs,
|
|
482
|
-
schema,
|
|
483
|
-
});
|
|
484
|
-
const visit = (schema: ILlmSchema): void => {
|
|
485
|
-
if (LlmTypeChecker.isArray(schema))
|
|
486
|
-
union.push({
|
|
487
|
-
...schema,
|
|
488
|
-
...LlmDescriptionInverter.array(schema.description),
|
|
489
|
-
items: next(schema.items),
|
|
490
|
-
});
|
|
491
|
-
else if (LlmTypeChecker.isObject(schema))
|
|
492
|
-
union.push({
|
|
493
|
-
...schema,
|
|
494
|
-
properties: Object.fromEntries(
|
|
495
|
-
Object.entries(schema.properties).map(([key, value]) => [
|
|
496
|
-
key,
|
|
497
|
-
next(value),
|
|
498
|
-
]),
|
|
499
|
-
),
|
|
500
|
-
additionalProperties:
|
|
501
|
-
typeof schema.additionalProperties === "object" &&
|
|
502
|
-
schema.additionalProperties !== null
|
|
503
|
-
? next(schema.additionalProperties)
|
|
504
|
-
: schema.additionalProperties,
|
|
505
|
-
});
|
|
506
|
-
else if (LlmTypeChecker.isAnyOf(schema)) schema.anyOf.forEach(visit);
|
|
507
|
-
else if (LlmTypeChecker.isReference(schema)) {
|
|
508
|
-
const key: string =
|
|
509
|
-
schema.$ref.split("#/$defs/")[1] ?? schema.$ref.split("/").at(-1)!;
|
|
510
|
-
if (props.components.schemas?.[key] === undefined) {
|
|
511
|
-
props.components.schemas ??= {};
|
|
512
|
-
props.components.schemas[key] = {};
|
|
513
|
-
props.components.schemas[key] = next(props.$defs[key] ?? {});
|
|
514
|
-
}
|
|
515
|
-
union.push({
|
|
516
|
-
...schema,
|
|
517
|
-
$ref: `#/components/schemas/${key}`,
|
|
518
|
-
});
|
|
519
|
-
} else if (LlmTypeChecker.isBoolean(schema))
|
|
520
|
-
if (!!schema.enum?.length)
|
|
521
|
-
schema.enum.forEach((v) =>
|
|
522
|
-
union.push({
|
|
523
|
-
const: v,
|
|
524
|
-
}),
|
|
525
|
-
);
|
|
526
|
-
else union.push(schema);
|
|
527
|
-
else if (
|
|
528
|
-
LlmTypeChecker.isInteger(schema) ||
|
|
529
|
-
LlmTypeChecker.isNumber(schema)
|
|
530
|
-
)
|
|
531
|
-
if (!!schema.enum?.length)
|
|
532
|
-
schema.enum.forEach((v) =>
|
|
533
|
-
union.push({
|
|
534
|
-
const: v,
|
|
535
|
-
}),
|
|
536
|
-
);
|
|
537
|
-
else
|
|
538
|
-
union.push({
|
|
539
|
-
...schema,
|
|
540
|
-
...LlmDescriptionInverter.numeric(schema.description),
|
|
541
|
-
...{ enum: undefined },
|
|
542
|
-
});
|
|
543
|
-
else if (LlmTypeChecker.isString(schema))
|
|
544
|
-
if (!!schema.enum?.length)
|
|
545
|
-
schema.enum.forEach((v) =>
|
|
546
|
-
union.push({
|
|
547
|
-
const: v,
|
|
548
|
-
}),
|
|
549
|
-
);
|
|
550
|
-
else
|
|
551
|
-
union.push({
|
|
552
|
-
...schema,
|
|
553
|
-
...LlmDescriptionInverter.string(schema.description),
|
|
554
|
-
...{ enum: undefined },
|
|
555
|
-
});
|
|
556
|
-
else
|
|
557
|
-
union.push({
|
|
558
|
-
...schema,
|
|
559
|
-
});
|
|
560
|
-
};
|
|
561
|
-
visit(props.schema);
|
|
562
|
-
|
|
563
|
-
return {
|
|
564
|
-
...attribute,
|
|
565
|
-
...(union.length === 0
|
|
566
|
-
? { type: undefined }
|
|
567
|
-
: union.length === 1
|
|
568
|
-
? { ...union[0] }
|
|
569
|
-
: {
|
|
570
|
-
oneOf: union.map((u) => ({ ...u, nullable: undefined })),
|
|
571
|
-
discriminator:
|
|
572
|
-
LlmTypeChecker.isAnyOf(props.schema) &&
|
|
573
|
-
props.schema["x-discriminator"] !== undefined
|
|
574
|
-
? {
|
|
575
|
-
propertyName:
|
|
576
|
-
props.schema["x-discriminator"].propertyName,
|
|
577
|
-
mapping:
|
|
578
|
-
props.schema["x-discriminator"].mapping !== undefined
|
|
579
|
-
? Object.fromEntries(
|
|
580
|
-
Object.entries(
|
|
581
|
-
props.schema["x-discriminator"].mapping,
|
|
582
|
-
).map(([key, value]) => [
|
|
583
|
-
key,
|
|
584
|
-
`#/components/schemas/${value.split("/").at(-1)}`,
|
|
585
|
-
]),
|
|
586
|
-
)
|
|
587
|
-
: undefined,
|
|
588
|
-
}
|
|
589
|
-
: undefined,
|
|
590
|
-
}),
|
|
591
|
-
} satisfies OpenApi.IJsonSchema;
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
const validateStrict = (
|
|
596
|
-
schema: OpenApi.IJsonSchema,
|
|
597
|
-
accessor: string,
|
|
598
|
-
): IJsonSchemaTransformError.IReason[] => {
|
|
599
|
-
const reasons: IJsonSchemaTransformError.IReason[] = [];
|
|
600
|
-
if (OpenApiTypeChecker.isObject(schema)) {
|
|
601
|
-
if (!!schema.additionalProperties)
|
|
602
|
-
reasons.push({
|
|
603
|
-
schema: schema,
|
|
604
|
-
accessor: `${accessor}.additionalProperties`,
|
|
605
|
-
message:
|
|
606
|
-
"LLM does not allow additionalProperties in strict mode, the dynamic key typed object.",
|
|
607
|
-
});
|
|
608
|
-
for (const key of Object.keys(schema.properties ?? {}))
|
|
609
|
-
if (schema.required?.includes(key) === false)
|
|
610
|
-
reasons.push({
|
|
611
|
-
schema: schema,
|
|
612
|
-
accessor: `${accessor}.properties.${key}`,
|
|
613
|
-
message: "LLM does not allow optional properties in strict mode.",
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
return reasons;
|
|
617
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
IJsonSchemaAttribute,
|
|
3
|
+
IJsonSchemaTransformError,
|
|
4
|
+
ILlmSchema,
|
|
5
|
+
IResult,
|
|
6
|
+
OpenApi,
|
|
7
|
+
} from "@typia/interface";
|
|
8
|
+
|
|
9
|
+
import { JsonDescriptor } from "../utils/internal/JsonDescriptor";
|
|
10
|
+
import { LlmTypeChecker } from "../validators/LlmTypeChecker";
|
|
11
|
+
import { OpenApiTypeChecker } from "../validators/OpenApiTypeChecker";
|
|
12
|
+
import { LlmDescriptionInverter } from "./internal/LlmDescriptionInverter";
|
|
13
|
+
import { LlmParametersFinder } from "./internal/LlmParametersComposer";
|
|
14
|
+
import { OpenApiConstraintShifter } from "./internal/OpenApiConstraintShifter";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* OpenAPI to LLM schema converter.
|
|
18
|
+
*
|
|
19
|
+
* `LlmSchemaConverter` converts OpenAPI JSON schemas to LLM-compatible
|
|
20
|
+
* {@link ILlmSchema} format. LLMs don't fully support JSON Schema, so this
|
|
21
|
+
* simplifies schemas by removing unsupported features (tuples, `const`, mixed
|
|
22
|
+
* unions).
|
|
23
|
+
*
|
|
24
|
+
* Main functions:
|
|
25
|
+
*
|
|
26
|
+
* - {@link parameters}: Convert object schema to {@link ILlmSchema.IParameters}
|
|
27
|
+
* - {@link schema}: Convert any schema to {@link ILlmSchema}
|
|
28
|
+
* - {@link invert}: Extract constraints from description back to schema
|
|
29
|
+
*
|
|
30
|
+
* Configuration options ({@link ILlmSchema.IConfig}):
|
|
31
|
+
*
|
|
32
|
+
* - `strict`: OpenAI structured output mode (all properties required)
|
|
33
|
+
*
|
|
34
|
+
* @author Jeongho Nam - https://github.com/samchon
|
|
35
|
+
*/
|
|
36
|
+
export namespace LlmSchemaConverter {
|
|
37
|
+
/**
|
|
38
|
+
* Get configuration with defaults applied.
|
|
39
|
+
*
|
|
40
|
+
* @param config Partial configuration
|
|
41
|
+
* @returns Full configuration with defaults
|
|
42
|
+
*/
|
|
43
|
+
export const getConfig = (
|
|
44
|
+
config?: Partial<ILlmSchema.IConfig> | undefined,
|
|
45
|
+
): ILlmSchema.IConfig => ({
|
|
46
|
+
strict: config?.strict ?? false,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/* -----------------------------------------------------------
|
|
50
|
+
CONVERTERS
|
|
51
|
+
----------------------------------------------------------- */
|
|
52
|
+
/**
|
|
53
|
+
* Convert OpenAPI object schema to LLM parameters schema.
|
|
54
|
+
*
|
|
55
|
+
* @param props.config Conversion configuration
|
|
56
|
+
* @param props.components OpenAPI components for reference resolution
|
|
57
|
+
* @param props.schema Object or reference schema to convert
|
|
58
|
+
* @param props.accessor Error path accessor
|
|
59
|
+
* @param props.refAccessor Reference path accessor
|
|
60
|
+
* @returns Converted parameters or error
|
|
61
|
+
*/
|
|
62
|
+
export const parameters = (props: {
|
|
63
|
+
config?: Partial<ILlmSchema.IConfig>;
|
|
64
|
+
components: OpenApi.IComponents;
|
|
65
|
+
schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference;
|
|
66
|
+
accessor?: string;
|
|
67
|
+
refAccessor?: string;
|
|
68
|
+
}): IResult<ILlmSchema.IParameters, IJsonSchemaTransformError> => {
|
|
69
|
+
const config: ILlmSchema.IConfig = getConfig(props.config);
|
|
70
|
+
const entity: IResult<
|
|
71
|
+
OpenApi.IJsonSchema.IObject,
|
|
72
|
+
IJsonSchemaTransformError
|
|
73
|
+
> = LlmParametersFinder.parameters({
|
|
74
|
+
...props,
|
|
75
|
+
method: "LlmSchemaConverter.parameters",
|
|
76
|
+
});
|
|
77
|
+
if (entity.success === false) return entity;
|
|
78
|
+
|
|
79
|
+
const $defs: Record<string, ILlmSchema> = {};
|
|
80
|
+
const result: IResult<ILlmSchema, IJsonSchemaTransformError> = transform({
|
|
81
|
+
...props,
|
|
82
|
+
config,
|
|
83
|
+
$defs,
|
|
84
|
+
schema: entity.value,
|
|
85
|
+
});
|
|
86
|
+
if (result.success === false) return result;
|
|
87
|
+
return {
|
|
88
|
+
success: true,
|
|
89
|
+
value: {
|
|
90
|
+
...(result.value as ILlmSchema.IObject),
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
$defs,
|
|
93
|
+
description: OpenApiTypeChecker.isReference(props.schema)
|
|
94
|
+
? JsonDescriptor.cascade({
|
|
95
|
+
prefix: "#/components/schemas/",
|
|
96
|
+
components: props.components,
|
|
97
|
+
schema: {
|
|
98
|
+
...props.schema,
|
|
99
|
+
description: result.value.description,
|
|
100
|
+
},
|
|
101
|
+
escape: true,
|
|
102
|
+
})
|
|
103
|
+
: result.value.description,
|
|
104
|
+
} satisfies ILlmSchema.IParameters,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert OpenAPI schema to LLM schema.
|
|
110
|
+
*
|
|
111
|
+
* @param props.config Conversion configuration
|
|
112
|
+
* @param props.components OpenAPI components for reference resolution
|
|
113
|
+
* @param props.$defs Definition store (mutated with referenced types)
|
|
114
|
+
* @param props.schema Schema to convert
|
|
115
|
+
* @param props.accessor Error path accessor
|
|
116
|
+
* @param props.refAccessor Reference path accessor
|
|
117
|
+
* @returns Converted schema or error
|
|
118
|
+
*/
|
|
119
|
+
export const schema = (props: {
|
|
120
|
+
config?: Partial<ILlmSchema.IConfig>;
|
|
121
|
+
components: OpenApi.IComponents;
|
|
122
|
+
$defs: Record<string, ILlmSchema>;
|
|
123
|
+
schema: OpenApi.IJsonSchema;
|
|
124
|
+
accessor?: string;
|
|
125
|
+
refAccessor?: string;
|
|
126
|
+
}): IResult<ILlmSchema, IJsonSchemaTransformError> =>
|
|
127
|
+
transform({
|
|
128
|
+
config: getConfig(props.config),
|
|
129
|
+
components: props.components,
|
|
130
|
+
$defs: props.$defs,
|
|
131
|
+
schema: props.schema,
|
|
132
|
+
accessor: props.accessor,
|
|
133
|
+
refAccessor: props.refAccessor,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const transform = (props: {
|
|
137
|
+
config: ILlmSchema.IConfig;
|
|
138
|
+
components: OpenApi.IComponents;
|
|
139
|
+
$defs: Record<string, ILlmSchema>;
|
|
140
|
+
schema: OpenApi.IJsonSchema;
|
|
141
|
+
accessor?: string;
|
|
142
|
+
refAccessor?: string;
|
|
143
|
+
}): IResult<ILlmSchema, IJsonSchemaTransformError> => {
|
|
144
|
+
// PREPARE ASSETS
|
|
145
|
+
const union: Array<ILlmSchema> = [];
|
|
146
|
+
const attribute: IJsonSchemaAttribute = {
|
|
147
|
+
title: props.schema.title,
|
|
148
|
+
description: props.schema.description,
|
|
149
|
+
deprecated: props.schema.deprecated,
|
|
150
|
+
readOnly: props.schema.readOnly,
|
|
151
|
+
writeOnly: props.schema.writeOnly,
|
|
152
|
+
example: props.schema.example,
|
|
153
|
+
examples: props.schema.examples,
|
|
154
|
+
...Object.fromEntries(
|
|
155
|
+
Object.entries(props.schema).filter(
|
|
156
|
+
([key, value]) => key.startsWith("x-") && value !== undefined,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// VALIDADTE SCHEMA
|
|
162
|
+
const reasons: IJsonSchemaTransformError.IReason[] = [];
|
|
163
|
+
OpenApiTypeChecker.visit({
|
|
164
|
+
closure: (next, accessor) => {
|
|
165
|
+
if (props.config.strict === true) {
|
|
166
|
+
// STRICT MODE VALIDATION
|
|
167
|
+
reasons.push(...validateStrict(next, accessor));
|
|
168
|
+
}
|
|
169
|
+
if (OpenApiTypeChecker.isTuple(next))
|
|
170
|
+
reasons.push({
|
|
171
|
+
accessor,
|
|
172
|
+
schema: next,
|
|
173
|
+
message: `LLM does not allow tuple type.`,
|
|
174
|
+
});
|
|
175
|
+
else if (OpenApiTypeChecker.isReference(next)) {
|
|
176
|
+
// UNABLE TO FIND MATCHED REFERENCE
|
|
177
|
+
const key: string =
|
|
178
|
+
next.$ref.split("#/components/schemas/")[1] ??
|
|
179
|
+
next.$ref.split("/").at(-1)!;
|
|
180
|
+
if (props.components.schemas?.[key] === undefined)
|
|
181
|
+
reasons.push({
|
|
182
|
+
schema: next,
|
|
183
|
+
accessor: accessor,
|
|
184
|
+
message: `unable to find reference type ${JSON.stringify(key)}.`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
components: props.components,
|
|
189
|
+
schema: props.schema,
|
|
190
|
+
accessor: props.accessor,
|
|
191
|
+
refAccessor: props.refAccessor,
|
|
192
|
+
});
|
|
193
|
+
if (reasons.length > 0)
|
|
194
|
+
return {
|
|
195
|
+
success: false,
|
|
196
|
+
error: {
|
|
197
|
+
method: "LlmSchemaConverter.schema",
|
|
198
|
+
message: "Failed to compose LLM schema",
|
|
199
|
+
reasons,
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const visitConstant = (input: OpenApi.IJsonSchema): void => {
|
|
204
|
+
const insert = (value: any): void => {
|
|
205
|
+
const matched:
|
|
206
|
+
| ILlmSchema.IString
|
|
207
|
+
| ILlmSchema.INumber
|
|
208
|
+
| ILlmSchema.IBoolean
|
|
209
|
+
| undefined = union.find(
|
|
210
|
+
(u) =>
|
|
211
|
+
(u as (IJsonSchemaAttribute & { type: string }) | undefined)
|
|
212
|
+
?.type === typeof value,
|
|
213
|
+
) as ILlmSchema.IString | undefined;
|
|
214
|
+
if (matched !== undefined) {
|
|
215
|
+
matched.enum ??= [];
|
|
216
|
+
matched.enum.push(value);
|
|
217
|
+
} else
|
|
218
|
+
union.push({
|
|
219
|
+
type: typeof value as "number",
|
|
220
|
+
enum: [value],
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
if (OpenApiTypeChecker.isConstant(input)) insert(input.const);
|
|
224
|
+
else if (OpenApiTypeChecker.isOneOf(input))
|
|
225
|
+
input.oneOf.forEach(visitConstant);
|
|
226
|
+
};
|
|
227
|
+
const visit = (input: OpenApi.IJsonSchema, accessor: string): void => {
|
|
228
|
+
if (OpenApiTypeChecker.isOneOf(input)) {
|
|
229
|
+
// UNION TYPE
|
|
230
|
+
input.oneOf.forEach((s, i) => visit(s, `${accessor}.oneOf[${i}]`));
|
|
231
|
+
} else if (OpenApiTypeChecker.isReference(input)) {
|
|
232
|
+
// REFERENCE TYPE
|
|
233
|
+
const key: string =
|
|
234
|
+
input.$ref.split("#/components/schemas/")[1] ??
|
|
235
|
+
input.$ref.split("/").at(-1)!;
|
|
236
|
+
const target: OpenApi.IJsonSchema | undefined =
|
|
237
|
+
props.components.schemas?.[key];
|
|
238
|
+
if (target === undefined) return;
|
|
239
|
+
else {
|
|
240
|
+
// KEEP THE REFERENCE TYPE
|
|
241
|
+
const out = () => {
|
|
242
|
+
union.push({
|
|
243
|
+
...input,
|
|
244
|
+
$ref: `#/$defs/${key}`,
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
if (props.$defs[key] !== undefined) return out();
|
|
248
|
+
|
|
249
|
+
props.$defs[key] = {};
|
|
250
|
+
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
251
|
+
transform({
|
|
252
|
+
config: props.config,
|
|
253
|
+
components: props.components,
|
|
254
|
+
$defs: props.$defs,
|
|
255
|
+
schema: target,
|
|
256
|
+
refAccessor: props.refAccessor,
|
|
257
|
+
accessor: `${props.refAccessor ?? "$def"}[${JSON.stringify(key)}]`,
|
|
258
|
+
});
|
|
259
|
+
if (converted.success === false) return; // UNREACHABLE
|
|
260
|
+
props.$defs[key] = converted.value;
|
|
261
|
+
return out();
|
|
262
|
+
}
|
|
263
|
+
} else if (OpenApiTypeChecker.isObject(input)) {
|
|
264
|
+
// OBJECT TYPE
|
|
265
|
+
const properties: Record<string, ILlmSchema> = Object.fromEntries(
|
|
266
|
+
Object.entries(input.properties ?? {})
|
|
267
|
+
.map(([key, value]) => {
|
|
268
|
+
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
269
|
+
transform({
|
|
270
|
+
config: props.config,
|
|
271
|
+
components: props.components,
|
|
272
|
+
$defs: props.$defs,
|
|
273
|
+
schema: value,
|
|
274
|
+
refAccessor: props.refAccessor,
|
|
275
|
+
accessor: `${props.accessor ?? "$input.schema"}.properties[${JSON.stringify(key)}]`,
|
|
276
|
+
});
|
|
277
|
+
if (converted.success === false) {
|
|
278
|
+
reasons.push(...converted.error.reasons);
|
|
279
|
+
return [key, null];
|
|
280
|
+
}
|
|
281
|
+
return [key, converted.value];
|
|
282
|
+
})
|
|
283
|
+
.filter(([, value]) => value !== null),
|
|
284
|
+
);
|
|
285
|
+
if (Object.values(properties).some((v) => v === null)) return;
|
|
286
|
+
|
|
287
|
+
const additionalProperties: ILlmSchema | boolean | undefined | null =
|
|
288
|
+
(() => {
|
|
289
|
+
if (
|
|
290
|
+
typeof input.additionalProperties === "object" &&
|
|
291
|
+
input.additionalProperties !== null
|
|
292
|
+
) {
|
|
293
|
+
const converted: IResult<ILlmSchema, IJsonSchemaTransformError> =
|
|
294
|
+
transform({
|
|
295
|
+
config: props.config,
|
|
296
|
+
components: props.components,
|
|
297
|
+
$defs: props.$defs,
|
|
298
|
+
schema: input.additionalProperties,
|
|
299
|
+
refAccessor: props.refAccessor,
|
|
300
|
+
accessor: `${accessor}.additionalProperties`,
|
|
301
|
+
});
|
|
302
|
+
if (converted.success === false) {
|
|
303
|
+
reasons.push(...converted.error.reasons);
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
return converted.value;
|
|
307
|
+
}
|
|
308
|
+
return props.config.strict === true
|
|
309
|
+
? false
|
|
310
|
+
: input.additionalProperties;
|
|
311
|
+
})();
|
|
312
|
+
if (additionalProperties === null) return;
|
|
313
|
+
union.push({
|
|
314
|
+
...input,
|
|
315
|
+
properties,
|
|
316
|
+
additionalProperties,
|
|
317
|
+
required: input.required ?? [],
|
|
318
|
+
description:
|
|
319
|
+
props.config.strict === true
|
|
320
|
+
? JsonDescriptor.take(input)
|
|
321
|
+
: input.description,
|
|
322
|
+
});
|
|
323
|
+
} else if (OpenApiTypeChecker.isArray(input)) {
|
|
324
|
+
// ARRAY TYPE
|
|
325
|
+
const items: IResult<ILlmSchema, IJsonSchemaTransformError> = transform(
|
|
326
|
+
{
|
|
327
|
+
config: props.config,
|
|
328
|
+
components: props.components,
|
|
329
|
+
$defs: props.$defs,
|
|
330
|
+
schema: input.items,
|
|
331
|
+
refAccessor: props.refAccessor,
|
|
332
|
+
accessor: `${accessor}.items`,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
if (items.success === false) {
|
|
336
|
+
reasons.push(...items.error.reasons);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
union.push(
|
|
340
|
+
props.config.strict === true
|
|
341
|
+
? OpenApiConstraintShifter.shiftArray({
|
|
342
|
+
...input,
|
|
343
|
+
items: items.value,
|
|
344
|
+
})
|
|
345
|
+
: {
|
|
346
|
+
...input,
|
|
347
|
+
items: items.value,
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
} else if (OpenApiTypeChecker.isString(input))
|
|
351
|
+
union.push(
|
|
352
|
+
props.config.strict === true
|
|
353
|
+
? OpenApiConstraintShifter.shiftString({ ...input })
|
|
354
|
+
: input,
|
|
355
|
+
);
|
|
356
|
+
else if (
|
|
357
|
+
OpenApiTypeChecker.isNumber(input) ||
|
|
358
|
+
OpenApiTypeChecker.isInteger(input)
|
|
359
|
+
)
|
|
360
|
+
union.push(
|
|
361
|
+
props.config.strict === true
|
|
362
|
+
? OpenApiConstraintShifter.shiftNumeric({ ...input })
|
|
363
|
+
: input,
|
|
364
|
+
);
|
|
365
|
+
else if (OpenApiTypeChecker.isTuple(input))
|
|
366
|
+
return; // UNREACHABLE
|
|
367
|
+
else if (OpenApiTypeChecker.isConstant(input) === false)
|
|
368
|
+
union.push({ ...input });
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
visitConstant(props.schema);
|
|
372
|
+
visit(props.schema, props.accessor ?? "$input.schema");
|
|
373
|
+
|
|
374
|
+
if (reasons.length > 0)
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
error: {
|
|
378
|
+
method: "LlmSchemaConverter.schema",
|
|
379
|
+
message: "Failed to compose LLM schema",
|
|
380
|
+
reasons,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
else if (union.length === 0)
|
|
384
|
+
return {
|
|
385
|
+
// unknown type
|
|
386
|
+
success: true,
|
|
387
|
+
value: {
|
|
388
|
+
...attribute,
|
|
389
|
+
type: undefined,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
else if (union.length === 1)
|
|
393
|
+
return {
|
|
394
|
+
// single type
|
|
395
|
+
success: true,
|
|
396
|
+
value: {
|
|
397
|
+
...attribute,
|
|
398
|
+
...union[0],
|
|
399
|
+
description:
|
|
400
|
+
props.config.strict === true &&
|
|
401
|
+
LlmTypeChecker.isReference(union[0]!)
|
|
402
|
+
? undefined
|
|
403
|
+
: (union[0]!.description ?? attribute.description),
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
return {
|
|
407
|
+
success: true,
|
|
408
|
+
value: {
|
|
409
|
+
...attribute,
|
|
410
|
+
anyOf: union.map((u) => ({
|
|
411
|
+
...u,
|
|
412
|
+
description:
|
|
413
|
+
props.config.strict === true && LlmTypeChecker.isReference(u)
|
|
414
|
+
? undefined
|
|
415
|
+
: u.description,
|
|
416
|
+
})),
|
|
417
|
+
"x-discriminator":
|
|
418
|
+
OpenApiTypeChecker.isOneOf(props.schema) &&
|
|
419
|
+
props.schema.discriminator !== undefined &&
|
|
420
|
+
props.schema.oneOf.length === union.length &&
|
|
421
|
+
union.every(
|
|
422
|
+
(e) => LlmTypeChecker.isReference(e) || LlmTypeChecker.isNull(e),
|
|
423
|
+
)
|
|
424
|
+
? {
|
|
425
|
+
propertyName: props.schema.discriminator.propertyName,
|
|
426
|
+
mapping:
|
|
427
|
+
props.schema.discriminator.mapping !== undefined
|
|
428
|
+
? Object.fromEntries(
|
|
429
|
+
Object.entries(props.schema.discriminator.mapping).map(
|
|
430
|
+
([key, value]) => [
|
|
431
|
+
key,
|
|
432
|
+
`#/$defs/${value.split("/").at(-1)}`,
|
|
433
|
+
],
|
|
434
|
+
),
|
|
435
|
+
)
|
|
436
|
+
: undefined,
|
|
437
|
+
}
|
|
438
|
+
: undefined,
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/* -----------------------------------------------------------
|
|
444
|
+
INVERTERS
|
|
445
|
+
----------------------------------------------------------- */
|
|
446
|
+
/**
|
|
447
|
+
* Convert LLM schema back to OpenAPI schema.
|
|
448
|
+
*
|
|
449
|
+
* Restores constraint information from description tags and converts `$defs`
|
|
450
|
+
* references to `#/components/schemas`.
|
|
451
|
+
*
|
|
452
|
+
* @param props.components Target components (mutated with definitions)
|
|
453
|
+
* @param props.schema LLM schema to invert
|
|
454
|
+
* @param props.$defs LLM schema definitions
|
|
455
|
+
* @returns OpenAPI JSON schema
|
|
456
|
+
*/
|
|
457
|
+
export const invert = (props: {
|
|
458
|
+
components: OpenApi.IComponents;
|
|
459
|
+
schema: ILlmSchema;
|
|
460
|
+
$defs: Record<string, ILlmSchema>;
|
|
461
|
+
}): OpenApi.IJsonSchema => {
|
|
462
|
+
const union: OpenApi.IJsonSchema[] = [];
|
|
463
|
+
const attribute: IJsonSchemaAttribute = {
|
|
464
|
+
title: props.schema.title,
|
|
465
|
+
description: props.schema.description,
|
|
466
|
+
deprecated: props.schema.deprecated,
|
|
467
|
+
readOnly: props.schema.readOnly,
|
|
468
|
+
writeOnly: props.schema.writeOnly,
|
|
469
|
+
example: props.schema.example,
|
|
470
|
+
examples: props.schema.examples,
|
|
471
|
+
...Object.fromEntries(
|
|
472
|
+
Object.entries(props.schema).filter(
|
|
473
|
+
([key, value]) => key.startsWith("x-") && value !== undefined,
|
|
474
|
+
),
|
|
475
|
+
),
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const next = (schema: ILlmSchema): OpenApi.IJsonSchema =>
|
|
479
|
+
invert({
|
|
480
|
+
components: props.components,
|
|
481
|
+
$defs: props.$defs,
|
|
482
|
+
schema,
|
|
483
|
+
});
|
|
484
|
+
const visit = (schema: ILlmSchema): void => {
|
|
485
|
+
if (LlmTypeChecker.isArray(schema))
|
|
486
|
+
union.push({
|
|
487
|
+
...schema,
|
|
488
|
+
...LlmDescriptionInverter.array(schema.description),
|
|
489
|
+
items: next(schema.items),
|
|
490
|
+
});
|
|
491
|
+
else if (LlmTypeChecker.isObject(schema))
|
|
492
|
+
union.push({
|
|
493
|
+
...schema,
|
|
494
|
+
properties: Object.fromEntries(
|
|
495
|
+
Object.entries(schema.properties).map(([key, value]) => [
|
|
496
|
+
key,
|
|
497
|
+
next(value),
|
|
498
|
+
]),
|
|
499
|
+
),
|
|
500
|
+
additionalProperties:
|
|
501
|
+
typeof schema.additionalProperties === "object" &&
|
|
502
|
+
schema.additionalProperties !== null
|
|
503
|
+
? next(schema.additionalProperties)
|
|
504
|
+
: schema.additionalProperties,
|
|
505
|
+
});
|
|
506
|
+
else if (LlmTypeChecker.isAnyOf(schema)) schema.anyOf.forEach(visit);
|
|
507
|
+
else if (LlmTypeChecker.isReference(schema)) {
|
|
508
|
+
const key: string =
|
|
509
|
+
schema.$ref.split("#/$defs/")[1] ?? schema.$ref.split("/").at(-1)!;
|
|
510
|
+
if (props.components.schemas?.[key] === undefined) {
|
|
511
|
+
props.components.schemas ??= {};
|
|
512
|
+
props.components.schemas[key] = {};
|
|
513
|
+
props.components.schemas[key] = next(props.$defs[key] ?? {});
|
|
514
|
+
}
|
|
515
|
+
union.push({
|
|
516
|
+
...schema,
|
|
517
|
+
$ref: `#/components/schemas/${key}`,
|
|
518
|
+
});
|
|
519
|
+
} else if (LlmTypeChecker.isBoolean(schema))
|
|
520
|
+
if (!!schema.enum?.length)
|
|
521
|
+
schema.enum.forEach((v) =>
|
|
522
|
+
union.push({
|
|
523
|
+
const: v,
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
else union.push(schema);
|
|
527
|
+
else if (
|
|
528
|
+
LlmTypeChecker.isInteger(schema) ||
|
|
529
|
+
LlmTypeChecker.isNumber(schema)
|
|
530
|
+
)
|
|
531
|
+
if (!!schema.enum?.length)
|
|
532
|
+
schema.enum.forEach((v) =>
|
|
533
|
+
union.push({
|
|
534
|
+
const: v,
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
else
|
|
538
|
+
union.push({
|
|
539
|
+
...schema,
|
|
540
|
+
...LlmDescriptionInverter.numeric(schema.description),
|
|
541
|
+
...{ enum: undefined },
|
|
542
|
+
});
|
|
543
|
+
else if (LlmTypeChecker.isString(schema))
|
|
544
|
+
if (!!schema.enum?.length)
|
|
545
|
+
schema.enum.forEach((v) =>
|
|
546
|
+
union.push({
|
|
547
|
+
const: v,
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
else
|
|
551
|
+
union.push({
|
|
552
|
+
...schema,
|
|
553
|
+
...LlmDescriptionInverter.string(schema.description),
|
|
554
|
+
...{ enum: undefined },
|
|
555
|
+
});
|
|
556
|
+
else
|
|
557
|
+
union.push({
|
|
558
|
+
...schema,
|
|
559
|
+
});
|
|
560
|
+
};
|
|
561
|
+
visit(props.schema);
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
...attribute,
|
|
565
|
+
...(union.length === 0
|
|
566
|
+
? { type: undefined }
|
|
567
|
+
: union.length === 1
|
|
568
|
+
? { ...union[0] }
|
|
569
|
+
: {
|
|
570
|
+
oneOf: union.map((u) => ({ ...u, nullable: undefined })),
|
|
571
|
+
discriminator:
|
|
572
|
+
LlmTypeChecker.isAnyOf(props.schema) &&
|
|
573
|
+
props.schema["x-discriminator"] !== undefined
|
|
574
|
+
? {
|
|
575
|
+
propertyName:
|
|
576
|
+
props.schema["x-discriminator"].propertyName,
|
|
577
|
+
mapping:
|
|
578
|
+
props.schema["x-discriminator"].mapping !== undefined
|
|
579
|
+
? Object.fromEntries(
|
|
580
|
+
Object.entries(
|
|
581
|
+
props.schema["x-discriminator"].mapping,
|
|
582
|
+
).map(([key, value]) => [
|
|
583
|
+
key,
|
|
584
|
+
`#/components/schemas/${value.split("/").at(-1)}`,
|
|
585
|
+
]),
|
|
586
|
+
)
|
|
587
|
+
: undefined,
|
|
588
|
+
}
|
|
589
|
+
: undefined,
|
|
590
|
+
}),
|
|
591
|
+
} satisfies OpenApi.IJsonSchema;
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const validateStrict = (
|
|
596
|
+
schema: OpenApi.IJsonSchema,
|
|
597
|
+
accessor: string,
|
|
598
|
+
): IJsonSchemaTransformError.IReason[] => {
|
|
599
|
+
const reasons: IJsonSchemaTransformError.IReason[] = [];
|
|
600
|
+
if (OpenApiTypeChecker.isObject(schema)) {
|
|
601
|
+
if (!!schema.additionalProperties)
|
|
602
|
+
reasons.push({
|
|
603
|
+
schema: schema,
|
|
604
|
+
accessor: `${accessor}.additionalProperties`,
|
|
605
|
+
message:
|
|
606
|
+
"LLM does not allow additionalProperties in strict mode, the dynamic key typed object.",
|
|
607
|
+
});
|
|
608
|
+
for (const key of Object.keys(schema.properties ?? {}))
|
|
609
|
+
if (schema.required?.includes(key) === false)
|
|
610
|
+
reasons.push({
|
|
611
|
+
schema: schema,
|
|
612
|
+
accessor: `${accessor}.properties.${key}`,
|
|
613
|
+
message: "LLM does not allow optional properties in strict mode.",
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return reasons;
|
|
617
|
+
};
|