@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.
Files changed (68) hide show
  1. package/README.ko.md +7 -2
  2. package/README.md +7 -2
  3. package/bundle/dist/application/monit/monit.frame.js +3 -3
  4. package/bundle/dist/application/qgrid/oauth.js +1 -1
  5. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +41 -15
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
  7. package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
  8. package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
  9. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  10. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  11. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  12. package/bundle/dist/application/token/token.model.js +2 -2
  13. package/bundle/dist/server-error-handler.js +15 -0
  14. package/bundle/dist/sonamu.config.js +4 -7
  15. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +5 -4
  16. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  17. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
  18. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  19. package/bundle/dist/utils/providers/common/strictifier.js +329 -42
  20. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  21. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  22. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  23. package/bundle/src/application/monit/monit.frame.ts +1 -1
  24. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +358 -1
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
  27. package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
  29. package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
  30. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  31. package/bundle/src/application/qgrid/tool-emulation-schema.ts +176 -0
  32. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  33. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  34. package/bundle/src/server-error-handler.ts +22 -0
  35. package/bundle/src/sonamu.config.test.ts +37 -0
  36. package/bundle/src/sonamu.config.ts +2 -5
  37. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +56 -0
  38. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
  39. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  40. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  41. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +66 -0
  42. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
  43. package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
  44. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  45. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  46. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  47. package/bundle/src/utils/providers/common/strictifier.ts +513 -52
  48. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  49. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  50. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  51. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  52. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  53. package/bundle/web-dist/client/assets/{index-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
  54. package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
  55. package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
  56. package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
  57. package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
  58. package/bundle/web-dist/client/assets/{tokens-gEGH52O8.js → tokens-Ct0FGD4b.js} +1 -1
  59. package/bundle/web-dist/client/index.html +1 -1
  60. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  61. package/package.json +1 -1
  62. package/skills/qgrid/references/ai-sdk-provider-contract.md +42 -2
  63. package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
  64. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  65. package/skills/qgrid/references/decision-rationale.md +11 -1
  66. package/skills/qgrid/references/repo-map.md +2 -1
  67. package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
  68. 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, buildToolCallSchema } from "./tool-emulation";
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, timeoutMs?: number): Promise<QueryOutput> {
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 = buildStrictOutputSchema(input);
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, coord, result.images);
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
- toEmulationResult(result),
119
- input.tools,
120
- issueConvContext(result.threadCoord, decision),
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 = buildStrictOutputSchema(input);
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
- toEmulationResult(turnResult),
165
- input.tools,
166
- issueConvContext(turnResult.threadCoord, decision),
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, issuedCoord),
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
- // 드물게 어겨도 retry=1 + 비정상 종료는 정직한 에러(stream-json-adapter)로 받아준다.
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
- : input.jsonSchema
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 로 매핑한다.