@cartanova/qgrid-cli 2.5.2 → 2.5.4
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 +41 -15
- package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -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 +5 -4
- package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
- package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
- package/bundle/dist/utils/providers/common/strictifier.js +329 -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 +358 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
- package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
- package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -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 +56 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
- 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/anthropic/stream-json-adapter.test.ts +66 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -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 +513 -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-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-gEGH52O8.js → tokens-Ct0FGD4b.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 +42 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +11 -1
- package/skills/qgrid/references/repo-map.md +2 -1
- package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
- package/skills/qgrid/references/verification-and-debugging.md +16 -1
|
@@ -233,6 +233,109 @@ describe("qgrid run lifecycle TTFT", () => {
|
|
|
233
233
|
);
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
it("persists an immediate structured final answer as JSON text", async () => {
|
|
237
|
+
await afterQuery(
|
|
238
|
+
10,
|
|
239
|
+
0,
|
|
240
|
+
{
|
|
241
|
+
prompt: "answer",
|
|
242
|
+
tools: [{ name: "lookup", inputSchema: { type: "object" } }],
|
|
243
|
+
jsonSchema: JSON.stringify({
|
|
244
|
+
type: "object",
|
|
245
|
+
properties: { result: { type: "string" } },
|
|
246
|
+
}),
|
|
247
|
+
},
|
|
248
|
+
queryOutput({
|
|
249
|
+
text: '{"result":"ok"}',
|
|
250
|
+
content: [{ type: "text", text: '{"result":"ok"}' }],
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(appendStepMock).toHaveBeenCalledWith(
|
|
255
|
+
10,
|
|
256
|
+
expect.objectContaining({
|
|
257
|
+
step_index: 0,
|
|
258
|
+
type: "generate",
|
|
259
|
+
finish_reason: "stop",
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
expect(finishRunMock).toHaveBeenCalledWith(
|
|
263
|
+
10,
|
|
264
|
+
expect.objectContaining({
|
|
265
|
+
status: "succeeded",
|
|
266
|
+
response: '{"result":"ok"}',
|
|
267
|
+
}),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("persists a tool turn and a structured final answer on the continued run", async () => {
|
|
272
|
+
const schema = JSON.stringify({
|
|
273
|
+
type: "object",
|
|
274
|
+
properties: { result: { type: "string" } },
|
|
275
|
+
});
|
|
276
|
+
const first = await afterQuery(
|
|
277
|
+
10,
|
|
278
|
+
0,
|
|
279
|
+
{ prompt: "lookup", tools: [{ name: "lookup", inputSchema: {} }], jsonSchema: schema },
|
|
280
|
+
queryOutput({
|
|
281
|
+
text: "",
|
|
282
|
+
content: [
|
|
283
|
+
{
|
|
284
|
+
type: "tool-call",
|
|
285
|
+
toolCallId: "call-1",
|
|
286
|
+
toolName: "lookup",
|
|
287
|
+
input: '{"key":"value"}',
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
finishReason: "tool-calls",
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(first).toEqual({ runContext: { requestLogId: 10 } });
|
|
295
|
+
expect(finishRunMock).not.toHaveBeenCalled();
|
|
296
|
+
expect(appendStepMock).toHaveBeenCalledWith(
|
|
297
|
+
10,
|
|
298
|
+
expect.objectContaining({
|
|
299
|
+
step_index: 0,
|
|
300
|
+
type: "tool_call",
|
|
301
|
+
tool_call_id: "call-1",
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
await afterQuery(
|
|
306
|
+
10,
|
|
307
|
+
2,
|
|
308
|
+
{
|
|
309
|
+
prompt: "continue",
|
|
310
|
+
tools: [{ name: "lookup", inputSchema: {} }],
|
|
311
|
+
jsonSchema: schema,
|
|
312
|
+
runContext: { requestLogId: 10 },
|
|
313
|
+
toolResults: [{ toolCallId: "call-1", output: '{"value":"found"}' }],
|
|
314
|
+
},
|
|
315
|
+
queryOutput({
|
|
316
|
+
text: '{"result":"found"}',
|
|
317
|
+
content: [{ type: "text", text: '{"result":"found"}' }],
|
|
318
|
+
finishReason: "stop",
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(appendStepMock).toHaveBeenCalledWith(
|
|
323
|
+
10,
|
|
324
|
+
expect.objectContaining({
|
|
325
|
+
step_index: 2,
|
|
326
|
+
type: "generate",
|
|
327
|
+
finish_reason: "stop",
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
expect(finishRunMock).toHaveBeenCalledWith(
|
|
331
|
+
10,
|
|
332
|
+
expect.objectContaining({
|
|
333
|
+
status: "succeeded",
|
|
334
|
+
response: '{"result":"found"}',
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
236
339
|
it("finishes runs with image data-url img tags in response", async () => {
|
|
237
340
|
await afterQuery(
|
|
238
341
|
10,
|
|
@@ -25,7 +25,32 @@ function providerResult(overrides: Partial<GenerateResult> = {}): GenerateResult
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function deeplyNestedOutputSchema(depth: number): string {
|
|
29
|
+
const nestedArray = `${'{"type":"array","items":'.repeat(depth)}{"type":"string"}${"}".repeat(depth)}`;
|
|
30
|
+
return `{"type":"object","properties":{"value":${nestedArray}}}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
describe("QgridDispatcherClass", () => {
|
|
34
|
+
const toolsAndSchema = {
|
|
35
|
+
tools: [
|
|
36
|
+
{
|
|
37
|
+
name: "lookup",
|
|
38
|
+
description: "Look up a value",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: { key: { type: "string" } },
|
|
42
|
+
required: ["key"],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
jsonSchema: JSON.stringify({
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
result: { type: "string" },
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
|
|
29
54
|
it("AnthropicDispatcher 미초기화 시 query 는 폴백 없이 실패한다", async () => {
|
|
30
55
|
const dispatcher = new QgridDispatcherClass();
|
|
31
56
|
|
|
@@ -67,7 +92,7 @@ describe("QgridDispatcherClass", () => {
|
|
|
67
92
|
const abortSignal = new AbortController().signal;
|
|
68
93
|
|
|
69
94
|
await dispatcher.queryStream(
|
|
70
|
-
{ prompt: "hi", model: "anthropic/claude-sonnet-4-6" },
|
|
95
|
+
{ prompt: "hi", model: "anthropic/claude-sonnet-4-6", timeout: 360_000 },
|
|
71
96
|
{
|
|
72
97
|
onDelta: vi.fn(),
|
|
73
98
|
onComplete: vi.fn(),
|
|
@@ -77,6 +102,26 @@ describe("QgridDispatcherClass", () => {
|
|
|
77
102
|
);
|
|
78
103
|
|
|
79
104
|
expect(generateStream.mock.calls[0]![0].abortSignal).toBe(abortSignal);
|
|
105
|
+
expect(generateStream.mock.calls[0]![0].timeoutMs).toBe(360_000);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("Anthropic query 는 timeoutMs와 abortSignal을 provider request로 전달한다", async () => {
|
|
109
|
+
const dispatcher = new QgridDispatcherClass();
|
|
110
|
+
const generate = vi.fn(async (_req: GenerateRequest) =>
|
|
111
|
+
providerResult({ model: "claude-opus-5" }),
|
|
112
|
+
);
|
|
113
|
+
dispatcher.anthropicDispatcher = { generate } as never;
|
|
114
|
+
const abortSignal = new AbortController().signal;
|
|
115
|
+
|
|
116
|
+
await dispatcher.query(
|
|
117
|
+
{ prompt: "hi", model: "anthropic/claude-opus-5", timeout: 360_000 },
|
|
118
|
+
abortSignal,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(generate.mock.calls[0]![0]).toMatchObject({
|
|
122
|
+
timeoutMs: 360_000,
|
|
123
|
+
abortSignal,
|
|
124
|
+
});
|
|
80
125
|
});
|
|
81
126
|
|
|
82
127
|
it("Anthropic queryStream provider error 는 상위 onError 로 전달한다", async () => {
|
|
@@ -262,6 +307,68 @@ describe("QgridDispatcherClass", () => {
|
|
|
262
307
|
expect(contentsArray?.items?.additionalProperties).toBe(false);
|
|
263
308
|
});
|
|
264
309
|
|
|
310
|
+
it("tools + schema composition preserves prototype-sensitive keys through serialization", () => {
|
|
311
|
+
const propertyNames = ["__proto__", "constructor", "prototype"];
|
|
312
|
+
const properties = JSON.parse(
|
|
313
|
+
'{"__proto__":{"type":"string"},"constructor":{"type":"integer"},"prototype":{"type":"boolean"}}',
|
|
314
|
+
) as Record<string, unknown>;
|
|
315
|
+
const schema = buildStrictOutputSchema({
|
|
316
|
+
tools: toolsAndSchema.tools,
|
|
317
|
+
jsonSchema: JSON.stringify({
|
|
318
|
+
type: "object",
|
|
319
|
+
properties,
|
|
320
|
+
required: propertyNames,
|
|
321
|
+
}),
|
|
322
|
+
}) as {
|
|
323
|
+
$defs?: {
|
|
324
|
+
__qgrid_user_output?: {
|
|
325
|
+
properties?: Record<string, unknown>;
|
|
326
|
+
required?: string[];
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
const strictUserSchema = schema.$defs?.__qgrid_user_output;
|
|
331
|
+
const strictProperties = strictUserSchema?.properties;
|
|
332
|
+
|
|
333
|
+
expect(strictUserSchema?.required).toEqual(propertyNames);
|
|
334
|
+
for (const propertyName of propertyNames) {
|
|
335
|
+
expect(Object.prototype.hasOwnProperty.call(strictProperties, propertyName)).toBe(true);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const serialized = JSON.stringify(schema);
|
|
339
|
+
expect(serialized).toContain('"__proto__":{"type":"string"}');
|
|
340
|
+
expect(serialized).toContain('"constructor":{"type":"integer"}');
|
|
341
|
+
expect(serialized).toContain('"prototype":{"type":"boolean"}');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it.each([
|
|
345
|
+
["schema-only", undefined, deeplyNestedOutputSchema(5_000), "jsonSchema"],
|
|
346
|
+
["tools + schema", toolsAndSchema.tools, deeplyNestedOutputSchema(5_000), "jsonSchema"],
|
|
347
|
+
[
|
|
348
|
+
"tools-only",
|
|
349
|
+
[
|
|
350
|
+
{
|
|
351
|
+
name: "deepTool",
|
|
352
|
+
inputSchema: JSON.parse(deeplyNestedOutputSchema(5_000)) as unknown,
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
undefined,
|
|
356
|
+
"tools[0].inputSchema",
|
|
357
|
+
],
|
|
358
|
+
])(
|
|
359
|
+
"%s 경로에서 재귀 변환 전에 과도하게 깊은 caller schema를 거부한다",
|
|
360
|
+
(_label, tools, jsonSchema, errorPath) => {
|
|
361
|
+
expect(() => buildStrictOutputSchema({ jsonSchema, tools })).toThrow(
|
|
362
|
+
`qgrid: ${errorPath} exceeds depth limit`,
|
|
363
|
+
);
|
|
364
|
+
try {
|
|
365
|
+
buildStrictOutputSchema({ jsonSchema, tools });
|
|
366
|
+
} catch (error) {
|
|
367
|
+
expect(error).not.toBeInstanceOf(RangeError);
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
);
|
|
371
|
+
|
|
265
372
|
// SON-495: anthropic route 도 strict(required 유지)를 쓴다 — required 를 살려야 모델이 필드를
|
|
266
373
|
// 빠짐없이 채운다(실측 확정). optionalize 는 제거됨.
|
|
267
374
|
it("Anthropic route 에 strict(required 유지) outputSchema 를 전달한다", async () => {
|
|
@@ -341,6 +448,256 @@ describe("QgridDispatcherClass", () => {
|
|
|
341
448
|
expect(outputSchema.additionalProperties).toBe(false);
|
|
342
449
|
});
|
|
343
450
|
|
|
451
|
+
it("tools + jsonSchema 를 합성하고 user $defs까지 strictify 한다", () => {
|
|
452
|
+
const schema = buildStrictOutputSchema({
|
|
453
|
+
tools: toolsAndSchema.tools,
|
|
454
|
+
jsonSchema: JSON.stringify({
|
|
455
|
+
type: "object",
|
|
456
|
+
properties: { item: { $ref: "#/$defs/Item" } },
|
|
457
|
+
$defs: {
|
|
458
|
+
Item: {
|
|
459
|
+
type: "object",
|
|
460
|
+
properties: {
|
|
461
|
+
value: { type: "string" },
|
|
462
|
+
optionalNote: { type: "string" },
|
|
463
|
+
},
|
|
464
|
+
required: ["value"],
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
}),
|
|
468
|
+
}) as {
|
|
469
|
+
properties?: { answer?: { anyOf?: Array<{ $ref?: string }> } };
|
|
470
|
+
$defs?: {
|
|
471
|
+
__qgrid_user_output?: {
|
|
472
|
+
required?: string[];
|
|
473
|
+
additionalProperties?: boolean;
|
|
474
|
+
properties?: { item?: { anyOf?: Array<{ $ref?: string }> } };
|
|
475
|
+
$defs?: {
|
|
476
|
+
Item?: {
|
|
477
|
+
required?: string[];
|
|
478
|
+
additionalProperties?: boolean;
|
|
479
|
+
properties?: { optionalNote?: { anyOf?: unknown[] } };
|
|
480
|
+
};
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
const userSchema = schema.$defs?.__qgrid_user_output;
|
|
486
|
+
|
|
487
|
+
expect(schema.properties?.answer?.anyOf?.[0]?.$ref).toBe(
|
|
488
|
+
"#/$defs/__qgrid_user_output",
|
|
489
|
+
);
|
|
490
|
+
expect(userSchema).toMatchObject({
|
|
491
|
+
required: ["item"],
|
|
492
|
+
additionalProperties: false,
|
|
493
|
+
properties: {
|
|
494
|
+
item: {
|
|
495
|
+
anyOf: [
|
|
496
|
+
{ $ref: "#/$defs/__qgrid_user_output/$defs/Item" },
|
|
497
|
+
{ type: "null" },
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
expect(userSchema?.$defs?.Item).toMatchObject({
|
|
503
|
+
required: ["value", "optionalNote"],
|
|
504
|
+
additionalProperties: false,
|
|
505
|
+
});
|
|
506
|
+
expect(userSchema?.$defs?.Item?.properties?.optionalNote?.anyOf).toEqual([
|
|
507
|
+
{ type: "string" },
|
|
508
|
+
{ type: "null" },
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("tools + jsonSchema 합성 경로에서 prefixItems tuple 구성원도 strictify 한다", () => {
|
|
513
|
+
const schema = buildStrictOutputSchema({
|
|
514
|
+
tools: toolsAndSchema.tools,
|
|
515
|
+
jsonSchema: JSON.stringify({
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: {
|
|
518
|
+
tuple: {
|
|
519
|
+
type: "array",
|
|
520
|
+
prefixItems: [
|
|
521
|
+
{
|
|
522
|
+
type: "object",
|
|
523
|
+
properties: { label: { type: "string" } },
|
|
524
|
+
required: ["label"],
|
|
525
|
+
},
|
|
526
|
+
{ type: "integer" },
|
|
527
|
+
],
|
|
528
|
+
minItems: 2,
|
|
529
|
+
maxItems: 2,
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
required: ["tuple"],
|
|
533
|
+
}),
|
|
534
|
+
}) as {
|
|
535
|
+
$defs?: {
|
|
536
|
+
__qgrid_user_output?: {
|
|
537
|
+
properties?: {
|
|
538
|
+
tuple?: {
|
|
539
|
+
prefixItems?: Array<{
|
|
540
|
+
required?: string[];
|
|
541
|
+
additionalProperties?: boolean;
|
|
542
|
+
}>;
|
|
543
|
+
};
|
|
544
|
+
};
|
|
545
|
+
};
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
expect(
|
|
550
|
+
schema.$defs?.__qgrid_user_output?.properties?.tuple?.prefixItems?.[0],
|
|
551
|
+
).toMatchObject({
|
|
552
|
+
required: ["label"],
|
|
553
|
+
additionalProperties: false,
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("OpenAI tools + draft-07 tuple schema를 위치 제약을 보존해 정규화한다", () => {
|
|
558
|
+
const schema = buildStrictOutputSchema(
|
|
559
|
+
{
|
|
560
|
+
tools: toolsAndSchema.tools,
|
|
561
|
+
jsonSchema: JSON.stringify({
|
|
562
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
563
|
+
type: "object",
|
|
564
|
+
properties: {
|
|
565
|
+
tuple: {
|
|
566
|
+
type: "array",
|
|
567
|
+
items: [
|
|
568
|
+
{
|
|
569
|
+
type: "object",
|
|
570
|
+
properties: { label: { type: "string" } },
|
|
571
|
+
},
|
|
572
|
+
{ type: "integer" },
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
required: ["tuple"],
|
|
577
|
+
}),
|
|
578
|
+
},
|
|
579
|
+
"openai",
|
|
580
|
+
) as {
|
|
581
|
+
$defs?: {
|
|
582
|
+
__qgrid_user_output?: {
|
|
583
|
+
properties?: {
|
|
584
|
+
tuple?: {
|
|
585
|
+
items?: unknown;
|
|
586
|
+
prefixItems?: unknown[];
|
|
587
|
+
minItems?: number;
|
|
588
|
+
maxItems?: number;
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
const tuple = schema.$defs?.__qgrid_user_output?.properties?.tuple;
|
|
595
|
+
|
|
596
|
+
expect(tuple).toMatchObject({
|
|
597
|
+
minItems: 2,
|
|
598
|
+
maxItems: 2,
|
|
599
|
+
});
|
|
600
|
+
expect(Array.isArray(tuple?.items)).toBe(false);
|
|
601
|
+
expect(tuple?.prefixItems).toHaveLength(2);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("Anthropic tools + positional tuple schema는 의미를 약화하지 않고 거부한다", () => {
|
|
605
|
+
expect(() =>
|
|
606
|
+
buildStrictOutputSchema(
|
|
607
|
+
{
|
|
608
|
+
tools: toolsAndSchema.tools,
|
|
609
|
+
jsonSchema: JSON.stringify({
|
|
610
|
+
type: "object",
|
|
611
|
+
properties: {
|
|
612
|
+
tuple: {
|
|
613
|
+
type: "array",
|
|
614
|
+
items: [{ type: "string" }, { type: "integer" }],
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
required: ["tuple"],
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
"anthropic",
|
|
621
|
+
),
|
|
622
|
+
).toThrow(/positional tuple schemas are not supported on Anthropic/);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it.each([
|
|
626
|
+
["openai", "query"],
|
|
627
|
+
["openai", "queryStream"],
|
|
628
|
+
["anthropic", "query"],
|
|
629
|
+
["anthropic", "queryStream"],
|
|
630
|
+
] as const)(
|
|
631
|
+
"%s %s 모두 tools + jsonSchema composed outputSchema를 provider에 전달한다",
|
|
632
|
+
async (provider, method) => {
|
|
633
|
+
const dispatcher = new QgridDispatcherClass();
|
|
634
|
+
const model =
|
|
635
|
+
provider === "openai" ? "openai/gpt-5.5" : "anthropic/claude-sonnet-4-6";
|
|
636
|
+
let request: GenerateRequest | undefined;
|
|
637
|
+
let mappedText: string | undefined;
|
|
638
|
+
|
|
639
|
+
if (method === "query") {
|
|
640
|
+
const generate = vi.fn(async (req: GenerateRequest) => {
|
|
641
|
+
request = req;
|
|
642
|
+
return providerResult({
|
|
643
|
+
text: '{"action":"answer","answer":{"result":"ok"},"toolCalls":null}',
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
if (provider === "openai") dispatcher.openaiDispatcher = { generate } as never;
|
|
647
|
+
else dispatcher.anthropicDispatcher = { generate } as never;
|
|
648
|
+
|
|
649
|
+
const output = await dispatcher.query({ prompt: "hi", model, ...toolsAndSchema });
|
|
650
|
+
mappedText = output.text;
|
|
651
|
+
} else {
|
|
652
|
+
const generateStream = vi.fn(
|
|
653
|
+
async (req: GenerateRequest, cb: GenerateStreamCallbacks) => {
|
|
654
|
+
request = req;
|
|
655
|
+
cb.onComplete(
|
|
656
|
+
providerResult({
|
|
657
|
+
text: '{"action":"answer","answer":{"result":"ok"},"toolCalls":null}',
|
|
658
|
+
}),
|
|
659
|
+
);
|
|
660
|
+
},
|
|
661
|
+
);
|
|
662
|
+
if (provider === "openai") {
|
|
663
|
+
dispatcher.openaiDispatcher = { generateStream } as never;
|
|
664
|
+
} else {
|
|
665
|
+
dispatcher.anthropicDispatcher = { generateStream } as never;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
await dispatcher.queryStream(
|
|
669
|
+
{ prompt: "hi", model, ...toolsAndSchema },
|
|
670
|
+
{
|
|
671
|
+
onDelta: vi.fn(),
|
|
672
|
+
onComplete: (output) => {
|
|
673
|
+
mappedText = output.text;
|
|
674
|
+
},
|
|
675
|
+
onError: vi.fn(),
|
|
676
|
+
},
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
expect(request?.outputSchema).toMatchObject({
|
|
681
|
+
properties: {
|
|
682
|
+
answer: {
|
|
683
|
+
anyOf: [
|
|
684
|
+
{ $ref: "#/$defs/__qgrid_user_output" },
|
|
685
|
+
{ type: "null" },
|
|
686
|
+
],
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
$defs: {
|
|
690
|
+
__qgrid_user_output: {
|
|
691
|
+
type: "object",
|
|
692
|
+
required: ["result"],
|
|
693
|
+
additionalProperties: false,
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
expect(mappedText).toBe('{"result":"ok"}');
|
|
698
|
+
},
|
|
699
|
+
);
|
|
700
|
+
|
|
344
701
|
it("provider 가 산출한 costUsd 를 가격표 fallback 보다 우선한다", async () => {
|
|
345
702
|
const dispatcher = new QgridDispatcherClass();
|
|
346
703
|
const generate = vi.fn(
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type GenerateResult,
|
|
15
15
|
type StreamCallbacks,
|
|
16
16
|
} from "../../utils/providers/common/provider-dispatcher";
|
|
17
|
+
import {
|
|
18
|
+
parseAndValidateCallerSchemas,
|
|
19
|
+
serializeAndValidateDispatchSchema,
|
|
20
|
+
} from "../../utils/providers/common/schema-validation";
|
|
17
21
|
import { strictify } from "../../utils/providers/common/strictifier";
|
|
18
22
|
import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
|
|
19
23
|
import { type TokenSubsetA } from "../sonamu.generated";
|
|
@@ -21,7 +25,8 @@ import { decideConvRouting, issueConvContext } from "./conv-routing";
|
|
|
21
25
|
import { type QueryInput, type QueryOutput, type TokenStats } from "./qgrid.types";
|
|
22
26
|
import { maskToken, ProcessError, QuotaError } from "./qgrid.types";
|
|
23
27
|
import { type TokenSubscriber } from "./token-subscriber";
|
|
24
|
-
import { applyToolCallEmulation
|
|
28
|
+
import { applyToolCallEmulation } from "./tool-emulation";
|
|
29
|
+
import { buildToolCallSchema } from "./tool-emulation-schema";
|
|
25
30
|
|
|
26
31
|
export class QgridDispatcherClass {
|
|
27
32
|
tokens = new Map<number, TokenSubsetA>();
|
|
@@ -60,15 +65,11 @@ export class QgridDispatcherClass {
|
|
|
60
65
|
}));
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
async query(input: QueryInput,
|
|
64
|
-
void timeoutMs;
|
|
65
|
-
|
|
66
|
-
if (input.tools?.length && input.jsonSchema) {
|
|
67
|
-
throw new ProcessError("tools and jsonSchema cannot be used together");
|
|
68
|
-
}
|
|
69
|
-
|
|
68
|
+
async query(input: QueryInput, abortSignal?: AbortSignal): Promise<QueryOutput> {
|
|
70
69
|
const route = parseProviderRoute(input.model);
|
|
71
|
-
const outputSchema =
|
|
70
|
+
const outputSchema = buildAndValidateStrictOutputSchema(input, route.provider);
|
|
71
|
+
// tools+jsonSchema 합성 시 structured envelope 디코더를 쓴다. 4개 방출 지점이 공유하는 단일 판정.
|
|
72
|
+
const answerMode = input.jsonSchema ? ("structured" as const) : ("legacy" as const);
|
|
72
73
|
|
|
73
74
|
// provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
|
|
74
75
|
if (route.provider === "openai") {
|
|
@@ -87,6 +88,7 @@ export class QgridDispatcherClass {
|
|
|
87
88
|
coldHistory: decision.coldHistory,
|
|
88
89
|
reuse: decision.reuse,
|
|
89
90
|
reuseInput: decision.reuseInput,
|
|
91
|
+
abortSignal,
|
|
90
92
|
imageGeneration: input.imageGeneration,
|
|
91
93
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
92
94
|
});
|
|
@@ -97,7 +99,11 @@ export class QgridDispatcherClass {
|
|
|
97
99
|
? undefined
|
|
98
100
|
: issueConvContext(result.threadCoord, decision);
|
|
99
101
|
|
|
100
|
-
return applyToolCallEmulation(toEmulationResult(result), input.tools,
|
|
102
|
+
return applyToolCallEmulation(toEmulationResult(result), input.tools, {
|
|
103
|
+
threadCoord: coord,
|
|
104
|
+
images: result.images,
|
|
105
|
+
answerMode,
|
|
106
|
+
});
|
|
101
107
|
} else if (route.provider === "anthropic") {
|
|
102
108
|
if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
|
|
103
109
|
|
|
@@ -108,17 +114,18 @@ export class QgridDispatcherClass {
|
|
|
108
114
|
systemPrompt: input.system,
|
|
109
115
|
outputSchema,
|
|
110
116
|
effort: input.effort,
|
|
117
|
+
timeoutMs: input.timeout,
|
|
118
|
+
abortSignal,
|
|
111
119
|
coldInput: decision.coldInput,
|
|
112
120
|
coldHistory: decision.coldHistory,
|
|
113
121
|
imageGeneration: input.imageGeneration,
|
|
114
122
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
115
123
|
});
|
|
116
124
|
|
|
117
|
-
return applyToolCallEmulation(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
);
|
|
125
|
+
return applyToolCallEmulation(toEmulationResult(result), input.tools, {
|
|
126
|
+
threadCoord: issueConvContext(result.threadCoord, decision),
|
|
127
|
+
answerMode,
|
|
128
|
+
});
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
throw directLlmApiFallbackNotImplemented(input);
|
|
@@ -130,7 +137,9 @@ export class QgridDispatcherClass {
|
|
|
130
137
|
abortSignal?: AbortSignal,
|
|
131
138
|
): Promise<void> {
|
|
132
139
|
const route = parseProviderRoute(input.model);
|
|
133
|
-
const outputSchema =
|
|
140
|
+
const outputSchema = buildAndValidateStrictOutputSchema(input, route.provider);
|
|
141
|
+
// tools+jsonSchema 합성 시 structured envelope 디코더를 쓴다. 4개 방출 지점이 공유하는 단일 판정.
|
|
142
|
+
const answerMode = input.jsonSchema ? ("structured" as const) : ("legacy" as const);
|
|
134
143
|
|
|
135
144
|
if (route.provider === "openai") {
|
|
136
145
|
if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
|
|
@@ -160,11 +169,10 @@ export class QgridDispatcherClass {
|
|
|
160
169
|
onTurnId: cb.onTurnId,
|
|
161
170
|
onComplete: (turnResult) => {
|
|
162
171
|
cb.onComplete(
|
|
163
|
-
applyToolCallEmulation(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
),
|
|
172
|
+
applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
|
|
173
|
+
threadCoord: issueConvContext(turnResult.threadCoord, decision),
|
|
174
|
+
answerMode,
|
|
175
|
+
}),
|
|
168
176
|
);
|
|
169
177
|
},
|
|
170
178
|
onError: cb.onError,
|
|
@@ -183,6 +191,7 @@ export class QgridDispatcherClass {
|
|
|
183
191
|
effort: input.effort,
|
|
184
192
|
coldInput: decision.coldInput,
|
|
185
193
|
coldHistory: decision.coldHistory,
|
|
194
|
+
timeoutMs: input.timeout,
|
|
186
195
|
abortSignal,
|
|
187
196
|
imageGeneration: input.imageGeneration,
|
|
188
197
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
@@ -193,7 +202,10 @@ export class QgridDispatcherClass {
|
|
|
193
202
|
onComplete: (turnResult) => {
|
|
194
203
|
const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
|
|
195
204
|
cb.onComplete(
|
|
196
|
-
applyToolCallEmulation(toEmulationResult(turnResult), input.tools,
|
|
205
|
+
applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
|
|
206
|
+
threadCoord: issuedCoord,
|
|
207
|
+
answerMode,
|
|
208
|
+
}),
|
|
197
209
|
);
|
|
198
210
|
},
|
|
199
211
|
onError: cb.onError,
|
|
@@ -228,25 +240,35 @@ function directLlmApiFallbackNotImplemented(input: QueryInput): ProcessError {
|
|
|
228
240
|
// SON-495 교훈: 한때 Anthropic route 만 required 를 제거(optionalize)해 partial/rejected attempt 가
|
|
229
241
|
// 섞여도 통과하게 했으나, 실측 결과 그게 오히려 "모델이 비는 필드를 키째 생략"하도록 유도했다
|
|
230
242
|
// (required 살린 schema 는 누락 0, loose 는 누락 다발). strict 로 두면 모델이 빠짐없이 채우고,
|
|
231
|
-
// 드물게
|
|
243
|
+
// 드물게 어겨 provider retry 예산을 소진한 비정상 종료도 정직한 에러로 받아준다.
|
|
232
244
|
export function buildStrictOutputSchema(
|
|
233
245
|
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
246
|
+
provider?: string,
|
|
234
247
|
): JsonValue | undefined {
|
|
235
248
|
const outputSchema = buildRawOutputSchema(input);
|
|
236
249
|
|
|
237
250
|
return outputSchema
|
|
238
|
-
? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
|
|
251
|
+
? (strictify(outputSchema as Parameters<typeof strictify>[0], { provider }) as JsonValue)
|
|
239
252
|
: undefined;
|
|
240
253
|
}
|
|
241
254
|
|
|
255
|
+
export function buildAndValidateStrictOutputSchema(
|
|
256
|
+
input: Pick<QueryInput, "model" | "tools" | "jsonSchema">,
|
|
257
|
+
provider = parseProviderRoute(input.model).provider,
|
|
258
|
+
): JsonValue | undefined {
|
|
259
|
+
const outputSchema = buildStrictOutputSchema(input, provider);
|
|
260
|
+
serializeAndValidateDispatchSchema(outputSchema, provider);
|
|
261
|
+
return outputSchema;
|
|
262
|
+
}
|
|
263
|
+
|
|
242
264
|
function buildRawOutputSchema(
|
|
243
265
|
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
244
266
|
): JsonValue | undefined {
|
|
267
|
+
const callerOutputSchema = parseAndValidateCallerSchemas(input);
|
|
268
|
+
|
|
245
269
|
return input.tools?.length
|
|
246
|
-
? buildToolCallSchema(input.tools)
|
|
247
|
-
:
|
|
248
|
-
? (JSON.parse(input.jsonSchema) as JsonValue)
|
|
249
|
-
: undefined;
|
|
270
|
+
? buildToolCallSchema(input.tools, callerOutputSchema)
|
|
271
|
+
: callerOutputSchema;
|
|
250
272
|
}
|
|
251
273
|
|
|
252
274
|
// GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
|