@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
|
@@ -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
|
|
|
@@ -282,6 +307,68 @@ describe("QgridDispatcherClass", () => {
|
|
|
282
307
|
expect(contentsArray?.items?.additionalProperties).toBe(false);
|
|
283
308
|
});
|
|
284
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
|
+
|
|
285
372
|
// SON-495: anthropic route 도 strict(required 유지)를 쓴다 — required 를 살려야 모델이 필드를
|
|
286
373
|
// 빠짐없이 채운다(실측 확정). optionalize 는 제거됨.
|
|
287
374
|
it("Anthropic route 에 strict(required 유지) outputSchema 를 전달한다", async () => {
|
|
@@ -361,6 +448,256 @@ describe("QgridDispatcherClass", () => {
|
|
|
361
448
|
expect(outputSchema.additionalProperties).toBe(false);
|
|
362
449
|
});
|
|
363
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
|
+
|
|
364
701
|
it("provider 가 산출한 costUsd 를 가격표 fallback 보다 우선한다", async () => {
|
|
365
702
|
const dispatcher = new QgridDispatcherClass();
|
|
366
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>();
|
|
@@ -61,12 +66,10 @@ export class QgridDispatcherClass {
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
async query(input: QueryInput, abortSignal?: AbortSignal): Promise<QueryOutput> {
|
|
64
|
-
if (input.tools?.length && input.jsonSchema) {
|
|
65
|
-
throw new ProcessError("tools and jsonSchema cannot be used together");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
69
|
const route = parseProviderRoute(input.model);
|
|
69
|
-
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);
|
|
70
73
|
|
|
71
74
|
// provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
|
|
72
75
|
if (route.provider === "openai") {
|
|
@@ -96,7 +99,11 @@ export class QgridDispatcherClass {
|
|
|
96
99
|
? undefined
|
|
97
100
|
: issueConvContext(result.threadCoord, decision);
|
|
98
101
|
|
|
99
|
-
return applyToolCallEmulation(toEmulationResult(result), input.tools,
|
|
102
|
+
return applyToolCallEmulation(toEmulationResult(result), input.tools, {
|
|
103
|
+
threadCoord: coord,
|
|
104
|
+
images: result.images,
|
|
105
|
+
answerMode,
|
|
106
|
+
});
|
|
100
107
|
} else if (route.provider === "anthropic") {
|
|
101
108
|
if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
|
|
102
109
|
|
|
@@ -115,11 +122,10 @@ export class QgridDispatcherClass {
|
|
|
115
122
|
imageGenerationOptions: input.imageGenerationOptions,
|
|
116
123
|
});
|
|
117
124
|
|
|
118
|
-
return applyToolCallEmulation(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
125
|
+
return applyToolCallEmulation(toEmulationResult(result), input.tools, {
|
|
126
|
+
threadCoord: issueConvContext(result.threadCoord, decision),
|
|
127
|
+
answerMode,
|
|
128
|
+
});
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
throw directLlmApiFallbackNotImplemented(input);
|
|
@@ -131,7 +137,9 @@ export class QgridDispatcherClass {
|
|
|
131
137
|
abortSignal?: AbortSignal,
|
|
132
138
|
): Promise<void> {
|
|
133
139
|
const route = parseProviderRoute(input.model);
|
|
134
|
-
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);
|
|
135
143
|
|
|
136
144
|
if (route.provider === "openai") {
|
|
137
145
|
if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
|
|
@@ -161,11 +169,10 @@ export class QgridDispatcherClass {
|
|
|
161
169
|
onTurnId: cb.onTurnId,
|
|
162
170
|
onComplete: (turnResult) => {
|
|
163
171
|
cb.onComplete(
|
|
164
|
-
applyToolCallEmulation(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
),
|
|
172
|
+
applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
|
|
173
|
+
threadCoord: issueConvContext(turnResult.threadCoord, decision),
|
|
174
|
+
answerMode,
|
|
175
|
+
}),
|
|
169
176
|
);
|
|
170
177
|
},
|
|
171
178
|
onError: cb.onError,
|
|
@@ -195,7 +202,10 @@ export class QgridDispatcherClass {
|
|
|
195
202
|
onComplete: (turnResult) => {
|
|
196
203
|
const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
|
|
197
204
|
cb.onComplete(
|
|
198
|
-
applyToolCallEmulation(toEmulationResult(turnResult), input.tools,
|
|
205
|
+
applyToolCallEmulation(toEmulationResult(turnResult), input.tools, {
|
|
206
|
+
threadCoord: issuedCoord,
|
|
207
|
+
answerMode,
|
|
208
|
+
}),
|
|
199
209
|
);
|
|
200
210
|
},
|
|
201
211
|
onError: cb.onError,
|
|
@@ -230,25 +240,35 @@ function directLlmApiFallbackNotImplemented(input: QueryInput): ProcessError {
|
|
|
230
240
|
// SON-495 교훈: 한때 Anthropic route 만 required 를 제거(optionalize)해 partial/rejected attempt 가
|
|
231
241
|
// 섞여도 통과하게 했으나, 실측 결과 그게 오히려 "모델이 비는 필드를 키째 생략"하도록 유도했다
|
|
232
242
|
// (required 살린 schema 는 누락 0, loose 는 누락 다발). strict 로 두면 모델이 빠짐없이 채우고,
|
|
233
|
-
// 드물게
|
|
243
|
+
// 드물게 어겨 provider retry 예산을 소진한 비정상 종료도 정직한 에러로 받아준다.
|
|
234
244
|
export function buildStrictOutputSchema(
|
|
235
245
|
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
246
|
+
provider?: string,
|
|
236
247
|
): JsonValue | undefined {
|
|
237
248
|
const outputSchema = buildRawOutputSchema(input);
|
|
238
249
|
|
|
239
250
|
return outputSchema
|
|
240
|
-
? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
|
|
251
|
+
? (strictify(outputSchema as Parameters<typeof strictify>[0], { provider }) as JsonValue)
|
|
241
252
|
: undefined;
|
|
242
253
|
}
|
|
243
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
|
+
|
|
244
264
|
function buildRawOutputSchema(
|
|
245
265
|
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
246
266
|
): JsonValue | undefined {
|
|
267
|
+
const callerOutputSchema = parseAndValidateCallerSchemas(input);
|
|
268
|
+
|
|
247
269
|
return input.tools?.length
|
|
248
|
-
? buildToolCallSchema(input.tools)
|
|
249
|
-
:
|
|
250
|
-
? (JSON.parse(input.jsonSchema) as JsonValue)
|
|
251
|
-
: undefined;
|
|
270
|
+
? buildToolCallSchema(input.tools, callerOutputSchema)
|
|
271
|
+
: callerOutputSchema;
|
|
252
272
|
}
|
|
253
273
|
|
|
254
274
|
// GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
|