@cartanova/qgrid-cli 2.5.3 → 2.5.5
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/README.ko.md +7 -2
- package/README.md +7 -2
- package/bundle/dist/application/monit/monit.frame.js +3 -3
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +36 -14
- package/bundle/dist/application/qgrid/qgrid.frame.js +19 -5
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +93 -0
- package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
- package/bundle/dist/application/request-log/request-log.model.js +3 -3
- package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
- package/bundle/dist/application/token/token.model.js +2 -2
- package/bundle/dist/server-error-handler.js +15 -0
- package/bundle/dist/sonamu.config.js +4 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +4 -3
- package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
- package/bundle/dist/utils/providers/common/json-schema-keywords.js +77 -0
- package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
- package/bundle/dist/utils/providers/common/strictifier.js +332 -42
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
- package/bundle/src/application/monit/monit.frame.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +337 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +45 -25
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +580 -8
- package/bundle/src/application/qgrid/qgrid.frame.ts +28 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +177 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
- package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
- package/bundle/src/server-error-handler.ts +22 -0
- package/bundle/src/sonamu.config.test.ts +37 -0
- package/bundle/src/sonamu.config.ts +2 -5
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +44 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +2 -2
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
- package/bundle/src/utils/providers/common/json-schema-keywords.ts +72 -0
- package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
- package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
- package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
- package/bundle/src/utils/providers/common/strictifier.ts +520 -52
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
- package/bundle/web-dist/client/assets/{index-DXdGBfB8.js → index-CN_kMswh.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D_j1yxC4.js → logs-6nYj9OKL.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CIToyoxW.js → monit-CaPH9Uwc.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Dd21A-wi.js → routes-Dh5HPi-b.js} +1 -1
- package/bundle/web-dist/client/assets/{show-DfePWRbc.js → show-Be_LVd9d.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-B4MV2fdk.js → tokens-CAi3zu_0.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +34 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -0
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +10 -1
- package/skills/qgrid/references/docs-workflow.md +9 -0
- package/skills/qgrid/references/repo-map.md +2 -1
- package/skills/qgrid/references/tool-calling-and-multiturn.md +145 -6
- package/skills/qgrid/references/verification-and-debugging.md +16 -1
|
@@ -8,8 +8,19 @@
|
|
|
8
8
|
* - recursive 처리 (nested objects, arrays, allOf/anyOf/oneOf)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import {
|
|
12
|
+
SCHEMA_ARRAY_KEYWORDS,
|
|
13
|
+
SCHEMA_MAP_KEYWORDS,
|
|
14
|
+
SCHEMA_SINGLE_KEYWORDS,
|
|
15
|
+
UNSUPPORTED_REFERENCE_KEYWORDS,
|
|
16
|
+
} from "./json-schema-keywords";
|
|
17
|
+
|
|
11
18
|
export type JsonSchema = Record<string, unknown>;
|
|
12
19
|
|
|
20
|
+
export interface StrictifierOptions {
|
|
21
|
+
provider?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export class StrictifierError extends Error {
|
|
14
25
|
constructor(
|
|
15
26
|
message: string,
|
|
@@ -20,71 +31,377 @@ export class StrictifierError extends Error {
|
|
|
20
31
|
}
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
export function strictify(schema: JsonSchema): JsonSchema {
|
|
24
|
-
|
|
34
|
+
export function strictify(schema: JsonSchema, options: StrictifierOptions = {}): JsonSchema {
|
|
35
|
+
if (options.provider === "openai" || options.provider === "anthropic") {
|
|
36
|
+
assertNormalizationCompatibility(schema, options.provider);
|
|
37
|
+
}
|
|
38
|
+
const result = strictifyNode(schema, "$", options.provider);
|
|
39
|
+
if (options.provider === "openai" || options.provider === "anthropic") {
|
|
40
|
+
assertNormalizationCompatibility(result, options.provider);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
/**
|
|
46
|
+
* 위치 목록은 json-schema-keywords 가 소유하고, 여기서는 각 위치의 `normalizable` 여부만 얹는다.
|
|
47
|
+
* normalizable=true 는 strictifyNode 가 그 위치를 실제로 재작성한다는 뜻이다. `not`/`if` 처럼
|
|
48
|
+
* 재작성하면 caller 의미가 바뀌는 위치는 false 이고, 그 아래 tuple/`$ref` 는 정규화 대신 거부된다.
|
|
49
|
+
*
|
|
50
|
+
* `items`/`additionalItems` 는 tuple 판정과 함께 다뤄야 해서 아래 순회에서 개별 처리한다.
|
|
51
|
+
*/
|
|
52
|
+
const NON_NORMALIZABLE_POSITIONS = new Set<string>([
|
|
53
|
+
"dependentSchemas",
|
|
54
|
+
"patternProperties",
|
|
55
|
+
"additionalProperties",
|
|
56
|
+
"contains",
|
|
57
|
+
"contentSchema",
|
|
58
|
+
"else",
|
|
59
|
+
"if",
|
|
60
|
+
"not",
|
|
61
|
+
"propertyNames",
|
|
62
|
+
"then",
|
|
63
|
+
"unevaluatedItems",
|
|
64
|
+
"unevaluatedProperties",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function positionEntries(keywords: readonly string[]): ReadonlyArray<readonly [string, boolean]> {
|
|
68
|
+
return keywords.map((keyword) => [keyword, !NON_NORMALIZABLE_POSITIONS.has(keyword)] as const);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const SCHEMA_SCAN_MAP_KEYWORDS = positionEntries(SCHEMA_MAP_KEYWORDS);
|
|
72
|
+
const SCHEMA_SCAN_ARRAY_KEYWORDS = positionEntries(SCHEMA_ARRAY_KEYWORDS);
|
|
73
|
+
// items/additionalItems 는 tuple 분기에서 직접 다루므로 단일 위치 스캔에서 제외한다.
|
|
74
|
+
const SCHEMA_SCAN_SINGLE_KEYWORDS = positionEntries(
|
|
75
|
+
SCHEMA_SINGLE_KEYWORDS.filter((keyword) => keyword !== "items" && keyword !== "additionalItems"),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Inspect every JSON Schema-bearing position without rewriting it.
|
|
80
|
+
*
|
|
81
|
+
* Positive positions already traversed by strictifyNode may normalize OpenAI tuples. Positions
|
|
82
|
+
* such as `not` and `if` cannot be tightened without changing caller semantics, so positional
|
|
83
|
+
* tuples there fail explicitly instead of leaking an unsupported provider schema. References
|
|
84
|
+
* from those positions also fail because their definitions are normalized globally.
|
|
85
|
+
*/
|
|
86
|
+
function assertNormalizationCompatibility(
|
|
87
|
+
schema: JsonSchema,
|
|
88
|
+
provider: "openai" | "anthropic",
|
|
89
|
+
): void {
|
|
90
|
+
const references: Array<{ value: string; path: string }> = [];
|
|
91
|
+
const stack: Array<{ node: JsonSchema; path: string; normalizable: boolean }> = [
|
|
92
|
+
{ node: schema, path: "$", normalizable: true },
|
|
93
|
+
];
|
|
29
94
|
|
|
30
|
-
|
|
95
|
+
while (stack.length > 0) {
|
|
96
|
+
const current = stack.pop()!;
|
|
97
|
+
const itemsValue = ownValue(current.node, "items");
|
|
98
|
+
const prefixItemsValue = ownValue(current.node, "prefixItems");
|
|
99
|
+
const legacyTuple = Array.isArray(itemsValue);
|
|
100
|
+
const prefixTuple = Array.isArray(prefixItemsValue);
|
|
31
101
|
|
|
32
|
-
|
|
33
|
-
|
|
102
|
+
assertUnsupportedReferenceKeywords(current.node, current.path);
|
|
103
|
+
collectSchemaReference(current, references);
|
|
104
|
+
|
|
105
|
+
if (legacyTuple || prefixTuple) {
|
|
106
|
+
const tuple = (legacyTuple ? itemsValue : prefixItemsValue) as unknown[];
|
|
107
|
+
if (provider === "anthropic" && tuple.length > 0) {
|
|
108
|
+
throw new StrictifierError(
|
|
109
|
+
"positional tuple schemas are not supported on Anthropic because Claude Code cannot preserve item positions",
|
|
110
|
+
current.path,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (!current.normalizable) {
|
|
114
|
+
throw new StrictifierError(
|
|
115
|
+
"positional tuple schemas are not supported in this schema position",
|
|
116
|
+
current.path,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (ownValue(current.node, "type") !== "array") {
|
|
120
|
+
throw new StrictifierError(
|
|
121
|
+
'positional tuple schemas must declare type "array"; express nullable tuples with anyOf',
|
|
122
|
+
`${current.path}.type`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_MAP_KEYWORDS) {
|
|
128
|
+
const schemas = ownValue(current.node, keyword);
|
|
129
|
+
if (!isSchemaObject(schemas)) continue;
|
|
130
|
+
for (const [name, child] of Object.entries(schemas)) {
|
|
131
|
+
if (!isSchemaObject(child)) continue;
|
|
132
|
+
stack.push({
|
|
133
|
+
node: child,
|
|
134
|
+
path: `${current.path}.${keyword}.${name}`,
|
|
135
|
+
normalizable: current.normalizable && keywordNormalizable,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const dependencies = ownValue(current.node, "dependencies");
|
|
141
|
+
if (isSchemaObject(dependencies)) {
|
|
142
|
+
for (const [name, child] of Object.entries(dependencies)) {
|
|
143
|
+
if (!isSchemaObject(child)) continue;
|
|
144
|
+
stack.push({
|
|
145
|
+
node: child,
|
|
146
|
+
path: `${current.path}.dependencies.${name}`,
|
|
147
|
+
normalizable: false,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_ARRAY_KEYWORDS) {
|
|
153
|
+
const schemas = ownValue(current.node, keyword);
|
|
154
|
+
if (!Array.isArray(schemas)) continue;
|
|
155
|
+
for (let index = 0; index < schemas.length; index += 1) {
|
|
156
|
+
const child = schemas[index];
|
|
157
|
+
if (!isSchemaObject(child)) continue;
|
|
158
|
+
stack.push({
|
|
159
|
+
node: child,
|
|
160
|
+
path: `${current.path}.${keyword}[${index}]`,
|
|
161
|
+
normalizable: current.normalizable && keywordNormalizable,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const items = ownValue(current.node, "items");
|
|
167
|
+
if (isSchemaObject(items)) {
|
|
168
|
+
stack.push({
|
|
169
|
+
node: items,
|
|
170
|
+
path: `${current.path}.items`,
|
|
171
|
+
normalizable: current.normalizable,
|
|
172
|
+
});
|
|
173
|
+
} else if (Array.isArray(items)) {
|
|
174
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
175
|
+
const child = items[index];
|
|
176
|
+
if (!isSchemaObject(child)) continue;
|
|
177
|
+
stack.push({
|
|
178
|
+
node: child,
|
|
179
|
+
path: `${current.path}.items[${index}]`,
|
|
180
|
+
normalizable: current.normalizable,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const additionalItems = ownValue(current.node, "additionalItems");
|
|
186
|
+
if (isSchemaObject(additionalItems)) {
|
|
187
|
+
stack.push({
|
|
188
|
+
node: additionalItems,
|
|
189
|
+
path: `${current.path}.additionalItems`,
|
|
190
|
+
normalizable: current.normalizable && legacyTuple,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [keyword, keywordNormalizable] of SCHEMA_SCAN_SINGLE_KEYWORDS) {
|
|
195
|
+
const child = ownValue(current.node, keyword);
|
|
196
|
+
if (!isSchemaObject(child)) continue;
|
|
197
|
+
stack.push({
|
|
198
|
+
node: child,
|
|
199
|
+
path: `${current.path}.${keyword}`,
|
|
200
|
+
normalizable: current.normalizable && keywordNormalizable,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
34
203
|
}
|
|
35
204
|
|
|
36
|
-
|
|
37
|
-
|
|
205
|
+
assertReferenceTargets(schema, references);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function assertUnsupportedReferenceKeywords(schema: JsonSchema, path: string): void {
|
|
209
|
+
for (const keyword of UNSUPPORTED_REFERENCE_KEYWORDS) {
|
|
210
|
+
if (Object.hasOwn(schema, keyword)) {
|
|
211
|
+
throw new StrictifierError(`${keyword} is not supported`, `${path}.${keyword}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collectSchemaReference(
|
|
217
|
+
current: { node: JsonSchema; path: string; normalizable: boolean },
|
|
218
|
+
references: Array<{ value: string; path: string }>,
|
|
219
|
+
): void {
|
|
220
|
+
if (!Object.hasOwn(current.node, "$ref")) return;
|
|
221
|
+
|
|
222
|
+
const reference = current.node.$ref;
|
|
223
|
+
if (typeof reference !== "string") {
|
|
224
|
+
throw new StrictifierError("$ref must be a string", `${current.path}.$ref`);
|
|
225
|
+
}
|
|
226
|
+
if (!current.normalizable) {
|
|
227
|
+
throw new StrictifierError(
|
|
228
|
+
"schema references are not supported in this schema position",
|
|
229
|
+
current.path,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
references.push({ value: reference, path: `${current.path}.$ref` });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function assertReferenceTargets(
|
|
237
|
+
root: JsonSchema,
|
|
238
|
+
references: Array<{ value: string; path: string }>,
|
|
239
|
+
): void {
|
|
240
|
+
for (const reference of references) {
|
|
241
|
+
const tokens = parseLocalJsonPointer(reference.value, reference.path);
|
|
242
|
+
if (!isDefinitionRootPointer(tokens)) {
|
|
243
|
+
throw new StrictifierError(
|
|
244
|
+
"$ref target must be the document root or a definition root",
|
|
245
|
+
reference.path,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
resolveDefinitionRoot(root, tokens, reference.path);
|
|
38
249
|
}
|
|
250
|
+
}
|
|
39
251
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
252
|
+
function isDefinitionRootPointer(tokens: string[]): boolean {
|
|
253
|
+
if (tokens.length === 0) return true;
|
|
254
|
+
if (tokens.length % 2 !== 0) return false;
|
|
255
|
+
|
|
256
|
+
for (let index = 0; index < tokens.length; index += 2) {
|
|
257
|
+
if (tokens[index] !== "$defs" && tokens[index] !== "definitions") return false;
|
|
45
258
|
}
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
46
261
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
262
|
+
function parseLocalJsonPointer(reference: string, path: string): string[] {
|
|
263
|
+
if (reference === "#") return [];
|
|
264
|
+
if (!reference.startsWith("#/")) {
|
|
265
|
+
throw new StrictifierError(
|
|
266
|
+
"only local root-relative JSON Pointer $ref values are supported",
|
|
267
|
+
path,
|
|
268
|
+
);
|
|
52
269
|
}
|
|
53
270
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
271
|
+
let fragment: string;
|
|
272
|
+
try {
|
|
273
|
+
fragment = decodeURIComponent(reference.slice(1));
|
|
274
|
+
} catch {
|
|
275
|
+
throw new StrictifierError("$ref contains invalid percent encoding", path);
|
|
59
276
|
}
|
|
277
|
+
if (!fragment.startsWith("/")) {
|
|
278
|
+
throw new StrictifierError(
|
|
279
|
+
"only local root-relative JSON Pointer $ref values are supported",
|
|
280
|
+
path,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return fragment
|
|
285
|
+
.slice(1)
|
|
286
|
+
.split("/")
|
|
287
|
+
.map((token) => decodeJsonPointerToken(token, path));
|
|
288
|
+
}
|
|
60
289
|
|
|
61
|
-
|
|
62
|
-
|
|
290
|
+
function resolveDefinitionRoot(root: JsonSchema, tokens: string[], path: string): void {
|
|
291
|
+
let current = root;
|
|
292
|
+
for (let index = 0; index < tokens.length; index += 2) {
|
|
293
|
+
const keyword = tokens[index]!;
|
|
294
|
+
const name = tokens[index + 1]!;
|
|
295
|
+
const definitions = ownValue(current, keyword);
|
|
296
|
+
if (!isSchemaObject(definitions) || !Object.hasOwn(definitions, name)) {
|
|
297
|
+
throw new StrictifierError("$ref does not resolve inside the caller schema", path);
|
|
298
|
+
}
|
|
299
|
+
const target = definitions[name];
|
|
300
|
+
if (!isSchemaObject(target)) {
|
|
301
|
+
throw new StrictifierError("$ref target must be an object schema", path);
|
|
302
|
+
}
|
|
303
|
+
current = target;
|
|
63
304
|
}
|
|
305
|
+
}
|
|
64
306
|
|
|
65
|
-
|
|
307
|
+
function decodeJsonPointerToken(rawToken: string, path: string): string {
|
|
308
|
+
let token = "";
|
|
309
|
+
for (let index = 0; index < rawToken.length; index += 1) {
|
|
310
|
+
const character = rawToken[index]!;
|
|
311
|
+
if (character !== "~") {
|
|
312
|
+
token += character;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const escaped = rawToken[index + 1];
|
|
317
|
+
if (escaped === "0") {
|
|
318
|
+
token += "~";
|
|
319
|
+
} else if (escaped === "1") {
|
|
320
|
+
token += "/";
|
|
321
|
+
} else {
|
|
322
|
+
throw new StrictifierError("$ref contains an invalid JSON Pointer escape", path);
|
|
323
|
+
}
|
|
324
|
+
index += 1;
|
|
325
|
+
}
|
|
326
|
+
return token;
|
|
66
327
|
}
|
|
67
328
|
|
|
68
|
-
function
|
|
69
|
-
|
|
329
|
+
function strictifyNode(node: JsonSchema, path: string, provider?: string): JsonSchema {
|
|
330
|
+
if (typeof node !== "object" || node === null) return node;
|
|
331
|
+
|
|
332
|
+
const dialectNeutralNode = provider === "openai" ? omitKeys(node, ["$schema"]) : node;
|
|
333
|
+
let result = strictifySchemaMaps(dialectNeutralNode, path, provider);
|
|
334
|
+
const type = ownValue(result, "type") as string | undefined;
|
|
335
|
+
|
|
336
|
+
if (type === "object" || Object.hasOwn(result, "properties")) {
|
|
337
|
+
result = strictifyObject(result, path, provider);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (type === "array" || Object.hasOwn(result, "items") || Object.hasOwn(result, "prefixItems")) {
|
|
341
|
+
result = strictifyArray(result, path, provider);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return strictifySchemaArrays(result, path, provider);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 재작성 패스는 정의 맵만 재귀한다. properties 는 strictifyObject 가 별도로 처리하므로
|
|
348
|
+
// 공유 위치 목록(SCHEMA_MAP_KEYWORDS)과 의도적으로 범위가 다르다.
|
|
349
|
+
const DEFINITION_MAP_KEYWORDS = ["$defs", "definitions"] as const;
|
|
350
|
+
|
|
351
|
+
function strictifySchemaMaps(node: JsonSchema, path: string, provider?: string): JsonSchema {
|
|
352
|
+
const result = { ...node };
|
|
353
|
+
|
|
354
|
+
for (const keyword of DEFINITION_MAP_KEYWORDS) {
|
|
355
|
+
const schemas = ownValue(node, keyword);
|
|
356
|
+
if (!isSchemaObject(schemas)) continue;
|
|
357
|
+
|
|
358
|
+
result[keyword] = Object.fromEntries(
|
|
359
|
+
Object.entries(schemas).map(([name, schema]) => [
|
|
360
|
+
name,
|
|
361
|
+
isSchemaObject(schema)
|
|
362
|
+
? strictifyNode(schema, `${path}.${keyword}.${name}`, provider)
|
|
363
|
+
: schema,
|
|
364
|
+
]),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// prefixItems 는 strictifyArray 의 tuple 경로가 다루므로 여기서는 조합 keyword 만 재귀한다.
|
|
372
|
+
const COMBINATOR_KEYWORDS = ["anyOf", "oneOf", "allOf"] as const;
|
|
373
|
+
|
|
374
|
+
function strictifySchemaArrays(node: JsonSchema, path: string, provider?: string): JsonSchema {
|
|
375
|
+
const result = { ...node };
|
|
376
|
+
|
|
377
|
+
for (const keyword of COMBINATOR_KEYWORDS) {
|
|
378
|
+
const schemas = ownValue(node, keyword);
|
|
379
|
+
if (!schemas) continue;
|
|
380
|
+
result[keyword] = (schemas as JsonSchema[]).map((schema, index) =>
|
|
381
|
+
strictifyNode(schema, `${path}.${keyword}[${index}]`, provider),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function strictifyObject(node: JsonSchema, path: string, provider?: string): JsonSchema {
|
|
389
|
+
const properties = ownValue(node, "properties") as Record<string, JsonSchema> | undefined;
|
|
70
390
|
if (!properties) {
|
|
71
391
|
return { ...node, additionalProperties: false };
|
|
72
392
|
}
|
|
73
393
|
|
|
74
|
-
const existingRequired = new Set((node
|
|
394
|
+
const existingRequired = new Set((ownValue(node, "required") as string[] | undefined) ?? []);
|
|
75
395
|
const allKeys = Object.keys(properties);
|
|
76
396
|
|
|
77
|
-
const strictProps
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
397
|
+
const strictProps = Object.fromEntries(
|
|
398
|
+
allKeys.map((key) => {
|
|
399
|
+
const prop = properties[key]!;
|
|
400
|
+
const strictProp = strictifyNode(prop, `${path}.${key}`, provider);
|
|
81
401
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
strictProps[key] = strictProp;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
402
|
+
return [key, existingRequired.has(key) ? strictProp : makeNullable(strictProp)];
|
|
403
|
+
}),
|
|
404
|
+
) as Record<string, JsonSchema>;
|
|
88
405
|
|
|
89
406
|
const result: JsonSchema = {
|
|
90
407
|
...node,
|
|
@@ -97,29 +414,180 @@ function strictifyObject(node: JsonSchema, path: string): JsonSchema {
|
|
|
97
414
|
return result;
|
|
98
415
|
}
|
|
99
416
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
417
|
+
const UNREACHABLE_TUPLE_TAIL_SCHEMA: JsonSchema = { type: "string" };
|
|
418
|
+
|
|
419
|
+
function strictifyArray(node: JsonSchema, path: string, provider?: string): JsonSchema {
|
|
420
|
+
if (
|
|
421
|
+
(provider === "openai" || provider === "anthropic") &&
|
|
422
|
+
(Array.isArray(ownValue(node, "items")) || Array.isArray(ownValue(node, "prefixItems")))
|
|
423
|
+
) {
|
|
424
|
+
return normalizeTuple(node, path, provider);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const result = { ...node };
|
|
428
|
+
const items = ownValue(node, "items") as JsonSchema | JsonSchema[] | undefined;
|
|
429
|
+
|
|
430
|
+
if (Array.isArray(items)) {
|
|
431
|
+
result.items = items.map((schema, index) =>
|
|
432
|
+
strictifyNode(schema, `${path}.items[${index}]`, provider),
|
|
433
|
+
);
|
|
434
|
+
} else if (items !== undefined) {
|
|
435
|
+
result.items = strictifyNode(items, `${path}.items`, provider);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const prefixItems = ownValue(node, "prefixItems") as JsonSchema[] | undefined;
|
|
439
|
+
if (Array.isArray(prefixItems)) {
|
|
440
|
+
result.prefixItems = prefixItems.map((schema, index) =>
|
|
441
|
+
strictifyNode(schema, `${path}.prefixItems[${index}]`, provider),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function normalizeTuple(
|
|
449
|
+
node: JsonSchema,
|
|
450
|
+
path: string,
|
|
451
|
+
provider: "openai" | "anthropic",
|
|
452
|
+
): JsonSchema {
|
|
453
|
+
const items = ownValue(node, "items");
|
|
454
|
+
const prefixItems = ownValue(node, "prefixItems");
|
|
455
|
+
const legacyTuple = Array.isArray(items);
|
|
456
|
+
const prefixTuple = Array.isArray(prefixItems);
|
|
457
|
+
if (legacyTuple && prefixTuple) {
|
|
458
|
+
throw new StrictifierError("items and prefixItems tuple forms cannot be combined", path);
|
|
459
|
+
}
|
|
460
|
+
if (prefixTuple && Object.hasOwn(node, "additionalItems")) {
|
|
461
|
+
throw new StrictifierError("additionalItems cannot be combined with prefixItems", path);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const rawTuple = (legacyTuple ? items : prefixItems) as unknown[];
|
|
465
|
+
const tuplePath = legacyTuple ? `${path}.items` : `${path}.prefixItems`;
|
|
466
|
+
const tuple = rawTuple.map((schema, index) => {
|
|
467
|
+
if (!isSchemaObject(schema)) {
|
|
468
|
+
throw new StrictifierError(
|
|
469
|
+
"boolean and non-object tuple member schemas are not supported",
|
|
470
|
+
`${tuplePath}[${index}]`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const memberPath = `${tuplePath}[${index}]`;
|
|
474
|
+
const member = strictifyNode(schema, memberPath, provider);
|
|
475
|
+
if (!hasProviderSchemaEntryPoint(member)) {
|
|
476
|
+
throw new StrictifierError(
|
|
477
|
+
"tuple member must define a provider-representable schema",
|
|
478
|
+
memberPath,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return member;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const rawTail = legacyTuple ? ownValue(node, "additionalItems") : items;
|
|
485
|
+
if (rawTail === true) {
|
|
486
|
+
throw new StrictifierError("an unrestricted tuple rest is not supported", path);
|
|
487
|
+
}
|
|
488
|
+
if (rawTail !== undefined && rawTail !== false && !isSchemaObject(rawTail)) {
|
|
489
|
+
throw new StrictifierError("tuple rest must be an object schema or false", path);
|
|
490
|
+
}
|
|
103
491
|
|
|
492
|
+
const tail = isSchemaObject(rawTail)
|
|
493
|
+
? strictifyNode(rawTail, legacyTuple ? `${path}.additionalItems` : `${path}.items`, provider)
|
|
494
|
+
: undefined;
|
|
495
|
+
if (tail !== undefined && !hasProviderSchemaEntryPoint(tail)) {
|
|
496
|
+
throw new StrictifierError("an unrestricted tuple rest is not supported", path);
|
|
497
|
+
}
|
|
498
|
+
const tailMode = tail !== undefined ? "typed" : rawTail === false ? "closed" : "implicit";
|
|
499
|
+
const bounds = normalizeTupleBounds(node, tuple.length, tailMode, path);
|
|
500
|
+
const base = omitKeys(node, ["items", "prefixItems", "additionalItems", "minItems", "maxItems"]);
|
|
501
|
+
|
|
502
|
+
// Anthropic 은 assertNormalizationCompatibility 가 비어 있지 않은 tuple 을 이미 거부했으므로
|
|
503
|
+
// 여기 도달하는 tuple 은 빈 tuple 뿐이고, 그 경우 prefixItems spread 는 자연히 생략된다 —
|
|
504
|
+
// provider 분기 없이 한 형태로 두 provider 모두를 표현한다.
|
|
104
505
|
return {
|
|
105
|
-
...
|
|
106
|
-
|
|
506
|
+
...base,
|
|
507
|
+
...(tuple.length > 0 ? { prefixItems: tuple } : {}),
|
|
508
|
+
items: tail ?? UNREACHABLE_TUPLE_TAIL_SCHEMA,
|
|
509
|
+
...bounds,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function normalizeTupleBounds(
|
|
514
|
+
node: JsonSchema,
|
|
515
|
+
tupleLength: number,
|
|
516
|
+
tailMode: "typed" | "closed" | "implicit",
|
|
517
|
+
path: string,
|
|
518
|
+
): { minItems: number; maxItems?: number } {
|
|
519
|
+
const callerMinItems = integerBound(ownValue(node, "minItems"), "minItems", path);
|
|
520
|
+
const callerMaxItems = integerBound(ownValue(node, "maxItems"), "maxItems", path);
|
|
521
|
+
if (tailMode === "implicit" && callerMaxItems !== undefined && callerMaxItems > tupleLength) {
|
|
522
|
+
throw new StrictifierError(
|
|
523
|
+
"implicit tuple rest cannot be represented when maxItems exceeds the tuple length",
|
|
524
|
+
path,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const minItems = callerMinItems ?? (callerMaxItems === undefined ? tupleLength : 0);
|
|
529
|
+
const maxItems =
|
|
530
|
+
tailMode === "typed" ? callerMaxItems : Math.min(callerMaxItems ?? tupleLength, tupleLength);
|
|
531
|
+
|
|
532
|
+
if (maxItems !== undefined && minItems > maxItems) {
|
|
533
|
+
throw new StrictifierError("tuple minItems cannot exceed maxItems", path);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
minItems,
|
|
538
|
+
...(maxItems !== undefined ? { maxItems } : {}),
|
|
107
539
|
};
|
|
108
540
|
}
|
|
109
541
|
|
|
542
|
+
function integerBound(value: unknown, keyword: string, path: string): number | undefined {
|
|
543
|
+
if (value === undefined) return undefined;
|
|
544
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
545
|
+
throw new StrictifierError(`${keyword} must be a non-negative safe integer`, path);
|
|
546
|
+
}
|
|
547
|
+
return value as number;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function isSchemaObject(value: unknown): value is JsonSchema {
|
|
551
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function ownValue(schema: JsonSchema, keyword: string): unknown {
|
|
555
|
+
return Object.hasOwn(schema, keyword) ? schema[keyword] : undefined;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const PROVIDER_SCHEMA_ENTRY_POINT_KEYWORDS = new Set([
|
|
559
|
+
"type",
|
|
560
|
+
"$ref",
|
|
561
|
+
"properties",
|
|
562
|
+
"anyOf",
|
|
563
|
+
"oneOf",
|
|
564
|
+
"allOf",
|
|
565
|
+
]);
|
|
566
|
+
|
|
567
|
+
function hasProviderSchemaEntryPoint(schema: JsonSchema): boolean {
|
|
568
|
+
return Object.keys(schema).some((key) => PROVIDER_SCHEMA_ENTRY_POINT_KEYWORDS.has(key));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function omitKeys(node: JsonSchema, keys: readonly string[]): JsonSchema {
|
|
572
|
+
const omitted = new Set(keys);
|
|
573
|
+
return Object.fromEntries(Object.entries(node).filter(([key]) => !omitted.has(key)));
|
|
574
|
+
}
|
|
575
|
+
|
|
110
576
|
function makeNullable(schema: JsonSchema): JsonSchema {
|
|
111
|
-
if (schema
|
|
577
|
+
if (ownValue(schema, "type") === "null") return schema;
|
|
112
578
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
579
|
+
const anyOf = ownValue(schema, "anyOf");
|
|
580
|
+
if (anyOf) {
|
|
581
|
+
const variants = anyOf as JsonSchema[];
|
|
582
|
+
const hasNull = variants.some((v) => ownValue(v, "type") === "null");
|
|
116
583
|
if (hasNull) return schema;
|
|
117
584
|
return { anyOf: [...variants, { type: "null" }] };
|
|
118
585
|
}
|
|
119
586
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
587
|
+
const oneOf = ownValue(schema, "oneOf");
|
|
588
|
+
if (oneOf) {
|
|
589
|
+
const variants = oneOf as JsonSchema[];
|
|
590
|
+
const hasNull = variants.some((v) => ownValue(v, "type") === "null");
|
|
123
591
|
if (hasNull) return schema;
|
|
124
592
|
return { oneOf: [...variants, { type: "null" }] };
|
|
125
593
|
}
|
|
@@ -164,6 +164,37 @@ describe("CodexAppServerWorker active turn cleanup", () => {
|
|
|
164
164
|
expect.objectContaining({ message: "codex worker exited while turn was running" }),
|
|
165
165
|
);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
it("rejects the stream promise when the completion callback throws", async () => {
|
|
169
|
+
const { worker, handlers } = createWorkerWithFakeRpc();
|
|
170
|
+
const callbacks: StreamCallbacks = {
|
|
171
|
+
onDelta: vi.fn(),
|
|
172
|
+
onComplete: () => {
|
|
173
|
+
throw new Error("completion mapping failed");
|
|
174
|
+
},
|
|
175
|
+
onError: vi.fn(),
|
|
176
|
+
};
|
|
177
|
+
const promise = (
|
|
178
|
+
worker as unknown as {
|
|
179
|
+
consumeStreamNotifications(
|
|
180
|
+
threadId: string,
|
|
181
|
+
model: string,
|
|
182
|
+
cb: StreamCallbacks,
|
|
183
|
+
): Promise<void>;
|
|
184
|
+
}
|
|
185
|
+
).consumeStreamNotifications("thread-1", "gpt-test", callbacks);
|
|
186
|
+
|
|
187
|
+
handlers.get("turn/completed")?.({
|
|
188
|
+
threadId: "thread-1",
|
|
189
|
+
turn: { status: "completed", durationMs: 90 },
|
|
190
|
+
} as never);
|
|
191
|
+
|
|
192
|
+
await expect(promise).rejects.toThrow("completion mapping failed");
|
|
193
|
+
expect(callbacks.onError).not.toHaveBeenCalled();
|
|
194
|
+
expect(
|
|
195
|
+
(worker as unknown as { activeTurnAbort?: (error: Error) => void }).activeTurnAbort,
|
|
196
|
+
).toBeUndefined();
|
|
197
|
+
});
|
|
167
198
|
});
|
|
168
199
|
|
|
169
200
|
describe("CodexAppServerWorker TTFT", () => {
|