@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.
Files changed (65) 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 +36 -14
  6. package/bundle/dist/application/qgrid/qgrid.frame.js +19 -5
  7. package/bundle/dist/application/qgrid/tool-emulation-schema.js +93 -0
  8. package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
  9. package/bundle/dist/application/request-log/request-log.model.js +3 -3
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
  11. package/bundle/dist/application/token/token.model.js +2 -2
  12. package/bundle/dist/server-error-handler.js +15 -0
  13. package/bundle/dist/sonamu.config.js +4 -7
  14. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +4 -3
  15. package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
  16. package/bundle/dist/utils/providers/common/json-schema-keywords.js +77 -0
  17. package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
  18. package/bundle/dist/utils/providers/common/strictifier.js +332 -42
  19. package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
  20. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
  21. package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
  22. package/bundle/src/application/monit/monit.frame.ts +1 -1
  23. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
  24. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +337 -0
  25. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +45 -25
  26. package/bundle/src/application/qgrid/qgrid.frame.test.ts +580 -8
  27. package/bundle/src/application/qgrid/qgrid.frame.ts +28 -1
  28. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +188 -0
  29. package/bundle/src/application/qgrid/tool-emulation-schema.ts +177 -0
  30. package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
  31. package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
  32. package/bundle/src/server-error-handler.ts +22 -0
  33. package/bundle/src/sonamu.config.test.ts +37 -0
  34. package/bundle/src/sonamu.config.ts +2 -5
  35. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +44 -0
  36. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +2 -2
  37. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +10 -0
  38. package/bundle/src/utils/providers/anthropic/claude-session.ts +6 -1
  39. package/bundle/src/utils/providers/common/json-schema-keywords.ts +72 -0
  40. package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
  41. package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
  42. package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
  43. package/bundle/src/utils/providers/common/strictifier.ts +520 -52
  44. package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
  45. package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
  46. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
  47. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
  48. package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
  49. package/bundle/web-dist/client/assets/{index-DXdGBfB8.js → index-CN_kMswh.js} +3 -3
  50. package/bundle/web-dist/client/assets/{logs-D_j1yxC4.js → logs-6nYj9OKL.js} +1 -1
  51. package/bundle/web-dist/client/assets/{monit-CIToyoxW.js → monit-CaPH9Uwc.js} +1 -1
  52. package/bundle/web-dist/client/assets/{routes-Dd21A-wi.js → routes-Dh5HPi-b.js} +1 -1
  53. package/bundle/web-dist/client/assets/{show-DfePWRbc.js → show-Be_LVd9d.js} +1 -1
  54. package/bundle/web-dist/client/assets/{tokens-B4MV2fdk.js → tokens-CAi3zu_0.js} +1 -1
  55. package/bundle/web-dist/client/index.html +1 -1
  56. package/bundle/web-dist/server/entry-server.generated.js +1 -1
  57. package/package.json +1 -1
  58. package/skills/qgrid/references/ai-sdk-provider-contract.md +34 -2
  59. package/skills/qgrid/references/anthropic-claude-code-runtime.md +3 -0
  60. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  61. package/skills/qgrid/references/decision-rationale.md +10 -1
  62. package/skills/qgrid/references/docs-workflow.md +9 -0
  63. package/skills/qgrid/references/repo-map.md +2 -1
  64. package/skills/qgrid/references/tool-calling-and-multiturn.md +145 -6
  65. 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, 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>();
@@ -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 = 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);
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, coord, result.images);
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
- toEmulationResult(result),
120
- input.tools,
121
- issueConvContext(result.threadCoord, decision),
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 = 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);
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
- toEmulationResult(turnResult),
166
- input.tools,
167
- issueConvContext(turnResult.threadCoord, decision),
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, issuedCoord),
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
- // 드물게 어겨도 retry=1 + 비정상 종료는 정직한 에러(stream-json-adapter)로 받아준다.
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
- : input.jsonSchema
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 로 매핑한다.