schema-brief 0.1.0 → 0.2.0
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/CHANGELOG.md +21 -0
- package/README.md +55 -4
- package/package.json +2 -1
- package/src/index.d.ts +65 -0
- package/src/index.js +416 -15
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Add `extractJsonValues` for extracting multiple JSON payloads from one model response.
|
|
6
|
+
- Add `splitJson` for separating surrounding prose from parsed JSON payloads.
|
|
7
|
+
- Add `repairJsonText` for common non-eval JSON repair such as trailing commas, comments, and smart quotes.
|
|
8
|
+
- Add `createContract` as a reusable schema boundary object with prompt, parse, validate, repair, and provider helpers.
|
|
9
|
+
- Add `toOpenAIResponseFormat`, `toOpenAITool`, and `toAnthropicTool`.
|
|
10
|
+
- Update competitive positioning against structured-output frameworks and JSON extraction utilities.
|
|
11
|
+
|
|
12
|
+
## 0.1.1
|
|
13
|
+
|
|
14
|
+
- Improve JSON extraction when non-JSON markdown fences appear before the actual JSON payload.
|
|
15
|
+
- Report invalid schema `pattern` values as validation issues instead of throwing.
|
|
16
|
+
- Add validation support for `allOf`, `anyOf`, `oneOf`, tuple `items`, `uniqueItems`, `minProperties`, `maxProperties`, `exclusiveMinimum`, `exclusiveMaximum`, and `multipleOf`.
|
|
17
|
+
- Expand TypeScript declarations and README supported-keyword documentation.
|
|
18
|
+
|
|
19
|
+
## 0.1.0
|
|
20
|
+
|
|
21
|
+
- Initial release with prompt generation, JSON extraction, schema validation, and repair prompts.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Compact JSON Schema into LLM-ready instructions, extract JSON from model output, and validate the response with the same contract.
|
|
4
4
|
|
|
5
|
-
`schema-brief` is designed for TypeScript AI applications that need structured output without adding a heavy validation or prompt-engineering dependency. It supports the most common JSON Schema subset used for model outputs
|
|
5
|
+
`schema-brief` is designed for TypeScript AI applications that need structured output without adding a heavy validation or prompt-engineering dependency. It supports the most common JSON Schema subset used for model outputs and includes provider helper shapes for OpenAI and Anthropic.
|
|
6
6
|
|
|
7
7
|
## Why this exists
|
|
8
8
|
|
|
@@ -23,7 +23,7 @@ This package sits at that boundary: one schema becomes prompt instructions, vali
|
|
|
23
23
|
| LangChain structured output | Agent pipelines and retries inside LangChain | `schema-brief` is framework-neutral and has no provider/runtime dependency. |
|
|
24
24
|
| Vercel AI SDK structured output | Full-stack apps already using AI SDK | `schema-brief` can be used before or after any model call, including local models and custom SDKs. |
|
|
25
25
|
| AJV and JSON Schema validators | Full JSON Schema compliance and high-throughput validation | `schema-brief` optimizes for the common LLM-output subset and also generates prompt/repair text. |
|
|
26
|
-
| JSON extraction utilities | Pulling JSON out of messy model text | `schema-brief` couples extraction with schema prompt generation and
|
|
26
|
+
| JSON extraction utilities | Pulling JSON out of messy model text | `schema-brief` extracts one or many JSON payloads and couples extraction with schema prompt generation, validation, repair text, and provider formats. |
|
|
27
27
|
| Zod/Valibot-based helpers | TypeScript schema-first apps | `schema-brief` starts from portable JSON Schema, which fits OpenAPI, MCP, and provider-native structured outputs. |
|
|
28
28
|
|
|
29
29
|
## Install
|
|
@@ -69,6 +69,21 @@ if (!result.ok) {
|
|
|
69
69
|
}
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
You can also create a reusable contract object:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { createContract } from "schema-brief";
|
|
76
|
+
|
|
77
|
+
const contract = createContract(schema);
|
|
78
|
+
|
|
79
|
+
await model.generate({
|
|
80
|
+
system: contract.instructions,
|
|
81
|
+
response_format: contract.toOpenAIResponseFormat()
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const parsed = contract.parse(modelText);
|
|
85
|
+
```
|
|
86
|
+
|
|
72
87
|
## API
|
|
73
88
|
|
|
74
89
|
### `brief(schema, options?)`
|
|
@@ -86,6 +101,18 @@ Options:
|
|
|
86
101
|
|
|
87
102
|
Extracts and parses the first complete JSON object or array from raw text or markdown fences.
|
|
88
103
|
|
|
104
|
+
### `extractJsonValues(text)`
|
|
105
|
+
|
|
106
|
+
Extracts every complete JSON object or array from raw text.
|
|
107
|
+
|
|
108
|
+
### `splitJson(text)`
|
|
109
|
+
|
|
110
|
+
Returns `{ text, json }`, separating surrounding prose from parsed JSON payloads.
|
|
111
|
+
|
|
112
|
+
### `repairJsonText(text)`
|
|
113
|
+
|
|
114
|
+
Repairs common LLM JSON formatting mistakes before parsing, including trailing commas, JavaScript-style comments, and smart quotes. It does not evaluate code.
|
|
115
|
+
|
|
89
116
|
### `validate(schema, value)`
|
|
90
117
|
|
|
91
118
|
Validates a value against the supported JSON Schema subset.
|
|
@@ -102,10 +129,26 @@ Returns:
|
|
|
102
129
|
|
|
103
130
|
Combines `extractJson` and `validate`.
|
|
104
131
|
|
|
132
|
+
### `createContract(schema, options?)`
|
|
133
|
+
|
|
134
|
+
Returns a reusable object with `instructions`, `parse`, `validate`, `repairPrompt`, `toOpenAIResponseFormat`, `toOpenAITool`, and `toAnthropicTool`.
|
|
135
|
+
|
|
105
136
|
### `repairPrompt(schema, issues)`
|
|
106
137
|
|
|
107
138
|
Creates a concise prompt asking a model to repair invalid JSON.
|
|
108
139
|
|
|
140
|
+
### `toOpenAIResponseFormat(schema, options?)`
|
|
141
|
+
|
|
142
|
+
Creates an OpenAI `response_format` object using `type: "json_schema"`.
|
|
143
|
+
|
|
144
|
+
### `toOpenAITool(schema, options?)`
|
|
145
|
+
|
|
146
|
+
Creates an OpenAI tool/function definition from the schema.
|
|
147
|
+
|
|
148
|
+
### `toAnthropicTool(schema, options?)`
|
|
149
|
+
|
|
150
|
+
Creates an Anthropic tool definition from the schema.
|
|
151
|
+
|
|
109
152
|
## Supported Schema Keywords
|
|
110
153
|
|
|
111
154
|
- `type`
|
|
@@ -117,23 +160,31 @@ Creates a concise prompt asking a model to repair invalid JSON.
|
|
|
117
160
|
- `required`
|
|
118
161
|
- `additionalProperties`
|
|
119
162
|
- `items`
|
|
163
|
+
- `allOf`
|
|
164
|
+
- `anyOf`
|
|
165
|
+
- `oneOf`
|
|
120
166
|
- `minItems`
|
|
121
167
|
- `maxItems`
|
|
168
|
+
- `uniqueItems`
|
|
169
|
+
- `minProperties`
|
|
170
|
+
- `maxProperties`
|
|
122
171
|
- `minLength`
|
|
123
172
|
- `maxLength`
|
|
124
173
|
- `pattern`
|
|
125
174
|
- `format` for prompt output only
|
|
126
175
|
- `minimum`
|
|
127
176
|
- `maximum`
|
|
177
|
+
- `exclusiveMinimum`
|
|
178
|
+
- `exclusiveMaximum`
|
|
179
|
+
- `multipleOf`
|
|
128
180
|
|
|
129
181
|
## Product Roadmap
|
|
130
182
|
|
|
131
183
|
- Zod adapter: `briefFromZod(schema)`
|
|
132
184
|
- Standard Schema adapter
|
|
133
|
-
- OpenAI, Anthropic, and Vercel AI SDK helpers
|
|
134
185
|
- Token-budgeted schema compression
|
|
135
186
|
- Browser bundle size CI
|
|
136
|
-
- JSON repair suggestions with deterministic patches
|
|
187
|
+
- Schema-aware JSON repair suggestions with deterministic patches
|
|
137
188
|
|
|
138
189
|
## Development
|
|
139
190
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "schema-brief",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Compact JSON Schema prompts and validate structured LLM outputs with zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
16
|
"examples",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"README.md",
|
|
18
19
|
"LICENSE"
|
|
19
20
|
],
|
package/src/index.d.ts
CHANGED
|
@@ -20,14 +20,23 @@ export interface JsonSchema {
|
|
|
20
20
|
required?: readonly string[];
|
|
21
21
|
additionalProperties?: boolean | JsonSchema;
|
|
22
22
|
items?: JsonSchema | readonly JsonSchema[];
|
|
23
|
+
allOf?: readonly JsonSchema[];
|
|
24
|
+
anyOf?: readonly JsonSchema[];
|
|
25
|
+
oneOf?: readonly JsonSchema[];
|
|
23
26
|
minItems?: number;
|
|
24
27
|
maxItems?: number;
|
|
28
|
+
uniqueItems?: boolean;
|
|
29
|
+
minProperties?: number;
|
|
30
|
+
maxProperties?: number;
|
|
25
31
|
minLength?: number;
|
|
26
32
|
maxLength?: number;
|
|
27
33
|
pattern?: string;
|
|
28
34
|
format?: string;
|
|
29
35
|
minimum?: number;
|
|
30
36
|
maximum?: number;
|
|
37
|
+
exclusiveMinimum?: number;
|
|
38
|
+
exclusiveMaximum?: number;
|
|
39
|
+
multipleOf?: number;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export interface BriefOptions {
|
|
@@ -37,6 +46,17 @@ export interface BriefOptions {
|
|
|
37
46
|
maxDepth?: number;
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
export interface SplitJsonResult {
|
|
50
|
+
text: string[];
|
|
51
|
+
json: unknown[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ProviderFormatOptions {
|
|
55
|
+
name?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
strict?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
export interface ValidationIssue {
|
|
41
61
|
path: string;
|
|
42
62
|
code: string;
|
|
@@ -49,8 +69,53 @@ export type ValidationResult<T = unknown> =
|
|
|
49
69
|
|
|
50
70
|
export type ParseResult<T = unknown> = ValidationResult<T>;
|
|
51
71
|
|
|
72
|
+
export interface OpenAIResponseFormat {
|
|
73
|
+
type: "json_schema";
|
|
74
|
+
json_schema: {
|
|
75
|
+
name: string;
|
|
76
|
+
strict: boolean;
|
|
77
|
+
schema: JsonSchema;
|
|
78
|
+
description?: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OpenAITool {
|
|
83
|
+
type: "function";
|
|
84
|
+
function: {
|
|
85
|
+
name: string;
|
|
86
|
+
description: string;
|
|
87
|
+
parameters: JsonSchema;
|
|
88
|
+
strict: boolean;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AnthropicTool {
|
|
93
|
+
name: string;
|
|
94
|
+
description: string;
|
|
95
|
+
input_schema: JsonSchema;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface OutputContract<T = unknown> {
|
|
99
|
+
schema: JsonSchema;
|
|
100
|
+
prompt: string;
|
|
101
|
+
instructions: string;
|
|
102
|
+
parse(text: string): ParseResult<T>;
|
|
103
|
+
validate(value: unknown): ValidationResult<T>;
|
|
104
|
+
repairPrompt(issues: readonly ValidationIssue[]): string;
|
|
105
|
+
toOpenAIResponseFormat(options?: ProviderFormatOptions): OpenAIResponseFormat;
|
|
106
|
+
toOpenAITool(options?: ProviderFormatOptions): OpenAITool;
|
|
107
|
+
toAnthropicTool(options?: ProviderFormatOptions): AnthropicTool;
|
|
108
|
+
}
|
|
109
|
+
|
|
52
110
|
export function brief(schema: JsonSchema, options?: BriefOptions): string;
|
|
53
111
|
export function extractJson(text: string): unknown;
|
|
112
|
+
export function extractJsonValues(text: string): unknown[];
|
|
113
|
+
export function splitJson(text: string): SplitJsonResult;
|
|
114
|
+
export function repairJsonText(text: string): string;
|
|
54
115
|
export function validate<T = unknown>(schema: JsonSchema, value: unknown): ValidationResult<T>;
|
|
55
116
|
export function parseStructured<T = unknown>(text: string, schema: JsonSchema): ParseResult<T>;
|
|
117
|
+
export function createContract<T = unknown>(schema: JsonSchema, options?: BriefOptions): OutputContract<T>;
|
|
56
118
|
export function repairPrompt(schema: JsonSchema, issues: readonly ValidationIssue[]): string;
|
|
119
|
+
export function toOpenAIResponseFormat(schema: JsonSchema, options?: ProviderFormatOptions): OpenAIResponseFormat;
|
|
120
|
+
export function toOpenAITool(schema: JsonSchema, options?: ProviderFormatOptions): OpenAITool;
|
|
121
|
+
export function toAnthropicTool(schema: JsonSchema, options?: ProviderFormatOptions): AnthropicTool;
|
package/src/index.js
CHANGED
|
@@ -47,18 +47,97 @@ export function extractJson(text) {
|
|
|
47
47
|
throw new TypeError("extractJson expected a string");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const values = extractJsonValues(text);
|
|
51
|
+
if (values.length > 0) {
|
|
52
|
+
return values[0];
|
|
53
|
+
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
throw new SyntaxError("No JSON object or array found in text");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract every complete JSON object or array from text.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} text
|
|
62
|
+
* @returns {unknown[]}
|
|
63
|
+
*/
|
|
64
|
+
export function extractJsonValues(text) {
|
|
65
|
+
if (typeof text !== "string") {
|
|
66
|
+
throw new TypeError("extractJsonValues expected a string");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const matches = [];
|
|
70
|
+
const fences = [...text.matchAll(/```[a-zA-Z0-9_-]*\s*([\s\S]*?)```/g)];
|
|
71
|
+
|
|
72
|
+
for (const match of fences) {
|
|
73
|
+
const values = parseJsonValues(match[1].trim());
|
|
74
|
+
if (values.length > 0) {
|
|
75
|
+
matches.push({ start: match.index, values });
|
|
59
76
|
}
|
|
60
|
-
return JSON.parse(slice);
|
|
61
77
|
}
|
|
78
|
+
|
|
79
|
+
const outside = maskRanges(text, fences.map((match) => [match.index, match.index + match[0].length]));
|
|
80
|
+
for (const match of jsonMatches(outside)) {
|
|
81
|
+
matches.push({ start: match.start, values: [match.value] });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return matches
|
|
85
|
+
.sort((a, b) => a.start - b.start)
|
|
86
|
+
.flatMap((match) => match.values);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Separate parsed JSON payloads from surrounding text.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} text
|
|
93
|
+
* @returns {import("./index.d.ts").SplitJsonResult}
|
|
94
|
+
*/
|
|
95
|
+
export function splitJson(text) {
|
|
96
|
+
if (typeof text !== "string") {
|
|
97
|
+
throw new TypeError("splitJson expected a string");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parts = [];
|
|
101
|
+
const json = [];
|
|
102
|
+
const matches = jsonMatches(text);
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
|
|
105
|
+
for (const match of matches) {
|
|
106
|
+
const before = text.slice(cursor, match.start).trim();
|
|
107
|
+
if (before) {
|
|
108
|
+
parts.push(before);
|
|
109
|
+
}
|
|
110
|
+
json.push(match.value);
|
|
111
|
+
cursor = match.end;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const after = text.slice(cursor).trim();
|
|
115
|
+
if (after) {
|
|
116
|
+
parts.push(after);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { text: parts, json };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Repair common LLM JSON formatting mistakes without evaluating code.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} text
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function repairJsonText(text) {
|
|
129
|
+
if (typeof text !== "string") {
|
|
130
|
+
throw new TypeError("repairJsonText expected a string");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const normalized = text
|
|
134
|
+
.replace(/^\uFEFF/, "")
|
|
135
|
+
.replace(/[\u201C\u201D]/g, "\"")
|
|
136
|
+
.replace(/[\u2018\u2019]/g, "'");
|
|
137
|
+
|
|
138
|
+
return stripJsonComments(normalized)
|
|
139
|
+
.trim()
|
|
140
|
+
.replace(/,\s*([}\]])/g, "$1");
|
|
62
141
|
}
|
|
63
142
|
|
|
64
143
|
/**
|
|
@@ -100,6 +179,28 @@ export function parseStructured(text, schema) {
|
|
|
100
179
|
}
|
|
101
180
|
}
|
|
102
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Create a reusable structured-output contract around one schema.
|
|
184
|
+
*
|
|
185
|
+
* @param {import("./index.d.ts").JsonSchema} schema
|
|
186
|
+
* @param {import("./index.d.ts").BriefOptions} [options]
|
|
187
|
+
* @returns {import("./index.d.ts").OutputContract}
|
|
188
|
+
*/
|
|
189
|
+
export function createContract(schema, options = {}) {
|
|
190
|
+
const prompt = brief(schema, options);
|
|
191
|
+
return {
|
|
192
|
+
schema,
|
|
193
|
+
prompt,
|
|
194
|
+
instructions: prompt,
|
|
195
|
+
parse: (text) => parseStructured(text, schema),
|
|
196
|
+
validate: (value) => validate(schema, value),
|
|
197
|
+
repairPrompt: (issues) => repairPrompt(schema, issues),
|
|
198
|
+
toOpenAIResponseFormat: (providerOptions = {}) => toOpenAIResponseFormat(schema, providerOptions),
|
|
199
|
+
toOpenAITool: (providerOptions = {}) => toOpenAITool(schema, providerOptions),
|
|
200
|
+
toAnthropicTool: (providerOptions = {}) => toAnthropicTool(schema, providerOptions)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
103
204
|
/**
|
|
104
205
|
* Create a concise repair prompt from validation issues.
|
|
105
206
|
*
|
|
@@ -118,6 +219,64 @@ export function repairPrompt(schema, issues) {
|
|
|
118
219
|
].join("\n");
|
|
119
220
|
}
|
|
120
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Convert a JSON Schema into OpenAI's json_schema response_format shape.
|
|
224
|
+
*
|
|
225
|
+
* @param {import("./index.d.ts").JsonSchema} schema
|
|
226
|
+
* @param {import("./index.d.ts").ProviderFormatOptions} [options]
|
|
227
|
+
* @returns {import("./index.d.ts").OpenAIResponseFormat}
|
|
228
|
+
*/
|
|
229
|
+
export function toOpenAIResponseFormat(schema, options = {}) {
|
|
230
|
+
const jsonSchema = {
|
|
231
|
+
name: schemaName(schema, options.name),
|
|
232
|
+
strict: options.strict !== false,
|
|
233
|
+
schema
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (typeof options.description === "string" && options.description.trim()) {
|
|
237
|
+
jsonSchema.description = options.description.trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
type: "json_schema",
|
|
242
|
+
json_schema: jsonSchema
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Convert a JSON Schema into an OpenAI tool/function definition.
|
|
248
|
+
*
|
|
249
|
+
* @param {import("./index.d.ts").JsonSchema} schema
|
|
250
|
+
* @param {import("./index.d.ts").ProviderFormatOptions} [options]
|
|
251
|
+
* @returns {import("./index.d.ts").OpenAITool}
|
|
252
|
+
*/
|
|
253
|
+
export function toOpenAITool(schema, options = {}) {
|
|
254
|
+
return {
|
|
255
|
+
type: "function",
|
|
256
|
+
function: {
|
|
257
|
+
name: schemaName(schema, options.name),
|
|
258
|
+
description: providerDescription(schema, options),
|
|
259
|
+
parameters: schema,
|
|
260
|
+
strict: options.strict !== false
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Convert a JSON Schema into an Anthropic tool definition.
|
|
267
|
+
*
|
|
268
|
+
* @param {import("./index.d.ts").JsonSchema} schema
|
|
269
|
+
* @param {import("./index.d.ts").ProviderFormatOptions} [options]
|
|
270
|
+
* @returns {import("./index.d.ts").AnthropicTool}
|
|
271
|
+
*/
|
|
272
|
+
export function toAnthropicTool(schema, options = {}) {
|
|
273
|
+
return {
|
|
274
|
+
name: schemaName(schema, options.name),
|
|
275
|
+
description: providerDescription(schema, options),
|
|
276
|
+
input_schema: schema
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
121
280
|
function describeSchema(schema, context) {
|
|
122
281
|
if (context.depth > context.maxDepth) {
|
|
123
282
|
return "...";
|
|
@@ -131,6 +290,18 @@ function describeSchema(schema, context) {
|
|
|
131
290
|
return schema.enum.map((item) => JSON.stringify(item)).join(" | ");
|
|
132
291
|
}
|
|
133
292
|
|
|
293
|
+
if (Array.isArray(schema.oneOf)) {
|
|
294
|
+
return schema.oneOf.map((child) => describeSchema(child, nextContext(context))).join(" | ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (Array.isArray(schema.anyOf)) {
|
|
298
|
+
return schema.anyOf.map((child) => describeSchema(child, nextContext(context))).join(" | ");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (Array.isArray(schema.allOf)) {
|
|
302
|
+
return schema.allOf.map((child) => describeSchema(child, nextContext(context))).join(" & ");
|
|
303
|
+
}
|
|
304
|
+
|
|
134
305
|
const type = normalizeType(schema);
|
|
135
306
|
|
|
136
307
|
if (Array.isArray(type)) {
|
|
@@ -154,7 +325,9 @@ function describeSchema(schema, context) {
|
|
|
154
325
|
}
|
|
155
326
|
|
|
156
327
|
if (type === "array" || schema.items) {
|
|
157
|
-
const item =
|
|
328
|
+
const item = Array.isArray(schema.items)
|
|
329
|
+
? `[${schema.items.map((child) => describeSchema(child, nextContext(context))).join(", ")}]`
|
|
330
|
+
: schema.items
|
|
158
331
|
? describeSchema(schema.items, nextContext(context))
|
|
159
332
|
: "unknown";
|
|
160
333
|
const range = formatRange(schema.minItems, schema.maxItems, "items");
|
|
@@ -190,6 +363,10 @@ function collectDescriptions(schema, path = "$", notes = []) {
|
|
|
190
363
|
|
|
191
364
|
if (schema.items && !Array.isArray(schema.items)) {
|
|
192
365
|
collectDescriptions(schema.items, `${path}[]`, notes);
|
|
366
|
+
} else if (Array.isArray(schema.items)) {
|
|
367
|
+
schema.items.forEach((child, index) => {
|
|
368
|
+
collectDescriptions(child, `${path}[${index}]`, notes);
|
|
369
|
+
});
|
|
193
370
|
}
|
|
194
371
|
|
|
195
372
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
@@ -215,6 +392,10 @@ function visit(schema, value, path, issues, depth) {
|
|
|
215
392
|
return;
|
|
216
393
|
}
|
|
217
394
|
|
|
395
|
+
if (!validateCompositions(schema, value, path, issues, depth)) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
218
399
|
const expected = normalizeType(schema);
|
|
219
400
|
if (expected && !matchesType(expected, value)) {
|
|
220
401
|
issues.push(issue(path, "type", `Expected ${Array.isArray(expected) ? expected.join(" or ") : expected}`));
|
|
@@ -243,6 +424,15 @@ function visit(schema, value, path, issues, depth) {
|
|
|
243
424
|
function validateObject(schema, value, path, issues, depth) {
|
|
244
425
|
const props = schema.properties ?? {};
|
|
245
426
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
427
|
+
const keys = Object.keys(value);
|
|
428
|
+
|
|
429
|
+
if (typeof schema.minProperties === "number" && keys.length < schema.minProperties) {
|
|
430
|
+
issues.push(issue(path, "min_properties", `Expected at least ${schema.minProperties} properties`));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (typeof schema.maxProperties === "number" && keys.length > schema.maxProperties) {
|
|
434
|
+
issues.push(issue(path, "max_properties", `Expected at most ${schema.maxProperties} properties`));
|
|
435
|
+
}
|
|
246
436
|
|
|
247
437
|
for (const key of required) {
|
|
248
438
|
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
@@ -273,7 +463,25 @@ function validateArray(schema, value, path, issues, depth) {
|
|
|
273
463
|
issues.push(issue(path, "max_items", `Expected at most ${schema.maxItems} items`));
|
|
274
464
|
}
|
|
275
465
|
|
|
276
|
-
if (schema.
|
|
466
|
+
if (schema.uniqueItems === true) {
|
|
467
|
+
const seen = new Set();
|
|
468
|
+
for (const itemValue of value) {
|
|
469
|
+
const key = stableStringify(itemValue);
|
|
470
|
+
if (seen.has(key)) {
|
|
471
|
+
issues.push(issue(path, "unique_items", "Expected array items to be unique"));
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
seen.add(key);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (Array.isArray(schema.items)) {
|
|
479
|
+
schema.items.forEach((itemSchema, index) => {
|
|
480
|
+
if (index < value.length) {
|
|
481
|
+
visit(itemSchema, value[index], `${path}[${index}]`, issues, depth + 1);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
} else if (schema.items) {
|
|
277
485
|
value.forEach((itemValue, index) => {
|
|
278
486
|
visit(schema.items, itemValue, `${path}[${index}]`, issues, depth + 1);
|
|
279
487
|
});
|
|
@@ -290,7 +498,8 @@ function validateString(schema, value, path, issues) {
|
|
|
290
498
|
}
|
|
291
499
|
|
|
292
500
|
if (typeof schema.pattern === "string") {
|
|
293
|
-
const pattern =
|
|
501
|
+
const pattern = safeRegExp(schema.pattern, path, issues);
|
|
502
|
+
if (!pattern) return;
|
|
294
503
|
if (!pattern.test(value)) {
|
|
295
504
|
issues.push(issue(path, "pattern", `Expected string to match /${schema.pattern}/`));
|
|
296
505
|
}
|
|
@@ -313,6 +522,47 @@ function validateNumber(schema, value, path, issues) {
|
|
|
313
522
|
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
|
314
523
|
issues.push(issue(path, "maximum", `Expected value <= ${schema.maximum}`));
|
|
315
524
|
}
|
|
525
|
+
|
|
526
|
+
if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
|
|
527
|
+
issues.push(issue(path, "exclusive_minimum", `Expected value > ${schema.exclusiveMinimum}`));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
|
|
531
|
+
issues.push(issue(path, "exclusive_maximum", `Expected value < ${schema.exclusiveMaximum}`));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (typeof schema.multipleOf === "number" && schema.multipleOf > 0) {
|
|
535
|
+
const quotient = value / schema.multipleOf;
|
|
536
|
+
if (Math.abs(quotient - Math.round(quotient)) > Number.EPSILON * 100) {
|
|
537
|
+
issues.push(issue(path, "multiple_of", `Expected a multiple of ${schema.multipleOf}`));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function validateCompositions(schema, value, path, issues, depth) {
|
|
543
|
+
if (Array.isArray(schema.allOf)) {
|
|
544
|
+
for (const child of schema.allOf) {
|
|
545
|
+
visit(child, value, path, issues, depth + 1);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (Array.isArray(schema.anyOf)) {
|
|
550
|
+
const matches = schema.anyOf.filter((child) => validate(child, value).ok);
|
|
551
|
+
if (matches.length === 0) {
|
|
552
|
+
issues.push(issue(path, "any_of", "Expected value to match at least one schema"));
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (Array.isArray(schema.oneOf)) {
|
|
558
|
+
const matches = schema.oneOf.filter((child) => validate(child, value).ok);
|
|
559
|
+
if (matches.length !== 1) {
|
|
560
|
+
issues.push(issue(path, "one_of", `Expected value to match exactly one schema, matched ${matches.length}`));
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return true;
|
|
316
566
|
}
|
|
317
567
|
|
|
318
568
|
function normalizeType(schema) {
|
|
@@ -374,8 +624,155 @@ function stableStringify(value) {
|
|
|
374
624
|
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`).join(",")}}`;
|
|
375
625
|
}
|
|
376
626
|
|
|
377
|
-
function
|
|
627
|
+
function parseJsonValues(source) {
|
|
628
|
+
try {
|
|
629
|
+
return [parseJsonText(source)];
|
|
630
|
+
} catch {
|
|
631
|
+
return jsonSlices(source)
|
|
632
|
+
.map((slice) => {
|
|
633
|
+
try {
|
|
634
|
+
return parseJsonText(slice.value);
|
|
635
|
+
} catch {
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
})
|
|
639
|
+
.filter((value) => value !== undefined);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function maskRanges(source, ranges) {
|
|
644
|
+
if (ranges.length === 0) {
|
|
645
|
+
return source;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const chars = [...source];
|
|
649
|
+
for (const [start, end] of ranges) {
|
|
650
|
+
for (let index = start; index < end; index += 1) {
|
|
651
|
+
chars[index] = " ";
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return chars.join("");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function parseJsonText(source) {
|
|
658
|
+
try {
|
|
659
|
+
return JSON.parse(source);
|
|
660
|
+
} catch {
|
|
661
|
+
return JSON.parse(repairJsonText(source));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function jsonMatches(source) {
|
|
666
|
+
return jsonSlices(source)
|
|
667
|
+
.map((slice) => {
|
|
668
|
+
try {
|
|
669
|
+
return {
|
|
670
|
+
start: slice.start,
|
|
671
|
+
end: slice.end,
|
|
672
|
+
value: parseJsonText(slice.value)
|
|
673
|
+
};
|
|
674
|
+
} catch {
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
.filter(Boolean);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function jsonSlices(source) {
|
|
682
|
+
const slices = [];
|
|
378
683
|
for (let index = 0; index < source.length; index += 1) {
|
|
684
|
+
const slice = firstJsonSlice(source, index);
|
|
685
|
+
if (slice) {
|
|
686
|
+
slices.push(slice);
|
|
687
|
+
index = slice.end - 1;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return slices;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function safeRegExp(pattern, path, issues) {
|
|
694
|
+
try {
|
|
695
|
+
return new RegExp(pattern);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
const detail = error instanceof Error ? error.message : "Invalid regular expression";
|
|
698
|
+
issues.push(issue(path, "invalid_pattern", detail));
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function schemaName(schema, name) {
|
|
704
|
+
const candidate = typeof name === "string" && name.trim() ? name : schema.title;
|
|
705
|
+
const fallback = "structured_output";
|
|
706
|
+
return (candidate ?? fallback)
|
|
707
|
+
.toString()
|
|
708
|
+
.trim()
|
|
709
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
710
|
+
.replace(/^_+|_+$/g, "")
|
|
711
|
+
.slice(0, 64) || fallback;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function providerDescription(schema, options) {
|
|
715
|
+
if (typeof options.description === "string" && options.description.trim()) {
|
|
716
|
+
return options.description.trim();
|
|
717
|
+
}
|
|
718
|
+
if (typeof schema.description === "string" && schema.description.trim()) {
|
|
719
|
+
return schema.description.trim();
|
|
720
|
+
}
|
|
721
|
+
return `Return ${schemaName(schema, options.name)} as JSON.`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function stripJsonComments(text) {
|
|
725
|
+
let output = "";
|
|
726
|
+
let inString = false;
|
|
727
|
+
let escaped = false;
|
|
728
|
+
|
|
729
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
730
|
+
const char = text[index];
|
|
731
|
+
const next = text[index + 1];
|
|
732
|
+
|
|
733
|
+
if (escaped) {
|
|
734
|
+
output += char;
|
|
735
|
+
escaped = false;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (char === "\\") {
|
|
740
|
+
output += char;
|
|
741
|
+
escaped = inString;
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (char === "\"") {
|
|
746
|
+
output += char;
|
|
747
|
+
inString = !inString;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (!inString && char === "/" && next === "/") {
|
|
752
|
+
while (index < text.length && text[index] !== "\n") {
|
|
753
|
+
index += 1;
|
|
754
|
+
}
|
|
755
|
+
output += "\n";
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!inString && char === "/" && next === "*") {
|
|
760
|
+
index += 2;
|
|
761
|
+
while (index < text.length && !(text[index] === "*" && text[index + 1] === "/")) {
|
|
762
|
+
index += 1;
|
|
763
|
+
}
|
|
764
|
+
index += 1;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
output += char;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return output;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function firstJsonSlice(source, fromIndex = 0) {
|
|
775
|
+
for (let index = fromIndex; index < source.length; index += 1) {
|
|
379
776
|
const char = source[index];
|
|
380
777
|
if (char !== "{" && char !== "[") continue;
|
|
381
778
|
|
|
@@ -409,11 +806,15 @@ function firstJsonSlice(source) {
|
|
|
409
806
|
} else if (current === "}" || current === "]") {
|
|
410
807
|
if (current !== stack.pop()) break;
|
|
411
808
|
if (stack.length === 0) {
|
|
412
|
-
return
|
|
809
|
+
return {
|
|
810
|
+
start: index,
|
|
811
|
+
end: cursor + 1,
|
|
812
|
+
value: source.slice(index, cursor + 1)
|
|
813
|
+
};
|
|
413
814
|
}
|
|
414
815
|
}
|
|
415
816
|
}
|
|
416
817
|
}
|
|
417
818
|
|
|
418
|
-
return
|
|
819
|
+
return null;
|
|
419
820
|
}
|