@typia/utils 12.0.0-dev.20260309 → 12.0.0-dev.20260310
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/utils/internal/stringifyValidationFailure.js +17 -15
- package/lib/utils/internal/stringifyValidationFailure.js.map +1 -1
- package/lib/utils/internal/stringifyValidationFailure.mjs +17 -15
- package/lib/utils/internal/stringifyValidationFailure.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 +647 -647
- package/src/converters/OpenApiConverter.ts +285 -285
- package/src/converters/index.ts +5 -5
- package/src/converters/internal/LlmDescriptionInverter.ts +178 -178
- package/src/converters/internal/LlmParametersComposer.ts +52 -52
- package/src/converters/internal/OpenApiConstraintShifter.ts +154 -154
- package/src/converters/internal/OpenApiExclusiveEmender.ts +46 -46
- package/src/converters/internal/OpenApiV3Downgrader.ts +355 -355
- package/src/converters/internal/OpenApiV3Upgrader.ts +470 -470
- package/src/converters/internal/OpenApiV3_1Upgrader.ts +685 -685
- package/src/converters/internal/SwaggerV2Downgrader.ts +424 -424
- package/src/converters/internal/SwaggerV2Upgrader.ts +523 -523
- package/src/http/HttpError.ts +107 -107
- package/src/http/HttpLlm.ts +167 -167
- package/src/http/HttpMigration.ts +92 -92
- package/src/http/index.ts +3 -3
- package/src/http/internal/HttpLlmApplicationComposer.ts +361 -361
- package/src/http/internal/HttpLlmFunctionFetcher.ts +37 -37
- package/src/http/internal/HttpMigrateApplicationComposer.ts +56 -56
- package/src/http/internal/HttpMigrateRouteAccessor.ts +135 -135
- package/src/http/internal/HttpMigrateRouteComposer.ts +505 -505
- package/src/http/internal/HttpMigrateRouteFetcher.ts +203 -203
- package/src/index.ts +4 -4
- package/src/utils/ArrayUtil.ts +42 -42
- package/src/utils/LlmJson.ts +141 -141
- package/src/utils/MapUtil.ts +15 -15
- package/src/utils/NamingConvention.ts +205 -205
- package/src/utils/Singleton.ts +17 -17
- package/src/utils/StringUtil.ts +14 -14
- package/src/utils/dedent.ts +57 -57
- package/src/utils/index.ts +8 -8
- package/src/utils/internal/EndpointUtil.ts +44 -44
- package/src/utils/internal/JsonDescriptor.ts +70 -70
- package/src/utils/internal/OpenApiTypeCheckerBase.ts +822 -822
- package/src/utils/internal/coerceLlmArguments.ts +314 -314
- package/src/utils/internal/parseLenientJson.ts +894 -894
- package/src/utils/internal/stringifyValidationFailure.ts +415 -411
- package/src/validators/LlmTypeChecker.ts +402 -402
- package/src/validators/OpenApiTypeChecker.ts +297 -297
- package/src/validators/OpenApiV3TypeChecker.ts +70 -70
- package/src/validators/OpenApiV3_1TypeChecker.ts +86 -86
- package/src/validators/OpenApiValidator.ts +94 -94
- package/src/validators/SwaggerV2TypeChecker.ts +71 -71
- package/src/validators/functional/_isBigintString.ts +8 -8
- package/src/validators/functional/_isFormatByte.ts +7 -7
- package/src/validators/functional/_isFormatDate.ts +3 -3
- package/src/validators/functional/_isFormatDateTime.ts +4 -4
- package/src/validators/functional/_isFormatDuration.ts +4 -4
- package/src/validators/functional/_isFormatEmail.ts +4 -4
- package/src/validators/functional/_isFormatHostname.ts +4 -4
- package/src/validators/functional/_isFormatIdnEmail.ts +4 -4
- package/src/validators/functional/_isFormatIdnHostname.ts +4 -4
- package/src/validators/functional/_isFormatIpv4.ts +4 -4
- package/src/validators/functional/_isFormatIpv6.ts +4 -4
- package/src/validators/functional/_isFormatIri.ts +3 -3
- package/src/validators/functional/_isFormatIriReference.ts +4 -4
- package/src/validators/functional/_isFormatJsonPointer.ts +3 -3
- package/src/validators/functional/_isFormatPassword.ts +1 -1
- package/src/validators/functional/_isFormatRegex.ts +8 -8
- package/src/validators/functional/_isFormatRelativeJsonPointer.ts +4 -4
- package/src/validators/functional/_isFormatTime.ts +4 -4
- package/src/validators/functional/_isFormatUri.ts +6 -6
- package/src/validators/functional/_isFormatUriReference.ts +5 -5
- package/src/validators/functional/_isFormatUriTemplate.ts +4 -4
- package/src/validators/functional/_isFormatUrl.ts +4 -4
- package/src/validators/functional/_isFormatUuid.ts +3 -3
- package/src/validators/functional/_isUniqueItems.ts +159 -159
- package/src/validators/index.ts +14 -14
- package/src/validators/internal/IOpenApiValidatorContext.ts +17 -17
- package/src/validators/internal/OpenApiArrayValidator.ts +49 -49
- package/src/validators/internal/OpenApiBooleanValidator.ts +11 -11
- package/src/validators/internal/OpenApiConstantValidator.ts +11 -11
- package/src/validators/internal/OpenApiIntegerValidator.ts +49 -49
- package/src/validators/internal/OpenApiNumberValidator.ts +48 -48
- package/src/validators/internal/OpenApiObjectValidator.ts +83 -83
- package/src/validators/internal/OpenApiOneOfValidator.ts +309 -309
- package/src/validators/internal/OpenApiSchemaNamingRule.ts +124 -124
- package/src/validators/internal/OpenApiStationValidator.ts +115 -115
- package/src/validators/internal/OpenApiStringValidator.ts +88 -88
- package/src/validators/internal/OpenApiTupleValidator.ts +55 -55
|
@@ -1,361 +1,361 @@
|
|
|
1
|
-
import {
|
|
2
|
-
IHttpLlmApplication,
|
|
3
|
-
IHttpLlmFunction,
|
|
4
|
-
IHttpMigrateApplication,
|
|
5
|
-
IHttpMigrateRoute,
|
|
6
|
-
IJsonSchemaTransformError,
|
|
7
|
-
ILlmSchema,
|
|
8
|
-
IResult,
|
|
9
|
-
OpenApi,
|
|
10
|
-
} from "@typia/interface";
|
|
11
|
-
|
|
12
|
-
import { LlmSchemaConverter } from "../../converters/LlmSchemaConverter";
|
|
13
|
-
import { LlmJson } from "../../utils";
|
|
14
|
-
import { OpenApiValidator } from "../../validators/OpenApiValidator";
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Composes {@link IHttpLlmApplication} from an {@link IHttpMigrateApplication}.
|
|
18
|
-
*
|
|
19
|
-
* Converts OpenAPI-migrated HTTP routes into LLM function calling schemas,
|
|
20
|
-
* filtering out unsupported methods (HEAD) and content types
|
|
21
|
-
* (multipart/form-data), and shortening function names to fit the configured
|
|
22
|
-
* maximum length.
|
|
23
|
-
*/
|
|
24
|
-
export namespace HttpLlmApplicationComposer {
|
|
25
|
-
/**
|
|
26
|
-
* Builds an {@link IHttpLlmApplication} from migrated HTTP routes.
|
|
27
|
-
*
|
|
28
|
-
* Iterates all routes, converts each to an {@link IHttpLlmFunction}, and
|
|
29
|
-
* collects conversion errors. Applies function name shortening at the end.
|
|
30
|
-
*/
|
|
31
|
-
export const application = (props: {
|
|
32
|
-
migrate: IHttpMigrateApplication;
|
|
33
|
-
config?: Partial<IHttpLlmApplication.IConfig>;
|
|
34
|
-
}): IHttpLlmApplication => {
|
|
35
|
-
// fill in config defaults
|
|
36
|
-
const config: IHttpLlmApplication.IConfig = {
|
|
37
|
-
maxLength: props.config?.maxLength ?? 64,
|
|
38
|
-
equals: props.config?.equals ?? false,
|
|
39
|
-
reference: props.config?.reference ?? true,
|
|
40
|
-
strict: props.config?.strict ?? false,
|
|
41
|
-
};
|
|
42
|
-
// seed with pre-existing migration errors, excluding human-only endpoints
|
|
43
|
-
const errors: IHttpLlmApplication.IError[] = props.migrate.errors
|
|
44
|
-
.filter((e) => e.operation()["x-samchon-human"] !== true)
|
|
45
|
-
.map((e) => ({
|
|
46
|
-
method: e.method,
|
|
47
|
-
path: e.path,
|
|
48
|
-
messages: e.messages,
|
|
49
|
-
operation: () => e.operation(),
|
|
50
|
-
route: () => undefined,
|
|
51
|
-
}));
|
|
52
|
-
// convert each route to an LLM function, rejecting unsupported ones
|
|
53
|
-
const functions: IHttpLlmFunction[] = props.migrate.routes
|
|
54
|
-
.filter((e) => e.operation()["x-samchon-human"] !== true)
|
|
55
|
-
.map((route, i) => {
|
|
56
|
-
// reject HEAD — LLMs cannot interpret header-only responses
|
|
57
|
-
if (route.method === "head") {
|
|
58
|
-
errors.push({
|
|
59
|
-
method: route.method,
|
|
60
|
-
path: route.path,
|
|
61
|
-
messages: ["HEAD method is not supported in the LLM application."],
|
|
62
|
-
operation: () => route.operation(),
|
|
63
|
-
route: () => route as any as IHttpMigrateRoute,
|
|
64
|
-
});
|
|
65
|
-
return null;
|
|
66
|
-
// reject multipart/form-data — binary uploads not expressible in JSON Schema
|
|
67
|
-
} else if (
|
|
68
|
-
route.body?.type === "multipart/form-data" ||
|
|
69
|
-
route.success?.type === "multipart/form-data"
|
|
70
|
-
) {
|
|
71
|
-
errors.push({
|
|
72
|
-
method: route.method,
|
|
73
|
-
path: route.path,
|
|
74
|
-
messages: [
|
|
75
|
-
`The "multipart/form-data" content type is not supported in the LLM application.`,
|
|
76
|
-
],
|
|
77
|
-
operation: () => route.operation(),
|
|
78
|
-
route: () => route as any as IHttpMigrateRoute,
|
|
79
|
-
});
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
const localErrors: string[] = [];
|
|
83
|
-
const func: IHttpLlmFunction | null = composeFunction({
|
|
84
|
-
components: props.migrate.document().components,
|
|
85
|
-
config,
|
|
86
|
-
route,
|
|
87
|
-
errors: localErrors,
|
|
88
|
-
index: i,
|
|
89
|
-
});
|
|
90
|
-
if (func === null)
|
|
91
|
-
errors.push({
|
|
92
|
-
method: route.method,
|
|
93
|
-
path: route.path,
|
|
94
|
-
messages: localErrors,
|
|
95
|
-
operation: () => route.operation(),
|
|
96
|
-
route: () => route as any as IHttpMigrateRoute,
|
|
97
|
-
});
|
|
98
|
-
return func;
|
|
99
|
-
})
|
|
100
|
-
.filter((v): v is IHttpLlmFunction => v !== null);
|
|
101
|
-
|
|
102
|
-
const app: IHttpLlmApplication = {
|
|
103
|
-
config,
|
|
104
|
-
functions,
|
|
105
|
-
errors,
|
|
106
|
-
};
|
|
107
|
-
shorten(app, props.config?.maxLength ?? 64);
|
|
108
|
-
return app;
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Converts a single {@link IHttpMigrateRoute} into an {@link IHttpLlmFunction}
|
|
113
|
-
* by composing parameter/output schemas and validating function name
|
|
114
|
-
* constraints.
|
|
115
|
-
*/
|
|
116
|
-
const composeFunction = (props: {
|
|
117
|
-
components: OpenApi.IComponents;
|
|
118
|
-
route: IHttpMigrateRoute;
|
|
119
|
-
config: IHttpLlmApplication.IConfig;
|
|
120
|
-
errors: string[];
|
|
121
|
-
index: number;
|
|
122
|
-
}): IHttpLlmFunction | null => {
|
|
123
|
-
// accessor prefix for error messages (mirrors OpenAPI document structure)
|
|
124
|
-
const endpoint: string = `$input.paths[${JSON.stringify(props.route.path)}][${JSON.stringify(props.route.method)}]`;
|
|
125
|
-
const operation: OpenApi.IOperation = props.route.operation();
|
|
126
|
-
const description: string | undefined = concatDescription({
|
|
127
|
-
summary: operation.summary,
|
|
128
|
-
description: operation.description,
|
|
129
|
-
});
|
|
130
|
-
if ((description?.length ?? 0) > 1_024) {
|
|
131
|
-
props.errors.push(
|
|
132
|
-
`The description of the function is too long (must be equal or less than 1,024 characters, but ${description!.length.toLocaleString()} length).`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// build function name from route accessor, replacing forbidden chars
|
|
137
|
-
const name: string = emend(props.route.accessor.join("_"));
|
|
138
|
-
const isNameVariable: boolean = /^[a-zA-Z0-9_-]+$/.test(name);
|
|
139
|
-
const isNameStartsWithNumber: boolean = /^[0-9]/.test(name[0] ?? "");
|
|
140
|
-
if (isNameVariable === false)
|
|
141
|
-
props.errors.push(
|
|
142
|
-
`Elements of path (separated by '/') must be composed with alphabets, numbers, underscores, and hyphens`,
|
|
143
|
-
);
|
|
144
|
-
if (isNameStartsWithNumber === true)
|
|
145
|
-
props.errors.push(`Function name cannot start with a number.`);
|
|
146
|
-
|
|
147
|
-
//----
|
|
148
|
-
// CONSTRUCT SCHEMAS
|
|
149
|
-
//----
|
|
150
|
-
// merge path parameters, query, and body into a single object schema
|
|
151
|
-
const parameters: OpenApi.IJsonSchema.IObject = {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties: Object.fromEntries([
|
|
154
|
-
// path parameters (e.g., /users/:id)
|
|
155
|
-
...props.route.parameters.map(
|
|
156
|
-
(s) =>
|
|
157
|
-
[
|
|
158
|
-
s.key,
|
|
159
|
-
{
|
|
160
|
-
...s.schema,
|
|
161
|
-
description: s.parameter().description ?? s.schema.description,
|
|
162
|
-
},
|
|
163
|
-
] as const,
|
|
164
|
-
),
|
|
165
|
-
// query parameters
|
|
166
|
-
...(props.route.query
|
|
167
|
-
? [
|
|
168
|
-
[
|
|
169
|
-
props.route.query.key,
|
|
170
|
-
{
|
|
171
|
-
...props.route.query.schema,
|
|
172
|
-
title:
|
|
173
|
-
props.route.query.title() ?? props.route.query.schema.title,
|
|
174
|
-
description:
|
|
175
|
-
props.route.query.description() ??
|
|
176
|
-
props.route.query.schema.description,
|
|
177
|
-
},
|
|
178
|
-
] as const,
|
|
179
|
-
]
|
|
180
|
-
: []),
|
|
181
|
-
// request body
|
|
182
|
-
...(props.route.body
|
|
183
|
-
? [
|
|
184
|
-
[
|
|
185
|
-
props.route.body.key,
|
|
186
|
-
{
|
|
187
|
-
...props.route.body.schema,
|
|
188
|
-
description:
|
|
189
|
-
props.route.body.description() ??
|
|
190
|
-
props.route.body.schema.description,
|
|
191
|
-
},
|
|
192
|
-
] as const,
|
|
193
|
-
]
|
|
194
|
-
: []),
|
|
195
|
-
]),
|
|
196
|
-
};
|
|
197
|
-
parameters.required = Object.keys(parameters.properties ?? {});
|
|
198
|
-
|
|
199
|
-
// convert merged object schema to LLM parameters
|
|
200
|
-
const llmParameters: IResult<
|
|
201
|
-
ILlmSchema.IParameters,
|
|
202
|
-
IJsonSchemaTransformError
|
|
203
|
-
> = LlmSchemaConverter.parameters({
|
|
204
|
-
config: props.config,
|
|
205
|
-
components: props.components,
|
|
206
|
-
schema: parameters,
|
|
207
|
-
accessor: `${endpoint}.parameters`,
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// convert response schema to LLM output parameters
|
|
211
|
-
const output:
|
|
212
|
-
| IResult<ILlmSchema.IParameters, IJsonSchemaTransformError>
|
|
213
|
-
| undefined = props.route.success
|
|
214
|
-
? LlmSchemaConverter.parameters({
|
|
215
|
-
config: props.config,
|
|
216
|
-
components: props.components,
|
|
217
|
-
schema: props.route.success.schema as
|
|
218
|
-
| OpenApi.IJsonSchema.IObject
|
|
219
|
-
| OpenApi.IJsonSchema.IReference,
|
|
220
|
-
accessor: `${endpoint}.responses[${JSON.stringify(props.route.success.status)}][${JSON.stringify(props.route.success.type)}].schema`,
|
|
221
|
-
})
|
|
222
|
-
: undefined;
|
|
223
|
-
|
|
224
|
-
//----
|
|
225
|
-
// CONVERSION
|
|
226
|
-
//----
|
|
227
|
-
// bail out if any validation or conversion failed
|
|
228
|
-
if (
|
|
229
|
-
output?.success === false ||
|
|
230
|
-
llmParameters.success === false ||
|
|
231
|
-
isNameVariable === false ||
|
|
232
|
-
isNameStartsWithNumber === true ||
|
|
233
|
-
(description?.length ?? 0) > 1_024
|
|
234
|
-
) {
|
|
235
|
-
if (output?.success === false)
|
|
236
|
-
props.errors.push(
|
|
237
|
-
...output.error.reasons.map((r) => `${r.accessor}: ${r.message}`),
|
|
238
|
-
);
|
|
239
|
-
if (llmParameters.success === false)
|
|
240
|
-
props.errors.push(
|
|
241
|
-
// rewrite internal accessor to match OpenAPI requestBody path
|
|
242
|
-
...llmParameters.error.reasons.map((r) => {
|
|
243
|
-
const accessor: string = r.accessor.replace(
|
|
244
|
-
`parameters.properties["body"]`,
|
|
245
|
-
`requestBody.content[${JSON.stringify(props.route.body?.type ?? "application/json")}].schema`,
|
|
246
|
-
);
|
|
247
|
-
return `${accessor}: ${r.message}`;
|
|
248
|
-
}),
|
|
249
|
-
);
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// assemble the LLM function
|
|
254
|
-
return {
|
|
255
|
-
method: props.route.method as "get",
|
|
256
|
-
path: props.route.path,
|
|
257
|
-
name,
|
|
258
|
-
parameters: llmParameters.value,
|
|
259
|
-
output: output?.value,
|
|
260
|
-
description,
|
|
261
|
-
deprecated: operation.deprecated,
|
|
262
|
-
tags: operation.tags,
|
|
263
|
-
parse: (input: string) => LlmJson.parse(input, llmParameters.value),
|
|
264
|
-
coerce: (input: unknown) => LlmJson.coerce(input, llmParameters.value),
|
|
265
|
-
validate: OpenApiValidator.create({
|
|
266
|
-
components: props.components,
|
|
267
|
-
schema: parameters,
|
|
268
|
-
required: true,
|
|
269
|
-
equals: props.config.equals ?? false,
|
|
270
|
-
}),
|
|
271
|
-
route: () => props.route as any,
|
|
272
|
-
operation: () => props.route.operation(),
|
|
273
|
-
};
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Shortens function names exceeding the character limit.
|
|
278
|
-
*
|
|
279
|
-
* Tries progressively shorter accessor suffixes first, then falls back to
|
|
280
|
-
* index-prefixed names, and finally UUID as a last resort.
|
|
281
|
-
*/
|
|
282
|
-
export const shorten = (
|
|
283
|
-
app: IHttpLlmApplication,
|
|
284
|
-
limit: number = 64,
|
|
285
|
-
): void => {
|
|
286
|
-
// collect all names for uniqueness checks
|
|
287
|
-
const dictionary: Set<string> = new Set();
|
|
288
|
-
const longFunctions: IHttpLlmFunction[] = [];
|
|
289
|
-
for (const func of app.functions) {
|
|
290
|
-
dictionary.add(func.name);
|
|
291
|
-
if (func.name.length > limit) {
|
|
292
|
-
longFunctions.push(func);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
if (longFunctions.length === 0) return;
|
|
296
|
-
|
|
297
|
-
let index: number = 0;
|
|
298
|
-
for (const func of longFunctions) {
|
|
299
|
-
let success: boolean = false;
|
|
300
|
-
const rename = (str: string) => {
|
|
301
|
-
dictionary.delete(func.name);
|
|
302
|
-
dictionary.add(str);
|
|
303
|
-
func.name = str;
|
|
304
|
-
success = true;
|
|
305
|
-
};
|
|
306
|
-
// try dropping leading accessor segments to shorten the name
|
|
307
|
-
// (e.g., "api_users_getById" → "users_getById" → "getById")
|
|
308
|
-
for (let i: number = 1; i < func.route().accessor.length; ++i) {
|
|
309
|
-
const shortName: string = func.route().accessor.slice(i).join("_");
|
|
310
|
-
if (shortName.length > limit - 8)
|
|
311
|
-
continue; // reserve room for "_N_" prefix
|
|
312
|
-
else if (dictionary.has(shortName) === false) rename(shortName);
|
|
313
|
-
else {
|
|
314
|
-
// name collision — prefix with a counter to disambiguate
|
|
315
|
-
const newName: string = `_${index}_${shortName}`;
|
|
316
|
-
if (dictionary.has(newName) === true) continue;
|
|
317
|
-
rename(newName);
|
|
318
|
-
++index;
|
|
319
|
-
}
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
// last resort — all suffix attempts failed or collided
|
|
323
|
-
if (success === false) rename(randomFormatUuid());
|
|
324
|
-
}
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const randomFormatUuid = (): string =>
|
|
329
|
-
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
330
|
-
const r = (Math.random() * 16) | 0;
|
|
331
|
-
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
332
|
-
return v.toString(16);
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
/** Replaces forbidden characters (`$`, `%`, `.`) with underscores. */
|
|
336
|
-
const emend = (str: string): string => {
|
|
337
|
-
for (const ch of FORBIDDEN) str = str.split(ch).join("_");
|
|
338
|
-
return str;
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
const FORBIDDEN = ["$", "%", "."];
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Concatenates summary and description into a single string.
|
|
345
|
-
*
|
|
346
|
-
* If both are present, joins them with a period and double newline, avoiding
|
|
347
|
-
* duplication when the description already starts with the summary.
|
|
348
|
-
*/
|
|
349
|
-
const concatDescription = (p: {
|
|
350
|
-
summary?: string | undefined;
|
|
351
|
-
description?: string | undefined;
|
|
352
|
-
}): string | undefined => {
|
|
353
|
-
if (!p.summary?.length || !p.description?.length)
|
|
354
|
-
return p.summary || p.description;
|
|
355
|
-
const summary: string = p.summary.endsWith(".")
|
|
356
|
-
? p.summary.slice(0, -1)
|
|
357
|
-
: p.summary;
|
|
358
|
-
return p.description.startsWith(summary)
|
|
359
|
-
? p.description
|
|
360
|
-
: summary + ".\n\n" + p.description;
|
|
361
|
-
};
|
|
1
|
+
import {
|
|
2
|
+
IHttpLlmApplication,
|
|
3
|
+
IHttpLlmFunction,
|
|
4
|
+
IHttpMigrateApplication,
|
|
5
|
+
IHttpMigrateRoute,
|
|
6
|
+
IJsonSchemaTransformError,
|
|
7
|
+
ILlmSchema,
|
|
8
|
+
IResult,
|
|
9
|
+
OpenApi,
|
|
10
|
+
} from "@typia/interface";
|
|
11
|
+
|
|
12
|
+
import { LlmSchemaConverter } from "../../converters/LlmSchemaConverter";
|
|
13
|
+
import { LlmJson } from "../../utils";
|
|
14
|
+
import { OpenApiValidator } from "../../validators/OpenApiValidator";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Composes {@link IHttpLlmApplication} from an {@link IHttpMigrateApplication}.
|
|
18
|
+
*
|
|
19
|
+
* Converts OpenAPI-migrated HTTP routes into LLM function calling schemas,
|
|
20
|
+
* filtering out unsupported methods (HEAD) and content types
|
|
21
|
+
* (multipart/form-data), and shortening function names to fit the configured
|
|
22
|
+
* maximum length.
|
|
23
|
+
*/
|
|
24
|
+
export namespace HttpLlmApplicationComposer {
|
|
25
|
+
/**
|
|
26
|
+
* Builds an {@link IHttpLlmApplication} from migrated HTTP routes.
|
|
27
|
+
*
|
|
28
|
+
* Iterates all routes, converts each to an {@link IHttpLlmFunction}, and
|
|
29
|
+
* collects conversion errors. Applies function name shortening at the end.
|
|
30
|
+
*/
|
|
31
|
+
export const application = (props: {
|
|
32
|
+
migrate: IHttpMigrateApplication;
|
|
33
|
+
config?: Partial<IHttpLlmApplication.IConfig>;
|
|
34
|
+
}): IHttpLlmApplication => {
|
|
35
|
+
// fill in config defaults
|
|
36
|
+
const config: IHttpLlmApplication.IConfig = {
|
|
37
|
+
maxLength: props.config?.maxLength ?? 64,
|
|
38
|
+
equals: props.config?.equals ?? false,
|
|
39
|
+
reference: props.config?.reference ?? true,
|
|
40
|
+
strict: props.config?.strict ?? false,
|
|
41
|
+
};
|
|
42
|
+
// seed with pre-existing migration errors, excluding human-only endpoints
|
|
43
|
+
const errors: IHttpLlmApplication.IError[] = props.migrate.errors
|
|
44
|
+
.filter((e) => e.operation()["x-samchon-human"] !== true)
|
|
45
|
+
.map((e) => ({
|
|
46
|
+
method: e.method,
|
|
47
|
+
path: e.path,
|
|
48
|
+
messages: e.messages,
|
|
49
|
+
operation: () => e.operation(),
|
|
50
|
+
route: () => undefined,
|
|
51
|
+
}));
|
|
52
|
+
// convert each route to an LLM function, rejecting unsupported ones
|
|
53
|
+
const functions: IHttpLlmFunction[] = props.migrate.routes
|
|
54
|
+
.filter((e) => e.operation()["x-samchon-human"] !== true)
|
|
55
|
+
.map((route, i) => {
|
|
56
|
+
// reject HEAD — LLMs cannot interpret header-only responses
|
|
57
|
+
if (route.method === "head") {
|
|
58
|
+
errors.push({
|
|
59
|
+
method: route.method,
|
|
60
|
+
path: route.path,
|
|
61
|
+
messages: ["HEAD method is not supported in the LLM application."],
|
|
62
|
+
operation: () => route.operation(),
|
|
63
|
+
route: () => route as any as IHttpMigrateRoute,
|
|
64
|
+
});
|
|
65
|
+
return null;
|
|
66
|
+
// reject multipart/form-data — binary uploads not expressible in JSON Schema
|
|
67
|
+
} else if (
|
|
68
|
+
route.body?.type === "multipart/form-data" ||
|
|
69
|
+
route.success?.type === "multipart/form-data"
|
|
70
|
+
) {
|
|
71
|
+
errors.push({
|
|
72
|
+
method: route.method,
|
|
73
|
+
path: route.path,
|
|
74
|
+
messages: [
|
|
75
|
+
`The "multipart/form-data" content type is not supported in the LLM application.`,
|
|
76
|
+
],
|
|
77
|
+
operation: () => route.operation(),
|
|
78
|
+
route: () => route as any as IHttpMigrateRoute,
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const localErrors: string[] = [];
|
|
83
|
+
const func: IHttpLlmFunction | null = composeFunction({
|
|
84
|
+
components: props.migrate.document().components,
|
|
85
|
+
config,
|
|
86
|
+
route,
|
|
87
|
+
errors: localErrors,
|
|
88
|
+
index: i,
|
|
89
|
+
});
|
|
90
|
+
if (func === null)
|
|
91
|
+
errors.push({
|
|
92
|
+
method: route.method,
|
|
93
|
+
path: route.path,
|
|
94
|
+
messages: localErrors,
|
|
95
|
+
operation: () => route.operation(),
|
|
96
|
+
route: () => route as any as IHttpMigrateRoute,
|
|
97
|
+
});
|
|
98
|
+
return func;
|
|
99
|
+
})
|
|
100
|
+
.filter((v): v is IHttpLlmFunction => v !== null);
|
|
101
|
+
|
|
102
|
+
const app: IHttpLlmApplication = {
|
|
103
|
+
config,
|
|
104
|
+
functions,
|
|
105
|
+
errors,
|
|
106
|
+
};
|
|
107
|
+
shorten(app, props.config?.maxLength ?? 64);
|
|
108
|
+
return app;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Converts a single {@link IHttpMigrateRoute} into an {@link IHttpLlmFunction}
|
|
113
|
+
* by composing parameter/output schemas and validating function name
|
|
114
|
+
* constraints.
|
|
115
|
+
*/
|
|
116
|
+
const composeFunction = (props: {
|
|
117
|
+
components: OpenApi.IComponents;
|
|
118
|
+
route: IHttpMigrateRoute;
|
|
119
|
+
config: IHttpLlmApplication.IConfig;
|
|
120
|
+
errors: string[];
|
|
121
|
+
index: number;
|
|
122
|
+
}): IHttpLlmFunction | null => {
|
|
123
|
+
// accessor prefix for error messages (mirrors OpenAPI document structure)
|
|
124
|
+
const endpoint: string = `$input.paths[${JSON.stringify(props.route.path)}][${JSON.stringify(props.route.method)}]`;
|
|
125
|
+
const operation: OpenApi.IOperation = props.route.operation();
|
|
126
|
+
const description: string | undefined = concatDescription({
|
|
127
|
+
summary: operation.summary,
|
|
128
|
+
description: operation.description,
|
|
129
|
+
});
|
|
130
|
+
if ((description?.length ?? 0) > 1_024) {
|
|
131
|
+
props.errors.push(
|
|
132
|
+
`The description of the function is too long (must be equal or less than 1,024 characters, but ${description!.length.toLocaleString()} length).`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// build function name from route accessor, replacing forbidden chars
|
|
137
|
+
const name: string = emend(props.route.accessor.join("_"));
|
|
138
|
+
const isNameVariable: boolean = /^[a-zA-Z0-9_-]+$/.test(name);
|
|
139
|
+
const isNameStartsWithNumber: boolean = /^[0-9]/.test(name[0] ?? "");
|
|
140
|
+
if (isNameVariable === false)
|
|
141
|
+
props.errors.push(
|
|
142
|
+
`Elements of path (separated by '/') must be composed with alphabets, numbers, underscores, and hyphens`,
|
|
143
|
+
);
|
|
144
|
+
if (isNameStartsWithNumber === true)
|
|
145
|
+
props.errors.push(`Function name cannot start with a number.`);
|
|
146
|
+
|
|
147
|
+
//----
|
|
148
|
+
// CONSTRUCT SCHEMAS
|
|
149
|
+
//----
|
|
150
|
+
// merge path parameters, query, and body into a single object schema
|
|
151
|
+
const parameters: OpenApi.IJsonSchema.IObject = {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: Object.fromEntries([
|
|
154
|
+
// path parameters (e.g., /users/:id)
|
|
155
|
+
...props.route.parameters.map(
|
|
156
|
+
(s) =>
|
|
157
|
+
[
|
|
158
|
+
s.key,
|
|
159
|
+
{
|
|
160
|
+
...s.schema,
|
|
161
|
+
description: s.parameter().description ?? s.schema.description,
|
|
162
|
+
},
|
|
163
|
+
] as const,
|
|
164
|
+
),
|
|
165
|
+
// query parameters
|
|
166
|
+
...(props.route.query
|
|
167
|
+
? [
|
|
168
|
+
[
|
|
169
|
+
props.route.query.key,
|
|
170
|
+
{
|
|
171
|
+
...props.route.query.schema,
|
|
172
|
+
title:
|
|
173
|
+
props.route.query.title() ?? props.route.query.schema.title,
|
|
174
|
+
description:
|
|
175
|
+
props.route.query.description() ??
|
|
176
|
+
props.route.query.schema.description,
|
|
177
|
+
},
|
|
178
|
+
] as const,
|
|
179
|
+
]
|
|
180
|
+
: []),
|
|
181
|
+
// request body
|
|
182
|
+
...(props.route.body
|
|
183
|
+
? [
|
|
184
|
+
[
|
|
185
|
+
props.route.body.key,
|
|
186
|
+
{
|
|
187
|
+
...props.route.body.schema,
|
|
188
|
+
description:
|
|
189
|
+
props.route.body.description() ??
|
|
190
|
+
props.route.body.schema.description,
|
|
191
|
+
},
|
|
192
|
+
] as const,
|
|
193
|
+
]
|
|
194
|
+
: []),
|
|
195
|
+
]),
|
|
196
|
+
};
|
|
197
|
+
parameters.required = Object.keys(parameters.properties ?? {});
|
|
198
|
+
|
|
199
|
+
// convert merged object schema to LLM parameters
|
|
200
|
+
const llmParameters: IResult<
|
|
201
|
+
ILlmSchema.IParameters,
|
|
202
|
+
IJsonSchemaTransformError
|
|
203
|
+
> = LlmSchemaConverter.parameters({
|
|
204
|
+
config: props.config,
|
|
205
|
+
components: props.components,
|
|
206
|
+
schema: parameters,
|
|
207
|
+
accessor: `${endpoint}.parameters`,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// convert response schema to LLM output parameters
|
|
211
|
+
const output:
|
|
212
|
+
| IResult<ILlmSchema.IParameters, IJsonSchemaTransformError>
|
|
213
|
+
| undefined = props.route.success
|
|
214
|
+
? LlmSchemaConverter.parameters({
|
|
215
|
+
config: props.config,
|
|
216
|
+
components: props.components,
|
|
217
|
+
schema: props.route.success.schema as
|
|
218
|
+
| OpenApi.IJsonSchema.IObject
|
|
219
|
+
| OpenApi.IJsonSchema.IReference,
|
|
220
|
+
accessor: `${endpoint}.responses[${JSON.stringify(props.route.success.status)}][${JSON.stringify(props.route.success.type)}].schema`,
|
|
221
|
+
})
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
//----
|
|
225
|
+
// CONVERSION
|
|
226
|
+
//----
|
|
227
|
+
// bail out if any validation or conversion failed
|
|
228
|
+
if (
|
|
229
|
+
output?.success === false ||
|
|
230
|
+
llmParameters.success === false ||
|
|
231
|
+
isNameVariable === false ||
|
|
232
|
+
isNameStartsWithNumber === true ||
|
|
233
|
+
(description?.length ?? 0) > 1_024
|
|
234
|
+
) {
|
|
235
|
+
if (output?.success === false)
|
|
236
|
+
props.errors.push(
|
|
237
|
+
...output.error.reasons.map((r) => `${r.accessor}: ${r.message}`),
|
|
238
|
+
);
|
|
239
|
+
if (llmParameters.success === false)
|
|
240
|
+
props.errors.push(
|
|
241
|
+
// rewrite internal accessor to match OpenAPI requestBody path
|
|
242
|
+
...llmParameters.error.reasons.map((r) => {
|
|
243
|
+
const accessor: string = r.accessor.replace(
|
|
244
|
+
`parameters.properties["body"]`,
|
|
245
|
+
`requestBody.content[${JSON.stringify(props.route.body?.type ?? "application/json")}].schema`,
|
|
246
|
+
);
|
|
247
|
+
return `${accessor}: ${r.message}`;
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// assemble the LLM function
|
|
254
|
+
return {
|
|
255
|
+
method: props.route.method as "get",
|
|
256
|
+
path: props.route.path,
|
|
257
|
+
name,
|
|
258
|
+
parameters: llmParameters.value,
|
|
259
|
+
output: output?.value,
|
|
260
|
+
description,
|
|
261
|
+
deprecated: operation.deprecated,
|
|
262
|
+
tags: operation.tags,
|
|
263
|
+
parse: (input: string) => LlmJson.parse(input, llmParameters.value),
|
|
264
|
+
coerce: (input: unknown) => LlmJson.coerce(input, llmParameters.value),
|
|
265
|
+
validate: OpenApiValidator.create({
|
|
266
|
+
components: props.components,
|
|
267
|
+
schema: parameters,
|
|
268
|
+
required: true,
|
|
269
|
+
equals: props.config.equals ?? false,
|
|
270
|
+
}),
|
|
271
|
+
route: () => props.route as any,
|
|
272
|
+
operation: () => props.route.operation(),
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Shortens function names exceeding the character limit.
|
|
278
|
+
*
|
|
279
|
+
* Tries progressively shorter accessor suffixes first, then falls back to
|
|
280
|
+
* index-prefixed names, and finally UUID as a last resort.
|
|
281
|
+
*/
|
|
282
|
+
export const shorten = (
|
|
283
|
+
app: IHttpLlmApplication,
|
|
284
|
+
limit: number = 64,
|
|
285
|
+
): void => {
|
|
286
|
+
// collect all names for uniqueness checks
|
|
287
|
+
const dictionary: Set<string> = new Set();
|
|
288
|
+
const longFunctions: IHttpLlmFunction[] = [];
|
|
289
|
+
for (const func of app.functions) {
|
|
290
|
+
dictionary.add(func.name);
|
|
291
|
+
if (func.name.length > limit) {
|
|
292
|
+
longFunctions.push(func);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (longFunctions.length === 0) return;
|
|
296
|
+
|
|
297
|
+
let index: number = 0;
|
|
298
|
+
for (const func of longFunctions) {
|
|
299
|
+
let success: boolean = false;
|
|
300
|
+
const rename = (str: string) => {
|
|
301
|
+
dictionary.delete(func.name);
|
|
302
|
+
dictionary.add(str);
|
|
303
|
+
func.name = str;
|
|
304
|
+
success = true;
|
|
305
|
+
};
|
|
306
|
+
// try dropping leading accessor segments to shorten the name
|
|
307
|
+
// (e.g., "api_users_getById" → "users_getById" → "getById")
|
|
308
|
+
for (let i: number = 1; i < func.route().accessor.length; ++i) {
|
|
309
|
+
const shortName: string = func.route().accessor.slice(i).join("_");
|
|
310
|
+
if (shortName.length > limit - 8)
|
|
311
|
+
continue; // reserve room for "_N_" prefix
|
|
312
|
+
else if (dictionary.has(shortName) === false) rename(shortName);
|
|
313
|
+
else {
|
|
314
|
+
// name collision — prefix with a counter to disambiguate
|
|
315
|
+
const newName: string = `_${index}_${shortName}`;
|
|
316
|
+
if (dictionary.has(newName) === true) continue;
|
|
317
|
+
rename(newName);
|
|
318
|
+
++index;
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
// last resort — all suffix attempts failed or collided
|
|
323
|
+
if (success === false) rename(randomFormatUuid());
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const randomFormatUuid = (): string =>
|
|
329
|
+
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
330
|
+
const r = (Math.random() * 16) | 0;
|
|
331
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
332
|
+
return v.toString(16);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
/** Replaces forbidden characters (`$`, `%`, `.`) with underscores. */
|
|
336
|
+
const emend = (str: string): string => {
|
|
337
|
+
for (const ch of FORBIDDEN) str = str.split(ch).join("_");
|
|
338
|
+
return str;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const FORBIDDEN = ["$", "%", "."];
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Concatenates summary and description into a single string.
|
|
345
|
+
*
|
|
346
|
+
* If both are present, joins them with a period and double newline, avoiding
|
|
347
|
+
* duplication when the description already starts with the summary.
|
|
348
|
+
*/
|
|
349
|
+
const concatDescription = (p: {
|
|
350
|
+
summary?: string | undefined;
|
|
351
|
+
description?: string | undefined;
|
|
352
|
+
}): string | undefined => {
|
|
353
|
+
if (!p.summary?.length || !p.description?.length)
|
|
354
|
+
return p.summary || p.description;
|
|
355
|
+
const summary: string = p.summary.endsWith(".")
|
|
356
|
+
? p.summary.slice(0, -1)
|
|
357
|
+
: p.summary;
|
|
358
|
+
return p.description.startsWith(summary)
|
|
359
|
+
? p.description
|
|
360
|
+
: summary + ".\n\n" + p.description;
|
|
361
|
+
};
|