From 51d43d44c1bd3db3baddcfccecf3f889b523e57d Mon Sep 17 00:00:00 2001 From: Jeongho Nam Date: Thu, 28 Nov 2024 14:47:12 +0900 Subject: [PATCH] Allow `additionalProperties` in Claude and Llama. They are possible to utilize the dynamic key typed object. Therefore, allowed `additionalProperties` in below types. "OpenAI ChatGPT" and "Google Gemini" blocks it. - `IClaudeSchema` - `ILlamaSchema` - `ILlmSchemaV3` - `ILlmSchemaV3_1` --- .../claude.additionalProperties.input.json | 8 ++ .../arguments/claude.sale.input.json | 25 +++-- .../llama.additionalProperties.input.json | 8 ++ .../claude.additionalProperties.schema.json | 39 ++++++++ .../schemas/claude.sale.schema.json | 34 +++---- .../llama.additionalProperties.schema.json | 39 ++++++++ .../schemas/llama.sale.schema.json | 34 +++---- package.json | 2 +- src/converters/ChatGptConverter.ts | 7 +- src/converters/ClaudeConverter.ts | 2 +- src/converters/GeminiConverter.ts | 14 ++- src/converters/LlamaConverter.ts | 2 +- src/converters/LlmConverterV3.ts | 15 ++- src/converters/LlmConverterV3_1.ts | 26 +++++- src/converters/LlmSchemaConverter.ts | 12 +-- src/structures/IChatGptSchema.ts | 9 +- src/structures/IClaudeSchema.ts | 1 - src/structures/ILlamaSchema.ts | 1 - src/structures/ILlmSchemaV3.ts | 11 ++- src/structures/ILlmSchemaV3_1.ts | 12 ++- src/utils/ChatGptTypeChecker.ts | 91 ++---------------- src/utils/GeminiTypeChecker.ts | 92 ++----------------- src/utils/LlmTypeCheckerV3.ts | 9 +- src/utils/OpenApiTypeChecker.ts | 10 ++ src/utils/internal/OpenApiTypeCheckerBase.ts | 12 +++ .../test_chatgpt_function_calling_default.ts | 5 +- ...e_function_calling_additionalProperties.ts | 70 ++++++++++++++ ...a_function_calling_additionalProperties.ts | 70 ++++++++++++++ test/utils/ChatGptFunctionCaller.ts | 6 +- test/utils/ClaudeFunctionCaller.ts | 2 +- 30 files changed, 398 insertions(+), 270 deletions(-) create mode 100644 examples/function-calling/arguments/claude.additionalProperties.input.json create mode 100644 examples/function-calling/arguments/llama.additionalProperties.input.json create mode 100644 examples/function-calling/schemas/claude.additionalProperties.schema.json create mode 100644 examples/function-calling/schemas/llama.additionalProperties.schema.json create mode 100644 test/features/llm/claude/test_claude_function_calling_additionalProperties.ts create mode 100644 test/features/llm/llama/test_llama_function_calling_additionalProperties.ts diff --git a/examples/function-calling/arguments/claude.additionalProperties.input.json b/examples/function-calling/arguments/claude.additionalProperties.input.json new file mode 100644 index 0000000..318e584 --- /dev/null +++ b/examples/function-calling/arguments/claude.additionalProperties.input.json @@ -0,0 +1,8 @@ +{ + "name": "John Doe", + "age": 42, + "etc": { + "hobby": "Soccer", + "job": "Scientist" + } +} \ No newline at end of file diff --git a/examples/function-calling/arguments/claude.sale.input.json b/examples/function-calling/arguments/claude.sale.input.json index f0a59ac..a53bcc5 100644 --- a/examples/function-calling/arguments/claude.sale.input.json +++ b/examples/function-calling/arguments/claude.sale.input.json @@ -7,6 +7,7 @@ "title": "Surface Pro 9", "format": "md", "body": "The Surface Pro 9 is a versatile 2-in-1 device that combines the power of a laptop with the flexibility of a tablet. It features advanced technology, making it suitable for both professional and personal use.\n\n- \"Unleash Your Creativity Anywhere\": The Surface Pro 9 is designed for those who need power and portability, making it perfect for creative professionals and students alike.\n- \"The Ultimate 2-in-1 Experience\": With its detachable keyboard and touchscreen capabilities, the Surface Pro 9 adapts to your needs, whether you're working, studying, or relaxing.\n- \"Stay Connected with 5G\": Experience lightning-fast internet speeds and seamless connectivity, no matter where you are.\n- \"Power Meets Flexibility\": The Surface Pro 9 combines the performance of a laptop with the convenience of a tablet, making it the ideal device for multitasking.\n\nIn summary, the Surface Pro 9 stands out as a powerful and flexible device, perfect for users who require both performance and portability. With its advanced features and sleek design, it is an excellent choice for anyone looking to enhance their productivity and creativity. Whether for work or play, the Surface Pro 9 is ready to meet your needs.", + "files": [], "thumbnails": [ { "name": "microsoft-surface-pro-9-thumbnail-1", @@ -23,8 +24,7 @@ "extension": "jpeg", "url": "https://serpapi.com/searches/673d3a37e45f3316ecd8ab3e/images/1be25e6e2b1fb7505946d975aac683f8826bcb8c509672de4a5f8c71f149fdef.jpeg" } - ], - "files": [] + ] }, "channels": [ { @@ -92,7 +92,7 @@ ], "stocks": [ { - "name": "Surface Pro 9 (i3/8GB/128GB)", + "name": "Surface Pro 9 - i3/8GB/128GB", "price": { "nominal": 1000000, "real": 899000 @@ -114,7 +114,7 @@ ] }, { - "name": "Surface Pro 9 (i3/16GB/256GB)", + "name": "Surface Pro 9 - i3/16GB/256GB", "price": { "nominal": 1200000, "real": 1099000 @@ -136,7 +136,7 @@ ] }, { - "name": "Surface Pro 9 (i3/16GB/512GB)", + "name": "Surface Pro 9 - i3/16GB/512GB", "price": { "nominal": 1400000, "real": 1299000 @@ -158,7 +158,7 @@ ] }, { - "name": "Surface Pro 9 (i5/16GB/256GB)", + "name": "Surface Pro 9 - i5/16GB/256GB", "price": { "nominal": 1500000, "real": 1399000 @@ -180,7 +180,7 @@ ] }, { - "name": "Surface Pro 9 (i5/32GB/512GB)", + "name": "Surface Pro 9 - i5/32GB/512GB", "price": { "nominal": 1800000, "real": 1699000 @@ -202,7 +202,7 @@ ] }, { - "name": "Surface Pro 9 (i7/16GB/512GB)", + "name": "Surface Pro 9 - i7/16GB/512GB", "price": { "nominal": 1800000, "real": 1699000 @@ -224,7 +224,7 @@ ] }, { - "name": "Surface Pro 9 (i7/32GB/512GB)", + "name": "Surface Pro 9 - i7/32GB/512GB", "price": { "nominal": 2000000, "real": 1899000 @@ -284,11 +284,10 @@ ], "tags": [ "Surface", - "Surface Pro", - "Surface Pro 9", "Microsoft", - "Tablet", "2-in-1", - "Laptop" + "Laptop", + "Tablet", + "Windows" ] } \ No newline at end of file diff --git a/examples/function-calling/arguments/llama.additionalProperties.input.json b/examples/function-calling/arguments/llama.additionalProperties.input.json new file mode 100644 index 0000000..e7cefee --- /dev/null +++ b/examples/function-calling/arguments/llama.additionalProperties.input.json @@ -0,0 +1,8 @@ +{ + "age": 42, + "etc": { + "hobby": "Soccer", + "job": "Scientist" + }, + "name": "John Doe" +} \ No newline at end of file diff --git a/examples/function-calling/schemas/claude.additionalProperties.schema.json b/examples/function-calling/schemas/claude.additionalProperties.schema.json new file mode 100644 index 0000000..31aaa28 --- /dev/null +++ b/examples/function-calling/schemas/claude.additionalProperties.schema.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "name": { + "title": "The name of the person", + "description": "The name of the person.", + "type": "string" + }, + "age": { + "title": "The age of the person", + "description": "The age of the person.", + "type": "integer" + }, + "etc": { + "title": "Additional informations about the person", + "description": "Construct a type with a set of properties K of type T", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "name", + "age", + "etc" + ] + } + }, + "required": [ + "input" + ], + "$defs": {} +} \ No newline at end of file diff --git a/examples/function-calling/schemas/claude.sale.schema.json b/examples/function-calling/schemas/claude.sale.schema.json index 972b08d..5b9322f 100644 --- a/examples/function-calling/schemas/claude.sale.schema.json +++ b/examples/function-calling/schemas/claude.sale.schema.json @@ -111,8 +111,7 @@ "name", "extension", "url" - ], - "additionalProperties": false + ] } }, "thumbnails": { @@ -152,8 +151,7 @@ "name", "extension", "url" - ], - "additionalProperties": false + ] } } }, @@ -163,8 +161,7 @@ "body", "files", "thumbnails" - ], - "additionalProperties": false + ] }, "channels": { "title": "List of channels and categories", @@ -191,8 +188,7 @@ "required": [ "code", "category_codes" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -243,8 +239,7 @@ }, "required": [ "name" - ], - "additionalProperties": false + ] }, "minItems": 1 } @@ -254,8 +249,7 @@ "name", "variable", "candidates" - ], - "additionalProperties": false + ] } }, "stocks": { @@ -292,8 +286,7 @@ "required": [ "nominal", "real" - ], - "additionalProperties": false + ] }, "quantity": { "title": "Initial inventory quantity", @@ -321,8 +314,7 @@ "required": [ "option_index", "candidate_index" - ], - "additionalProperties": false + ] } } }, @@ -331,8 +323,7 @@ "price", "quantity", "choices" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -358,8 +349,7 @@ "name", "required", "primary" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -381,13 +371,11 @@ "channels", "units", "tags" - ], - "additionalProperties": false + ] } }, "required": [ "input" ], - "additionalProperties": false, "$defs": {} } \ No newline at end of file diff --git a/examples/function-calling/schemas/llama.additionalProperties.schema.json b/examples/function-calling/schemas/llama.additionalProperties.schema.json new file mode 100644 index 0000000..31aaa28 --- /dev/null +++ b/examples/function-calling/schemas/llama.additionalProperties.schema.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "name": { + "title": "The name of the person", + "description": "The name of the person.", + "type": "string" + }, + "age": { + "title": "The age of the person", + "description": "The age of the person.", + "type": "integer" + }, + "etc": { + "title": "Additional informations about the person", + "description": "Construct a type with a set of properties K of type T", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "name", + "age", + "etc" + ] + } + }, + "required": [ + "input" + ], + "$defs": {} +} \ No newline at end of file diff --git a/examples/function-calling/schemas/llama.sale.schema.json b/examples/function-calling/schemas/llama.sale.schema.json index 972b08d..5b9322f 100644 --- a/examples/function-calling/schemas/llama.sale.schema.json +++ b/examples/function-calling/schemas/llama.sale.schema.json @@ -111,8 +111,7 @@ "name", "extension", "url" - ], - "additionalProperties": false + ] } }, "thumbnails": { @@ -152,8 +151,7 @@ "name", "extension", "url" - ], - "additionalProperties": false + ] } } }, @@ -163,8 +161,7 @@ "body", "files", "thumbnails" - ], - "additionalProperties": false + ] }, "channels": { "title": "List of channels and categories", @@ -191,8 +188,7 @@ "required": [ "code", "category_codes" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -243,8 +239,7 @@ }, "required": [ "name" - ], - "additionalProperties": false + ] }, "minItems": 1 } @@ -254,8 +249,7 @@ "name", "variable", "candidates" - ], - "additionalProperties": false + ] } }, "stocks": { @@ -292,8 +286,7 @@ "required": [ "nominal", "real" - ], - "additionalProperties": false + ] }, "quantity": { "title": "Initial inventory quantity", @@ -321,8 +314,7 @@ "required": [ "option_index", "candidate_index" - ], - "additionalProperties": false + ] } } }, @@ -331,8 +323,7 @@ "price", "quantity", "choices" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -358,8 +349,7 @@ "name", "required", "primary" - ], - "additionalProperties": false + ] }, "minItems": 1 }, @@ -381,13 +371,11 @@ "channels", "units", "tags" - ], - "additionalProperties": false + ] } }, "required": [ "input" ], - "additionalProperties": false, "$defs": {} } \ No newline at end of file diff --git a/package.json b/package.json index 6b924d8..f41e04e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samchon/openapi", - "version": "2.0.0-dev.20241127-2", + "version": "2.0.0-dev.20241128", "description": "OpenAPI definitions and converters for 'typia' and 'nestia'.", "main": "./lib/index.js", "module": "./lib/index.mjs", diff --git a/src/converters/ChatGptConverter.ts b/src/converters/ChatGptConverter.ts index a51a3de..c643283 100644 --- a/src/converters/ChatGptConverter.ts +++ b/src/converters/ChatGptConverter.ts @@ -10,7 +10,7 @@ export namespace ChatGptConverter { export const parameters = (props: { config: IChatGptSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }): IChatGptSchema.IParameters | null => { const params: ILlmSchemaV3_1.IParameters | null = LlmConverterV3_1.parameters({ @@ -24,9 +24,7 @@ export namespace ChatGptConverter { if (params === null) return null; for (const key of Object.keys(params.$defs)) params.$defs[key] = transform(params.$defs[key]); - for (const key of Object.keys(params.properties)) - params.properties[key] = transform(params.properties[key]); - return params; + return transform(params) as IChatGptSchema.IParameters; }; export const schema = (props: { @@ -81,6 +79,7 @@ export namespace ChatGptConverter { transform(value), ]), ), + additionalProperties: false, }); else if (LlmTypeCheckerV3_1.isConstant(input) === false) union.push(input); diff --git a/src/converters/ClaudeConverter.ts b/src/converters/ClaudeConverter.ts index 6f79dd0..e3301b2 100644 --- a/src/converters/ClaudeConverter.ts +++ b/src/converters/ClaudeConverter.ts @@ -6,7 +6,7 @@ export namespace ClaudeConverter { export const parameters = (props: { config: IClaudeSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }): IClaudeSchema.IParameters | null => LlmConverterV3_1.parameters({ config: { diff --git a/src/converters/GeminiConverter.ts b/src/converters/GeminiConverter.ts index 87518ac..d7947db 100644 --- a/src/converters/GeminiConverter.ts +++ b/src/converters/GeminiConverter.ts @@ -2,6 +2,7 @@ import { OpenApi } from "../OpenApi"; import { IGeminiSchema } from "../structures/IGeminiSchema"; import { ILlmSchemaV3 } from "../structures/ILlmSchemaV3"; import { LlmTypeCheckerV3 } from "../utils/LlmTypeCheckerV3"; +import { OpenApiTypeChecker } from "../utils/OpenApiTypeChecker"; import { LlmConverterV3 } from "./LlmConverterV3"; export namespace GeminiConverter { @@ -9,8 +10,17 @@ export namespace GeminiConverter { config: IGeminiSchema.IConfig; components: OpenApi.IComponents; schema: OpenApi.IJsonSchema; - }): IGeminiSchema.IParameters | null => - schema(props) as IGeminiSchema.IParameters | null; + }): IGeminiSchema.IParameters | null => { + const entity: OpenApi.IJsonSchema | null = + OpenApiTypeChecker.unreference(props); + if (entity === null || OpenApiTypeChecker.isObject(entity) === false) + return null; + return schema({ + config: props.config, + components: props.components, + schema: entity, + }) as IGeminiSchema.IParameters | null; + }; export const schema = (props: { config: IGeminiSchema.IConfig; diff --git a/src/converters/LlamaConverter.ts b/src/converters/LlamaConverter.ts index 337f193..28882f8 100644 --- a/src/converters/LlamaConverter.ts +++ b/src/converters/LlamaConverter.ts @@ -6,7 +6,7 @@ export namespace LlamaConverter { export const parameters = (props: { config: ILlamaSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }): ILlamaSchema.IParameters | null => LlmConverterV3_1.parameters({ config: { diff --git a/src/converters/LlmConverterV3.ts b/src/converters/LlmConverterV3.ts index 728bdec..16de870 100644 --- a/src/converters/LlmConverterV3.ts +++ b/src/converters/LlmConverterV3.ts @@ -9,9 +9,18 @@ export namespace LlmConverterV3 { export const parameters = (props: { config: ILlmSchemaV3.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; - }): ILlmSchemaV3.IParameters | null => - schema(props) as ILlmSchemaV3.IParameters | null; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; + }): ILlmSchemaV3.IParameters | null => { + const entity: OpenApi.IJsonSchema | null = + OpenApiTypeChecker.unreference(props); + if (entity === null || OpenApiTypeChecker.isObject(entity) === false) + return null; + return schema({ + config: props.config, + components: props.components, + schema: entity, + }) as ILlmSchemaV3.IParameters | null; + }; export const schema = (props: { config: ILlmSchemaV3.IConfig; diff --git a/src/converters/LlmConverterV3_1.ts b/src/converters/LlmConverterV3_1.ts index aa5ac91..8cdd5fb 100644 --- a/src/converters/LlmConverterV3_1.ts +++ b/src/converters/LlmConverterV3_1.ts @@ -9,13 +9,17 @@ export namespace LlmConverterV3_1 { export const parameters = (props: { config: ILlmSchemaV3_1.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }): ILlmSchemaV3_1.IParameters | null => { const $defs: Record = {}; + const entity: OpenApi.IJsonSchema | null = + OpenApiTypeChecker.unreference(props); + if (entity === null || OpenApiTypeChecker.isObject(entity) === false) + return null; const res: ILlmSchemaV3_1.IParameters | null = schema({ config: props.config, components: props.components, - schema: props.schema, + schema: entity, $defs, }) as ILlmSchemaV3_1.IParameters | null; if (res === null) return null; @@ -124,11 +128,25 @@ export namespace LlmConverterV3_1 { ); if (Object.values(properties).some((v) => v === null)) return union.push(null); - if (!!input.additionalProperties === null) return union.push(null); + const additionalProperties: + | ILlmSchemaV3_1 + | boolean + | null + | undefined = + typeof input.additionalProperties === "object" && + input.additionalProperties !== null + ? schema({ + config: props.config, + components: props.components, + $defs: props.$defs, + schema: input.additionalProperties, + }) + : input.additionalProperties; + if (additionalProperties === null) return union.push(null); return union.push({ ...input, properties: properties as Record, - additionalProperties: false, + additionalProperties, required: Object.keys(properties), }); } else if (OpenApiTypeChecker.isArray(input)) { diff --git a/src/converters/LlmSchemaConverter.ts b/src/converters/LlmSchemaConverter.ts index 709a5d4..c0af23b 100644 --- a/src/converters/LlmSchemaConverter.ts +++ b/src/converters/LlmSchemaConverter.ts @@ -28,32 +28,32 @@ const PARAMETERS_CASTERS = { chatgpt: (props: { config: IChatGptSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => ChatGptConverter.parameters(props), claude: (props: { config: IClaudeSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => ClaudeConverter.parameters(props), gemini: (props: { config: IGeminiSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => GeminiConverter.parameters(props), llama: (props: { config: ILlamaSchema.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => LlamaConverter.parameters(props), "3.0": (props: { config: ILlmSchemaV3.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => LlmConverterV3.parameters(props), "3.1": (props: { config: ILlmSchemaV3_1.IConfig; components: OpenApi.IComponents; - schema: OpenApi.IJsonSchema.IObject; + schema: OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference; }) => LlmConverterV3_1.parameters(props), }; diff --git a/src/structures/IChatGptSchema.ts b/src/structures/IChatGptSchema.ts index 08a1630..16823d0 100644 --- a/src/structures/IChatGptSchema.ts +++ b/src/structures/IChatGptSchema.ts @@ -24,6 +24,7 @@ * - {@link IChatGptSchema.IAnyOf} instead of the {@link OpenApi.IJsonSchema.IOneOf} * - {@link IChatGptSchema.IParameters.$defs} instead of the {@link OpenApi.IJsonSchema.IComponents.schemas} * - {@link IChatGptSchema.IString.enum} instead of the {@link OpenApi.IJsonSchema.IConstant} + * - {@link IChatGptSchema.additionalProperties} is fixed to `false` * - No tuple type {@link OpenApi.IJsonSchema.ITuple} support * - Forcibly transform every object properties to be required * @@ -151,9 +152,6 @@ export namespace IChatGptSchema { * The `properties` means a list of key-value pairs of the object's * regular properties. The key is the name of the regular property, * and the value is the type schema info. - * - * If you need additional properties that is represented by dynamic key, - * you can use the {@link additionalProperties} instead. */ properties: Record; @@ -163,8 +161,9 @@ export namespace IChatGptSchema { * The `additionalProperties` means the type schema info of the additional * properties that are not listed in the {@link properties}. * - * By the way, as LLM function calling does not support such dynamic key - * typed properties, the `additionalProperties` becomes always `false`. + * By the way, as ChatGPT function calling does not support such + * dynamic key typed properties, the `additionalProperties` becomes + * always `false`. */ additionalProperties: false; diff --git a/src/structures/IClaudeSchema.ts b/src/structures/IClaudeSchema.ts index 36961d1..4873b72 100644 --- a/src/structures/IClaudeSchema.ts +++ b/src/structures/IClaudeSchema.ts @@ -29,7 +29,6 @@ import { ILlmSchemaV3_1 } from "./ILlmSchemaV3_1"; * * - {@link IClaudeSchema.IParameters.$defs} instead of the {@link OpenApi.IJsonSchema.schemas} * - Do not support {@link OpenApi.IJsonSchema.ITuple} type - * - {@link IClaudeSchema.additionalProperties} is fixed to `false` * - {@link IClaudeSchema.properties} and {@link IClaudeSchema.required} are always defined * * For reference, if you've composed the `IClaudeSchema` type with the diff --git a/src/structures/ILlamaSchema.ts b/src/structures/ILlamaSchema.ts index ff80548..003d50d 100644 --- a/src/structures/ILlamaSchema.ts +++ b/src/structures/ILlamaSchema.ts @@ -29,7 +29,6 @@ import { ILlmSchemaV3_1 } from "./ILlmSchemaV3_1"; * * - {@link ILlamaSchema.IParameters.$defs} instead of the {@link OpenApi.IJsonSchema.schemas} * - Do not support {@link OpenApi.IJsonSchema.ITuple} type - * - {@link ILlamaSchema.additionalProperties} is fixed to `false` * - {@link ILlamaSchema.properties} and {@link ILlamaSchema.required} are always defined * * For reference, if you've composed the `ILlamaSchema` type with the diff --git a/src/structures/ILlmSchemaV3.ts b/src/structures/ILlmSchemaV3.ts index ac7b536..bdd6e3c 100644 --- a/src/structures/ILlmSchemaV3.ts +++ b/src/structures/ILlmSchemaV3.ts @@ -353,10 +353,15 @@ export namespace ILlmSchemaV3 { * The `additionalProperties` means the type schema info of the additional * properties that are not listed in the {@link properties}. * - * By the way, as LLM function calling does not support such dynamic key - * typed properties, the `additionalProperties` becomes always `false`. + * If the value is `true`, it means that the additional properties are not + * restricted. They can be any type. Otherwise, if the value is + * {@link ILlmSchemaV3} type, it means that the additional properties must + * follow the type schema info. + * + * - `true`: `Record` + * - `IOpenAiSchema`: `Record` */ - additionalProperties: false; + additionalProperties?: boolean | ILlmSchemaV3; } /** diff --git a/src/structures/ILlmSchemaV3_1.ts b/src/structures/ILlmSchemaV3_1.ts index 5b3a072..9cf9169 100644 --- a/src/structures/ILlmSchemaV3_1.ts +++ b/src/structures/ILlmSchemaV3_1.ts @@ -26,7 +26,6 @@ * * - {@link ILlmSchemaV3_1.IParameters.$defs} instead of the {@link OpenApi.IJsonSchema.schemas} * - Do not support {@link OpenApi.IJsonSchema.ITuple} type - * - {@link ILlmSchemaV3_1.additionalProperties} is fixed to `false` * - {@link ILlmSchemaV3_1.properties} and {@link ILlmSchemaV3_1.required} are always defined * * For reference, if you've composed the `ILlmSchemaV3_1` type with the @@ -330,10 +329,15 @@ export namespace ILlmSchemaV3_1 { * The `additionalProperties` means the type schema info of the additional * properties that are not listed in the {@link properties}. * - * By the way, as LLM function calling does not support such dynamic key - * typed properties, the `additionalProperties` becomes always `false`. + * If the value is `true`, it means that the additional properties are not + * restricted. They can be any type. Otherwise, if the value is + * {@link IOpenAiSchema} type, it means that the additional properties must + * follow the type schema info. + * + * - `true`: `Record` + * - `IOpenAiSchema`: `Record` */ - additionalProperties: false; + additionalProperties?: boolean | ILlmSchemaV3_1; /** * List of key values of the required properties. diff --git a/src/utils/ChatGptTypeChecker.ts b/src/utils/ChatGptTypeChecker.ts index 5137f27..1977bbd 100644 --- a/src/utils/ChatGptTypeChecker.ts +++ b/src/utils/ChatGptTypeChecker.ts @@ -189,52 +189,21 @@ export namespace ChatGptTypeChecker { visited: Map>; x: IChatGptSchema.IArray; y: IChatGptSchema.IArray; - }): boolean => { - // if ( - // !( - // p.x.minItems === undefined || - // (p.y.minItems !== undefined && p.x.minItems <= p.y.minItems) - // ) - // ) - // return false; - // else if ( - // !( - // p.x.maxItems === undefined || - // (p.y.maxItems !== undefined && p.x.maxItems >= p.y.maxItems) - // ) - // ) - // return false; - return coverStation({ + }): boolean => + coverStation({ $defs: p.$defs, visited: p.visited, x: p.x.items, y: p.y.items, }); - }; const coverObject = (p: { $defs?: Record | undefined; visited: Map>; x: IChatGptSchema.IObject; y: IChatGptSchema.IObject; - }): boolean => { - if (!p.x.additionalProperties && !!p.y.additionalProperties) return false; - else if ( - !!p.x.additionalProperties && - !!p.y.additionalProperties && - ((typeof p.x.additionalProperties === "object" && - p.y.additionalProperties === true) || - (typeof p.x.additionalProperties === "object" && - typeof p.y.additionalProperties === "object" && - !coverStation({ - $defs: p.$defs, - visited: p.visited, - x: p.x.additionalProperties, - y: p.y.additionalProperties, - }))) - ) - return false; - return Object.entries(p.y.properties ?? {}).every(([key, b]) => { + }): boolean => + Object.entries(p.y.properties ?? {}).every(([key, b]) => { const a: IChatGptSchema | undefined = p.x.properties?.[key]; if (a === undefined) return false; else if ( @@ -249,7 +218,6 @@ export namespace ChatGptTypeChecker { y: b, }); }); - }; const coverBoolean = ( x: IChatGptSchema.IBoolean, @@ -266,25 +234,7 @@ export namespace ChatGptTypeChecker { ): boolean => { if (!!x.enum?.length) return !!y.enum?.length && y.enum.every((v) => x.enum!.includes(v)); - return [ - x.type === y.type, - // x.minimum === undefined || - // (y.minimum !== undefined && x.minimum <= y.minimum), - // x.maximum === undefined || - // (y.maximum !== undefined && x.maximum >= y.maximum), - // x.exclusiveMinimum !== true || - // x.minimum === undefined || - // (y.minimum !== undefined && - // (y.exclusiveMinimum === true || x.minimum < y.minimum)), - // x.exclusiveMaximum !== true || - // x.maximum === undefined || - // (y.maximum !== undefined && - // (y.exclusiveMaximum === true || x.maximum > y.maximum)), - // x.multipleOf === undefined || - // (y.multipleOf !== undefined && - // y.multipleOf / x.multipleOf === - // Math.floor(y.multipleOf / x.multipleOf)), - ].every((v) => v); + return x.type === y.type; }; const coverNumber = ( @@ -293,25 +243,7 @@ export namespace ChatGptTypeChecker { ): boolean => { if (!!x.enum?.length) return !!y.enum?.length && y.enum.every((v) => x.enum!.includes(v)); - return [ - x.type === y.type || (x.type === "number" && y.type === "integer"), - // x.minimum === undefined || - // (y.minimum !== undefined && x.minimum <= y.minimum), - // x.maximum === undefined || - // (y.maximum !== undefined && x.maximum >= y.maximum), - // x.exclusiveMinimum !== true || - // x.minimum === undefined || - // (y.minimum !== undefined && - // (y.exclusiveMinimum === true || x.minimum < y.minimum)), - // x.exclusiveMaximum !== true || - // x.maximum === undefined || - // (y.maximum !== undefined && - // (y.exclusiveMaximum === true || x.maximum > y.maximum)), - // x.multipleOf === undefined || - // (y.multipleOf !== undefined && - // y.multipleOf / x.multipleOf === - // Math.floor(y.multipleOf / x.multipleOf)), - ].every((v) => v); + return x.type === y.type || (x.type === "number" && y.type === "integer"); }; const coverString = ( @@ -320,16 +252,7 @@ export namespace ChatGptTypeChecker { ): boolean => { if (!!x.enum?.length) return !!y.enum?.length && y.enum.every((v) => x.enum!.includes(v)); - return [ - x.type === y.type, - // x.format === undefined || - // (y.format !== undefined && coverFormat(x.format, y.format)), - // x.pattern === undefined || x.pattern === y.pattern, - // x.minLength === undefined || - // (y.minLength !== undefined && x.minLength <= y.minLength), - // x.maxLength === undefined || - // (y.maxLength !== undefined && x.maxLength >= y.maxLength), - ].every((v) => v); + return x.type === y.type; }; const flatSchema = ( diff --git a/src/utils/GeminiTypeChecker.ts b/src/utils/GeminiTypeChecker.ts index 0fb14cf..11602ca 100644 --- a/src/utils/GeminiTypeChecker.ts +++ b/src/utils/GeminiTypeChecker.ts @@ -16,11 +16,11 @@ export namespace GeminiTypeChecker { closure: (schema: IGeminiSchema) => void, ): void => { closure(schema); - if (isObject(schema)) { + if (isObject(schema)) Object.values(schema.properties ?? {}).forEach((child) => visit(child, closure), ); - } else if (isArray(schema)) visit(schema.items, closure); + else if (isArray(schema)) visit(schema.items, closure); }; export const covers = (x: IGeminiSchema, y: IGeminiSchema): boolean => { @@ -62,25 +62,7 @@ export namespace GeminiTypeChecker { ): boolean => { if (x.enum !== undefined) return y.enum !== undefined && x.enum.every((v) => y.enum!.includes(v)); - return [ - x.type === y.type, - // x.minimum === undefined || - // (y.minimum !== undefined && x.minimum <= y.minimum), - // x.maximum === undefined || - // (y.maximum !== undefined && x.maximum >= y.maximum), - // x.exclusiveMinimum !== true || - // x.minimum === undefined || - // (y.minimum !== undefined && - // (y.exclusiveMinimum === true || x.minimum < y.minimum)), - // x.exclusiveMaximum !== true || - // x.maximum === undefined || - // (y.maximum !== undefined && - // (y.exclusiveMaximum === true || x.maximum > y.maximum)), - // x.multipleOf === undefined || - // (y.multipleOf !== undefined && - // y.multipleOf / x.multipleOf === - // Math.floor(y.multipleOf / x.multipleOf)), - ].every((v) => v); + return x.type === y.type; }; /** @@ -92,25 +74,7 @@ export namespace GeminiTypeChecker { ): boolean => { if (x.enum !== undefined) return y.enum !== undefined && x.enum.every((v) => y.enum!.includes(v)); - return [ - x.type === y.type, - // x.minimum === undefined || - // (y.minimum !== undefined && x.minimum <= y.minimum), - // x.maximum === undefined || - // (y.maximum !== undefined && x.maximum >= y.maximum), - // x.exclusiveMinimum !== true || - // x.minimum === undefined || - // (y.minimum !== undefined && - // (y.exclusiveMinimum === true || x.minimum < y.minimum)), - // x.exclusiveMaximum !== true || - // x.maximum === undefined || - // (y.maximum !== undefined && - // (y.exclusiveMaximum === true || x.maximum > y.maximum)), - // x.multipleOf === undefined || - // (y.multipleOf !== undefined && - // y.multipleOf / x.multipleOf === - // Math.floor(y.multipleOf / x.multipleOf)), - ].every((v) => v); + return x.type === y.type; }; /** @@ -122,55 +86,16 @@ export namespace GeminiTypeChecker { ): boolean => { if (x.enum !== undefined) return y.enum !== undefined && x.enum.every((v) => y.enum!.includes(v)); - return [ - x.type === y.type, - // x.format === undefined || - // (y.format !== undefined && coverFormat(x.format, y.format)), - // x.pattern === undefined || x.pattern === y.pattern, - // x.minLength === undefined || - // (y.minLength !== undefined && x.minLength <= y.minLength), - // x.maxLength === undefined || - // (y.maxLength !== undefined && x.maxLength >= y.maxLength), - ].every((v) => v); + return x.type === y.type; }; - // /** - // * @internal - // */ - // const coverFormat = ( - // x: Required["format"], - // y: Required["format"], - // ): boolean => - // x === y || - // (x === "idn-email" && y === "email") || - // (x === "idn-hostname" && y === "hostname") || - // (["uri", "iri"].includes(x) && y === "url") || - // (x === "iri" && y === "uri") || - // (x === "iri-reference" && y === "uri-reference"); - /** * @internal */ const coverArray = ( x: IGeminiSchema.IArray, y: IGeminiSchema.IArray, - ): boolean => { - // if ( - // !( - // x.minItems === undefined || - // (y.minItems !== undefined && x.minItems <= y.minItems) - // ) - // ) - // return false; - // else if ( - // !( - // x.maxItems === undefined || - // (y.maxItems !== undefined && x.maxItems >= y.maxItems) - // ) - // ) - // return false; - return covers(x.items, y.items); - }; + ): boolean => covers(x.items, y.items); /** * @internal @@ -178,8 +103,8 @@ export namespace GeminiTypeChecker { const coverObject = ( x: IGeminiSchema.IObject, y: IGeminiSchema.IObject, - ): boolean => { - return Object.entries(y.properties ?? {}).every(([key, b]) => { + ): boolean => + Object.entries(y.properties ?? {}).every(([key, b]) => { const a: IGeminiSchema | undefined = x.properties?.[key]; if (a === undefined) return false; else if ( @@ -189,7 +114,6 @@ export namespace GeminiTypeChecker { return false; return covers(a, b); }); - }; /* ----------------------------------------------------------- TYPE CHECKERS diff --git a/src/utils/LlmTypeCheckerV3.ts b/src/utils/LlmTypeCheckerV3.ts index cf213e5..170d656 100644 --- a/src/utils/LlmTypeCheckerV3.ts +++ b/src/utils/LlmTypeCheckerV3.ts @@ -31,10 +31,15 @@ export namespace LlmTypeCheckerV3 { ): void => { callback(schema); if (isOneOf(schema)) schema.oneOf.forEach((s) => visit(s, callback)); - else if (isObject(schema)) + else if (isObject(schema)) { for (const [_, s] of Object.entries(schema.properties)) visit(s, callback); - else if (isArray(schema)) visit(schema.items, callback); + if ( + typeof schema.additionalProperties === "object" && + schema.additionalProperties !== null + ) + visit(schema.additionalProperties, callback); + } else if (isArray(schema)) visit(schema.items, callback); }; /* ----------------------------------------------------------- diff --git a/src/utils/OpenApiTypeChecker.ts b/src/utils/OpenApiTypeChecker.ts index 0334788..6c5e66e 100644 --- a/src/utils/OpenApiTypeChecker.ts +++ b/src/utils/OpenApiTypeChecker.ts @@ -90,6 +90,16 @@ export namespace OpenApiTypeChecker { recursive: props.recursive, }); + export const unreference = (props: { + components: OpenApi.IComponents; + schema: OpenApi.IJsonSchema; + }): OpenApi.IJsonSchema | null => + OpenApiTypeCheckerBase.unreference({ + prefix: "#/components/schemas/", + components: props.components, + schema: props.schema, + }); + export const visit = (props: { closure: (schema: OpenApi.IJsonSchema) => void; components: OpenApi.IComponents; diff --git a/src/utils/internal/OpenApiTypeCheckerBase.ts b/src/utils/internal/OpenApiTypeCheckerBase.ts index 06469ac..46fca09 100644 --- a/src/utils/internal/OpenApiTypeCheckerBase.ts +++ b/src/utils/internal/OpenApiTypeCheckerBase.ts @@ -99,6 +99,18 @@ export namespace OpenApiTypeCheckerBase { /* ----------------------------------------------------------- OPERATORS ----------------------------------------------------------- */ + export const unreference = (props: { + prefix: string; + components: OpenApi.IComponents; + schema: OpenApi.IJsonSchema; + }): OpenApi.IJsonSchema | null => { + if (isReference(props.schema) === false) return props.schema; + const key: string = props.schema.$ref.split(props.prefix).pop()!; + const found: OpenApi.IJsonSchema | undefined = + props.components.schemas?.[key]; + return found ? unreference({ ...props, schema: found }) : null; + }; + export const escape = (props: { prefix: string; components: OpenApi.IComponents; diff --git a/test/features/llm/chatgpt/test_chatgpt_function_calling_default.ts b/test/features/llm/chatgpt/test_chatgpt_function_calling_default.ts index 38c3a9e..cdd2b38 100644 --- a/test/features/llm/chatgpt/test_chatgpt_function_calling_default.ts +++ b/test/features/llm/chatgpt/test_chatgpt_function_calling_default.ts @@ -11,7 +11,7 @@ export const test_chatgpt_function_calling_default = () => }, name: "enrollPerson", description: "Enroll a person to the restaurant reservation list.", - collection: typia.json.schemas<[{ input: IPerson }]>(), + collection: typia.json.schemas<[IParameters]>(), texts: [ { role: "assistant", @@ -41,6 +41,9 @@ export const test_chatgpt_function_calling_default = () => }, }); +interface IParameters { + input: IPerson; +} interface IPerson { name: string & tags.Default<"John Doe">; age: number & tags.Default<42>; diff --git a/test/features/llm/claude/test_claude_function_calling_additionalProperties.ts b/test/features/llm/claude/test_claude_function_calling_additionalProperties.ts new file mode 100644 index 0000000..69ddcd7 --- /dev/null +++ b/test/features/llm/claude/test_claude_function_calling_additionalProperties.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import typia, { tags } from "typia"; + +import { TestGlobal } from "../../../TestGlobal"; +import { ClaudeFunctionCaller } from "../../../utils/ClaudeFunctionCaller"; + +export const test_claude_function_calling_additionalProperties = + (): Promise => + ClaudeFunctionCaller.test({ + model: "claude", + name: "enrollPerson", + description: "Enroll a person to the restaurant reservation list.", + collection: typia.json.schemas<[{ input: IPerson }]>(), + texts: [ + { + role: "assistant", + content: SYSTEM_MESSAGE, + }, + { + role: "user", + content: USER_MESSAGE, + }, + ], + handleParameters: async (parameters) => { + if (process.argv.includes("--file")) + await fs.promises.writeFile( + `${TestGlobal.ROOT}/examples/function-calling/schemas/claude.additionalProperties.schema.json`, + JSON.stringify(parameters, null, 2), + "utf8", + ); + }, + handleCompletion: async (input) => { + typia.assert(input); + if (process.argv.includes("--file")) + await fs.promises.writeFile( + `${TestGlobal.ROOT}/examples/function-calling/arguments/claude.additionalProperties.input.json`, + JSON.stringify(input, null, 2), + "utf8", + ); + }, + }); + +interface IPerson { + /** + * The name of the person. + */ + name: string; + + /** + * The age of the person. + */ + age: number & tags.Type<"uint32">; + + /** + * Additional informations about the person. + */ + etc: Record; +} + +const SYSTEM_MESSAGE = + "You are a helpful customer support assistant. Use the supplied tools to assist the user."; + +const USER_MESSAGE = ` + Just enroll a person with below information: + + - name: John Doe + - age: 42 + - hobby: Soccer + - job: Scientist +`; diff --git a/test/features/llm/llama/test_llama_function_calling_additionalProperties.ts b/test/features/llm/llama/test_llama_function_calling_additionalProperties.ts new file mode 100644 index 0000000..95e0d9e --- /dev/null +++ b/test/features/llm/llama/test_llama_function_calling_additionalProperties.ts @@ -0,0 +1,70 @@ +import fs from "fs"; +import typia, { tags } from "typia"; + +import { TestGlobal } from "../../../TestGlobal"; +import { LlamaFunctionCaller } from "../../../utils/LlamaFunctionCaller"; + +export const test_llama_function_calling_additionalProperties = + (): Promise => + LlamaFunctionCaller.test({ + model: "claude", + name: "enrollPerson", + description: "Enroll a person to the restaurant reservation list.", + collection: typia.json.schemas<[{ input: IPerson }]>(), + texts: [ + { + role: "assistant", + content: SYSTEM_MESSAGE, + }, + { + role: "user", + content: USER_MESSAGE, + }, + ], + handleParameters: async (parameters) => { + if (process.argv.includes("--file")) + await fs.promises.writeFile( + `${TestGlobal.ROOT}/examples/function-calling/schemas/llama.additionalProperties.schema.json`, + JSON.stringify(parameters, null, 2), + "utf8", + ); + }, + handleCompletion: async (input) => { + typia.assert(input); + if (process.argv.includes("--file")) + await fs.promises.writeFile( + `${TestGlobal.ROOT}/examples/function-calling/arguments/llama.additionalProperties.input.json`, + JSON.stringify(input, null, 2), + "utf8", + ); + }, + }); + +interface IPerson { + /** + * The name of the person. + */ + name: string; + + /** + * The age of the person. + */ + age: number & tags.Type<"uint32">; + + /** + * Additional informations about the person. + */ + etc: Record; +} + +const SYSTEM_MESSAGE = + "You are a helpful customer support assistant. Use the supplied tools to assist the user."; + +const USER_MESSAGE = ` + Just enroll a person with below information: + + - name: John Doe + - age: 42 + - hobby: Soccer + - job: Scientist +`; diff --git a/test/utils/ChatGptFunctionCaller.ts b/test/utils/ChatGptFunctionCaller.ts index f5130f0..3d7d10b 100644 --- a/test/utils/ChatGptFunctionCaller.ts +++ b/test/utils/ChatGptFunctionCaller.ts @@ -24,9 +24,9 @@ export namespace ChatGptFunctionCaller { const parameters: IChatGptSchema.IParameters | null = LlmSchemaConverter.parameters("chatgpt")({ components: props.collection.components, - schema: typia.assert( - props.collection.schemas[0], - ), + schema: typia.assert< + OpenApi.IJsonSchema.IObject | OpenApi.IJsonSchema.IReference + >(props.collection.schemas[0]), config: { ...LlmSchemaConverter.defaultConfig("chatgpt"), ...(props.config ?? {}), diff --git a/test/utils/ClaudeFunctionCaller.ts b/test/utils/ClaudeFunctionCaller.ts index dd39e61..af3dcee 100644 --- a/test/utils/ClaudeFunctionCaller.ts +++ b/test/utils/ClaudeFunctionCaller.ts @@ -12,7 +12,7 @@ export namespace ClaudeFunctionCaller { Model extends "chatgpt" | "claude" | "gemini", >(props: { model: Model; - config: Partial; + config?: Partial; name: string; description: string; collection: IJsonSchemaCollection;