@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
@@ -56,15 +56,19 @@ vi.mock("../token/token.model", () => ({
56
56
  },
57
57
  }));
58
58
 
59
- vi.mock("./qgrid.dispatcher", () => ({
60
- QgridDispatcher: {
61
- query: dispatcherQueryMock,
62
- queryStream: dispatcherQueryStreamMock,
63
- openaiDispatcher: {
64
- getRateLimitsByTokenId: getRateLimitsByTokenIdMock,
59
+ vi.mock("./qgrid.dispatcher", async (importOriginal) => {
60
+ const original = await importOriginal<typeof import("./qgrid.dispatcher")>();
61
+ return {
62
+ ...original,
63
+ QgridDispatcher: {
64
+ query: dispatcherQueryMock,
65
+ queryStream: dispatcherQueryStreamMock,
66
+ openaiDispatcher: {
67
+ getRateLimitsByTokenId: getRateLimitsByTokenIdMock,
68
+ },
65
69
  },
66
- },
67
- }));
70
+ };
71
+ });
68
72
 
69
73
  vi.mock("./qgrid-run-lifecycle", () => ({
70
74
  beforeQuery: beforeQueryMock,
@@ -90,6 +94,10 @@ const tokenEntry = {
90
94
  weight: 1,
91
95
  };
92
96
 
97
+ function deeplyNestedInputSchema(depth: number): unknown {
98
+ return JSON.parse(`${"[".repeat(depth)}0${"]".repeat(depth)}`) as unknown;
99
+ }
100
+
93
101
  describe("QgridFrame.updateToken", () => {
94
102
  beforeEach(() => {
95
103
  findOneMock.mockReset();
@@ -209,6 +217,34 @@ describe("QgridFrame.query request logging", () => {
209
217
  expect(result.runContext).toEqual(queryOutput().runContext);
210
218
  });
211
219
 
220
+ it("persists an immediate tools+schema structured answer as final JSON text", async () => {
221
+ const output = {
222
+ ...queryOutput(),
223
+ text: '{"result":"ok"}',
224
+ content: [{ type: "text" as const, text: '{"result":"ok"}' }],
225
+ };
226
+ dispatcherQueryMock.mockResolvedValueOnce(output);
227
+ const args = {
228
+ prompt: "answer",
229
+ model: "openai/gpt-5.6-terra",
230
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
231
+ jsonSchema: JSON.stringify({
232
+ type: "object",
233
+ properties: { result: { type: "string" } },
234
+ required: ["result"],
235
+ }),
236
+ };
237
+
238
+ await expect(QgridFrame.query(args)).resolves.toMatchObject({
239
+ text: '{"result":"ok"}',
240
+ content: [{ type: "text", text: '{"result":"ok"}' }],
241
+ finishReason: "stop",
242
+ });
243
+ expect(beforeQueryMock).toHaveBeenCalledWith(args);
244
+ expect(afterQueryMock).toHaveBeenCalledWith(41, 0, args, output);
245
+ expect(finishRunWithErrorMock).not.toHaveBeenCalled();
246
+ });
247
+
212
248
  it("does not create or update logs when logger is false", async () => {
213
249
  const output = queryOutput();
214
250
  dispatcherQueryMock.mockResolvedValueOnce(output);
@@ -243,6 +279,23 @@ describe("QgridFrame.query request logging", () => {
243
279
  expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
244
280
  });
245
281
 
282
+ it("marks an incoherent structured envelope failure as a request-log error", async () => {
283
+ const error = new Error(
284
+ "tool-call emulation: structured response envelope is missing required keys: toolCalls",
285
+ );
286
+ dispatcherQueryMock.mockRejectedValueOnce(error);
287
+ const args = {
288
+ prompt: "answer",
289
+ model: "anthropic/claude-sonnet-4-6",
290
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
291
+ jsonSchema: JSON.stringify({ type: "object", properties: {} }),
292
+ };
293
+
294
+ await expect(QgridFrame.query(args)).rejects.toThrow(error.message);
295
+ expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, error.message, args);
296
+ expect(afterQueryMock).not.toHaveBeenCalled();
297
+ });
298
+
246
299
  it("aborts provider execution and marks the run aborted when the HTTP response closes early", async () => {
247
300
  const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
248
301
  const responseRaw = Object.assign(new EventEmitter(), {
@@ -303,6 +356,353 @@ describe("QgridFrame.query request logging", () => {
303
356
  expect(dispatcherQueryMock).not.toHaveBeenCalled();
304
357
  });
305
358
 
359
+ it.each([
360
+ ["malformed JSON", '{"type":"object"'],
361
+ ["unsupported top-level array schema", '{"type":"array","items":{"type":"string"}}'],
362
+ ])("returns a deterministic 400 before logging or dispatch for %s", async (_label, jsonSchema) => {
363
+ await expect(
364
+ QgridFrame.query({
365
+ prompt: "hi",
366
+ model: "openai/gpt-5.5",
367
+ jsonSchema,
368
+ }),
369
+ ).rejects.toMatchObject({
370
+ statusCode: 400,
371
+ message: expect.stringContaining("qgrid: jsonSchema"),
372
+ });
373
+
374
+ expect(beforeQueryMock).not.toHaveBeenCalled();
375
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
376
+ });
377
+
378
+ it("returns 400 for a deeply nested tools-only schema before logging or dispatch", async () => {
379
+ await expect(
380
+ QgridFrame.query({
381
+ prompt: "hi",
382
+ model: "openai/gpt-5.5",
383
+ tools: [{ name: "deepTool", inputSchema: deeplyNestedInputSchema(256) }],
384
+ }),
385
+ ).rejects.toMatchObject({
386
+ statusCode: 400,
387
+ message: expect.stringContaining("tools[0].inputSchema exceeds depth limit"),
388
+ });
389
+
390
+ expect(beforeQueryMock).not.toHaveBeenCalled();
391
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
392
+ });
393
+
394
+ it("returns 400 for an unsupported composed-schema reference before logging or dispatch", async () => {
395
+ await expect(
396
+ QgridFrame.query({
397
+ prompt: "hi",
398
+ model: "openai/gpt-5.5",
399
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
400
+ jsonSchema: JSON.stringify({
401
+ $id: "urn:example:answer",
402
+ type: "object",
403
+ properties: { answer: { type: "string" } },
404
+ }),
405
+ }),
406
+ ).rejects.toMatchObject({
407
+ statusCode: 400,
408
+ message: expect.stringContaining("$id is not supported"),
409
+ });
410
+
411
+ expect(beforeQueryMock).not.toHaveBeenCalled();
412
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it("returns 400 for a malformed normalization keyword before logging or dispatch", async () => {
416
+ await expect(
417
+ QgridFrame.query({
418
+ prompt: "hi",
419
+ model: "openai/gpt-5.5",
420
+ jsonSchema: JSON.stringify({
421
+ type: "object",
422
+ anyOf: {},
423
+ }),
424
+ }),
425
+ ).rejects.toMatchObject({
426
+ statusCode: 400,
427
+ message: expect.stringContaining("caller schema cannot be normalized"),
428
+ });
429
+
430
+ expect(beforeQueryMock).not.toHaveBeenCalled();
431
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("returns 400 for an unrestricted positional tuple before logging or dispatch", async () => {
435
+ await expect(
436
+ QgridFrame.query({
437
+ prompt: "hi",
438
+ model: "openai/gpt-5.6-terra",
439
+ jsonSchema: JSON.stringify({
440
+ type: "object",
441
+ properties: {
442
+ tuple: {
443
+ type: "array",
444
+ items: [{ type: "string" }],
445
+ additionalItems: true,
446
+ },
447
+ },
448
+ required: ["tuple"],
449
+ }),
450
+ }),
451
+ ).rejects.toMatchObject({
452
+ statusCode: 400,
453
+ message: expect.stringContaining("unrestricted tuple rest is not supported"),
454
+ });
455
+
456
+ expect(beforeQueryMock).not.toHaveBeenCalled();
457
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("returns 400 for an Anthropic positional tuple before logging or dispatch", async () => {
461
+ await expect(
462
+ QgridFrame.query({
463
+ prompt: "hi",
464
+ model: "anthropic/claude-opus-4-8",
465
+ jsonSchema: JSON.stringify({
466
+ type: "object",
467
+ properties: {
468
+ tuple: {
469
+ type: "array",
470
+ items: [{ type: "string" }, { type: "integer" }],
471
+ },
472
+ },
473
+ required: ["tuple"],
474
+ }),
475
+ }),
476
+ ).rejects.toMatchObject({
477
+ statusCode: 400,
478
+ message: expect.stringContaining(
479
+ "positional tuple schemas are not supported on Anthropic",
480
+ ),
481
+ });
482
+
483
+ expect(beforeQueryMock).not.toHaveBeenCalled();
484
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
485
+ });
486
+
487
+ it("returns 400 for an Anthropic tuple nested under not before logging or dispatch", async () => {
488
+ await expect(
489
+ QgridFrame.query({
490
+ prompt: "hi",
491
+ model: "anthropic/claude-opus-4-8",
492
+ jsonSchema: JSON.stringify({
493
+ type: "object",
494
+ not: {
495
+ type: "array",
496
+ items: [{ type: "string" }],
497
+ },
498
+ }),
499
+ }),
500
+ ).rejects.toMatchObject({
501
+ statusCode: 400,
502
+ message: expect.stringContaining(
503
+ "positional tuple schemas are not supported on Anthropic",
504
+ ),
505
+ });
506
+
507
+ expect(beforeQueryMock).not.toHaveBeenCalled();
508
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
509
+ });
510
+
511
+ it("returns 400 for an OpenAI tuple under not instead of changing negative semantics", async () => {
512
+ await expect(
513
+ QgridFrame.query({
514
+ prompt: "hi",
515
+ model: "openai/gpt-5.6-terra",
516
+ jsonSchema: JSON.stringify({
517
+ type: "object",
518
+ not: {
519
+ type: "array",
520
+ items: [{ type: "string" }],
521
+ },
522
+ }),
523
+ }),
524
+ ).rejects.toMatchObject({
525
+ statusCode: 400,
526
+ message: expect.stringContaining(
527
+ "positional tuple schemas are not supported in this schema position",
528
+ ),
529
+ });
530
+
531
+ expect(beforeQueryMock).not.toHaveBeenCalled();
532
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
533
+ });
534
+
535
+ it("returns 400 for a definition referenced under not before globally normalizing it", async () => {
536
+ await expect(
537
+ QgridFrame.query({
538
+ prompt: "hi",
539
+ model: "openai/gpt-5.6-terra",
540
+ jsonSchema: JSON.stringify({
541
+ type: "object",
542
+ $defs: {
543
+ Forbidden: {
544
+ type: "object",
545
+ properties: { value: { const: 0 } },
546
+ required: ["value"],
547
+ },
548
+ },
549
+ not: { $ref: "#/$defs/Forbidden" },
550
+ }),
551
+ }),
552
+ ).rejects.toMatchObject({
553
+ statusCode: 400,
554
+ message: expect.stringContaining(
555
+ "schema references are not supported in this schema position",
556
+ ),
557
+ });
558
+
559
+ expect(beforeQueryMock).not.toHaveBeenCalled();
560
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
561
+ });
562
+
563
+ it.each([
564
+ [
565
+ "a literal JSON value",
566
+ {
567
+ type: "object",
568
+ properties: {
569
+ payload: { $ref: "#/$defs/Carrier/enum/0" },
570
+ },
571
+ required: ["payload"],
572
+ $defs: {
573
+ Carrier: {
574
+ enum: [{ type: "object", properties: { value: { type: "string" } } }],
575
+ },
576
+ },
577
+ },
578
+ "$ref target must be the document root or a definition root",
579
+ ],
580
+ [
581
+ "a conditional schema position",
582
+ {
583
+ type: "object",
584
+ properties: {
585
+ payload: { $ref: "#/if" },
586
+ },
587
+ required: ["payload"],
588
+ if: {
589
+ type: "object",
590
+ properties: { secret: { type: "string" } },
591
+ },
592
+ },
593
+ "$ref target must be the document root or a definition root",
594
+ ],
595
+ ] as const)(
596
+ "returns 400 for an OpenAI ref targeting %s before logging or dispatch",
597
+ async (_label, schema, message) => {
598
+ await expect(
599
+ QgridFrame.query({
600
+ prompt: "hi",
601
+ model: "openai/gpt-5.6-terra",
602
+ jsonSchema: JSON.stringify(schema),
603
+ }),
604
+ ).rejects.toMatchObject({
605
+ statusCode: 400,
606
+ message: expect.stringContaining(message),
607
+ });
608
+
609
+ expect(beforeQueryMock).not.toHaveBeenCalled();
610
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
611
+ },
612
+ );
613
+
614
+ it.each([
615
+ ["missing", undefined],
616
+ ["nullable union", ["array", "null"]],
617
+ ] as const)(
618
+ "returns 400 for an OpenAI positional tuple with %s type before logging or dispatch",
619
+ async (_label, type) => {
620
+ await expect(
621
+ QgridFrame.query({
622
+ prompt: "hi",
623
+ model: "openai/gpt-5.6-terra",
624
+ jsonSchema: JSON.stringify({
625
+ type: "object",
626
+ properties: {
627
+ tuple: {
628
+ ...(type === undefined ? {} : { type }),
629
+ items: [{ type: "string" }, { type: "integer" }],
630
+ },
631
+ },
632
+ required: ["tuple"],
633
+ }),
634
+ }),
635
+ ).rejects.toMatchObject({
636
+ statusCode: 400,
637
+ message: expect.stringContaining(
638
+ 'positional tuple schemas must declare type "array"',
639
+ ),
640
+ });
641
+
642
+ expect(beforeQueryMock).not.toHaveBeenCalled();
643
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
644
+ },
645
+ );
646
+
647
+ it.each([
648
+ ["missing", undefined],
649
+ ["nullable union", ["array", "null"]],
650
+ ] as const)(
651
+ "returns 400 for an Anthropic positional tuple with %s type before logging or dispatch",
652
+ async (_label, type) => {
653
+ await expect(
654
+ QgridFrame.query({
655
+ prompt: "hi",
656
+ model: "anthropic/claude-opus-4-8",
657
+ jsonSchema: JSON.stringify({
658
+ type: "object",
659
+ properties: {
660
+ tuple: {
661
+ ...(type === undefined ? {} : { type }),
662
+ prefixItems: [{ type: "string" }, { type: "integer" }],
663
+ },
664
+ },
665
+ required: ["tuple"],
666
+ }),
667
+ }),
668
+ ).rejects.toMatchObject({
669
+ statusCode: 400,
670
+ message: expect.stringContaining(
671
+ "positional tuple schemas are not supported on Anthropic",
672
+ ),
673
+ });
674
+
675
+ expect(beforeQueryMock).not.toHaveBeenCalled();
676
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
677
+ },
678
+ );
679
+
680
+ it("returns 400 when the final Anthropic schema exceeds the safe argv size", async () => {
681
+ await expect(
682
+ QgridFrame.query({
683
+ prompt: "hi",
684
+ model: "anthropic/claude-sonnet-4-6",
685
+ tools: [
686
+ {
687
+ name: "lookup",
688
+ description: "x".repeat(64 * 1024),
689
+ inputSchema: { type: "object" },
690
+ },
691
+ ],
692
+ jsonSchema: JSON.stringify({
693
+ type: "object",
694
+ properties: { answer: { type: "string" } },
695
+ }),
696
+ }),
697
+ ).rejects.toMatchObject({
698
+ statusCode: 400,
699
+ message: expect.stringContaining("Anthropic dispatch schema exceeds argv"),
700
+ });
701
+
702
+ expect(beforeQueryMock).not.toHaveBeenCalled();
703
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
704
+ });
705
+
306
706
  it("rejects legacy logMode in the wire schema", () => {
307
707
  expect(QueryInput.safeParse({ prompt: "hi", logMode: "none" }).success).toBe(false);
308
708
  expect(QueryInput.safeParse({ prompt: "hi", logMode: undefined }).success).toBe(false);
@@ -335,6 +735,96 @@ describe("QgridFrame.prepareStream", () => {
335
735
  expect(dispatcherQueryMock).not.toHaveBeenCalled();
336
736
  expect(requestLogSaveMock).not.toHaveBeenCalled();
337
737
  });
738
+
739
+ it("returns 400 for an invalid schema before allocating a stream id", async () => {
740
+ await expect(
741
+ QgridFrame.prepareStream({
742
+ prompt: "hi",
743
+ model: "anthropic/claude-sonnet-4-6",
744
+ jsonSchema: "null",
745
+ }),
746
+ ).rejects.toMatchObject({
747
+ statusCode: 400,
748
+ message: 'qgrid: jsonSchema top-level type must be "object"',
749
+ });
750
+ });
751
+
752
+ it("returns 400 for an Anthropic tuple under patternProperties before allocating a stream id", async () => {
753
+ await expect(
754
+ QgridFrame.prepareStream({
755
+ prompt: "hi",
756
+ model: "anthropic/claude-opus-4-8",
757
+ jsonSchema: JSON.stringify({
758
+ type: "object",
759
+ patternProperties: {
760
+ "^tuple$": {
761
+ type: "array",
762
+ prefixItems: [{ type: "string" }],
763
+ },
764
+ },
765
+ }),
766
+ }),
767
+ ).rejects.toMatchObject({
768
+ statusCode: 400,
769
+ message: expect.stringContaining(
770
+ "positional tuple schemas are not supported on Anthropic",
771
+ ),
772
+ });
773
+
774
+ expect(requestLogSaveMock).not.toHaveBeenCalled();
775
+ expect(dispatcherQueryMock).not.toHaveBeenCalled();
776
+ });
777
+
778
+ it("returns 400 for a deeply nested tool schema before allocating a stream id", async () => {
779
+ await expect(
780
+ QgridFrame.prepareStream({
781
+ prompt: "hi",
782
+ model: "anthropic/claude-sonnet-4-6",
783
+ tools: [{ name: "deepTool", inputSchema: deeplyNestedInputSchema(256) }],
784
+ }),
785
+ ).rejects.toMatchObject({
786
+ statusCode: 400,
787
+ message: expect.stringContaining("tools[0].inputSchema exceeds depth limit"),
788
+ });
789
+ });
790
+
791
+ it("returns 400 for an unsupported composed-schema reference before allocating a stream id", async () => {
792
+ await expect(
793
+ QgridFrame.prepareStream({
794
+ prompt: "hi",
795
+ model: "anthropic/claude-sonnet-4-6",
796
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
797
+ jsonSchema: JSON.stringify({
798
+ type: "object",
799
+ properties: { answer: { $ref: "#named-anchor" } },
800
+ }),
801
+ }),
802
+ ).rejects.toMatchObject({
803
+ statusCode: 400,
804
+ message: expect.stringContaining(
805
+ "only root-relative JSON Pointer $ref values are supported",
806
+ ),
807
+ });
808
+ });
809
+
810
+ it("returns 400 for an oversized Anthropic argv schema before allocating a stream id", async () => {
811
+ await expect(
812
+ QgridFrame.prepareStream({
813
+ prompt: "hi",
814
+ model: "anthropic/claude-sonnet-4-6",
815
+ tools: [
816
+ {
817
+ name: "lookup",
818
+ description: "x".repeat(64 * 1024),
819
+ inputSchema: { type: "object" },
820
+ },
821
+ ],
822
+ }),
823
+ ).rejects.toMatchObject({
824
+ statusCode: 400,
825
+ message: expect.stringContaining("Anthropic dispatch schema exceeds argv"),
826
+ });
827
+ });
338
828
  });
339
829
 
340
830
  describe("QgridFrame raw lifecycle API", () => {
@@ -520,6 +1010,88 @@ describe("QgridFrame.queryStream request logging", () => {
520
1010
  expect(sse.publish).toHaveBeenCalledWith("error", { message: "provider failed" });
521
1011
  });
522
1012
 
1013
+ it.each([
1014
+ ["openai", "openai/gpt-5.6-terra", "gpt-5.6-terra"],
1015
+ ["anthropic", "anthropic/claude-sonnet-4-6", "claude-sonnet-4-6"],
1016
+ ] as const)(
1017
+ "finishes a malformed %s structured stream through the real dispatcher without hanging",
1018
+ async (provider, requestedModel, servedModel) => {
1019
+ const sse = installSseContext();
1020
+ const { QgridDispatcherClass } =
1021
+ await vi.importActual<typeof import("./qgrid.dispatcher")>("./qgrid.dispatcher");
1022
+ const dispatcher = new QgridDispatcherClass();
1023
+ const generateStream = vi.fn(
1024
+ async (
1025
+ _request: unknown,
1026
+ callbacks: {
1027
+ onComplete: (result: {
1028
+ text: string;
1029
+ tokenName: string;
1030
+ usage: {
1031
+ totalTokens: number;
1032
+ inputTokens: number;
1033
+ cachedInputTokens: number;
1034
+ outputTokens: number;
1035
+ reasoningOutputTokens: number;
1036
+ };
1037
+ durationMs: number;
1038
+ model: string;
1039
+ threadCoord: { workerId: number; threadId: string; epoch: number };
1040
+ }) => void;
1041
+ },
1042
+ ) => {
1043
+ // 실제 provider adapter 완료 지점: toolCalls 누락 envelope가 dispatcher mapper에서 throw.
1044
+ callbacks.onComplete({
1045
+ text: '{"action":"answer","answer":{"result":"ok"}}',
1046
+ tokenName: `${provider}/test`,
1047
+ usage: {
1048
+ totalTokens: 10,
1049
+ inputTokens: 5,
1050
+ cachedInputTokens: 0,
1051
+ outputTokens: 5,
1052
+ reasoningOutputTokens: 0,
1053
+ },
1054
+ durationMs: 12,
1055
+ model: servedModel,
1056
+ threadCoord: { workerId: 1, threadId: "thread-malformed", epoch: 0 },
1057
+ });
1058
+ },
1059
+ );
1060
+ if (provider === "openai") {
1061
+ dispatcher.openaiDispatcher = { generateStream } as never;
1062
+ } else {
1063
+ dispatcher.anthropicDispatcher = { generateStream } as never;
1064
+ }
1065
+ dispatcherQueryStreamMock.mockImplementationOnce((args, callbacks, signal) =>
1066
+ dispatcher.queryStream(args, callbacks, signal),
1067
+ );
1068
+
1069
+ const args = {
1070
+ prompt: "answer",
1071
+ model: requestedModel,
1072
+ tools: [{ name: "lookup", inputSchema: { type: "object" } }],
1073
+ jsonSchema: JSON.stringify({
1074
+ type: "object",
1075
+ properties: { result: { type: "string" } },
1076
+ }),
1077
+ };
1078
+ const { streamId } = await QgridFrame.prepareStream(args);
1079
+
1080
+ await expect(QgridFrame.queryStream(streamId)).resolves.toBeUndefined();
1081
+
1082
+ expect(generateStream).toHaveBeenCalledOnce();
1083
+ expect(finishRunWithErrorMock).toHaveBeenCalledWith(
1084
+ 52,
1085
+ expect.stringContaining("invalid structured-output envelope"),
1086
+ args,
1087
+ );
1088
+ const errorMessage = finishRunWithErrorMock.mock.calls[0]?.[1];
1089
+ expect(afterQueryMock).not.toHaveBeenCalled();
1090
+ expect(sse.publish).toHaveBeenCalledWith("error", { message: errorMessage });
1091
+ expect(sse.end).toHaveBeenCalledOnce();
1092
+ },
1093
+ );
1094
+
523
1095
  it("marks the run aborted on close regardless of the provider error message", async () => {
524
1096
  const sse = installSseContext();
525
1097
  dispatcherQueryStreamMock.mockImplementationOnce(
@@ -11,6 +11,7 @@ import {
11
11
  getExpiresAt,
12
12
  getRefreshToken,
13
13
  } from "../../utils/providers/common/credentials";
14
+ import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
14
15
  import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
15
16
  import { type TokenSubsetA } from "../sonamu.generated";
16
17
  import { TokenModel, type TokenUpdateFields } from "../token/token.model";
@@ -29,7 +30,7 @@ import {
29
30
  finishRunAborted,
30
31
  finishRunWithError,
31
32
  } from "./qgrid-run-lifecycle";
32
- import { QgridDispatcher } from "./qgrid.dispatcher";
33
+ import { buildAndValidateStrictOutputSchema, QgridDispatcher } from "./qgrid.dispatcher";
33
34
  import {
34
35
  type QueryInput,
35
36
  type AppendStepInput,
@@ -43,6 +44,7 @@ import {
43
44
  type TokenStats,
44
45
  type UsageResponse,
45
46
  } from "./qgrid.types";
47
+ import { ToolSchemaCompositionError } from "./tool-emulation-schema";
46
48
 
47
49
  const pendingStreams = new Map<string, QueryInput>();
48
50
 
@@ -100,6 +102,29 @@ function rejectLegacyLogMode(args: QueryInput): void {
100
102
  );
101
103
  }
102
104
 
105
+ function rejectInvalidCallerSchemas(args: QueryInput): void {
106
+ if (args.jsonSchema === undefined && !args.tools?.length) return;
107
+
108
+ try {
109
+ buildAndValidateStrictOutputSchema(args);
110
+ } catch (error) {
111
+ if (
112
+ error instanceof CallerSchemaValidationError ||
113
+ error instanceof ToolSchemaCompositionError
114
+ ) {
115
+ throw new BadRequestException(error.message as LocalizedString);
116
+ }
117
+ // 잘못된 keyword 형태(anyOf 를 객체로 보내는 등)는 파이프라인에서 untyped 오류로 나오므로,
118
+ // 타입 좁히기 대신 Error 전체를 caller-fault 400 으로 매핑한다(계약 테스트로 고정됨).
119
+ if (error instanceof Error) {
120
+ throw new BadRequestException(
121
+ `qgrid: caller schema cannot be normalized: ${error.message}` as LocalizedString,
122
+ );
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
103
128
  function createHttpDisconnectHandle(): {
104
129
  signal: AbortSignal;
105
130
  dispose: () => void;
@@ -148,6 +173,7 @@ class QgridFrameClass extends BaseFrameClass {
148
173
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
149
174
  async query(args: QueryInput): Promise<QueryOutput> {
150
175
  rejectLegacyLogMode(args);
176
+ rejectInvalidCallerSchemas(args);
151
177
  const disconnect = createHttpDisconnectHandle();
152
178
  try {
153
179
  if (args.logger === false) {
@@ -198,6 +224,7 @@ class QgridFrameClass extends BaseFrameClass {
198
224
  async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
199
225
  rejectLegacyLogMode(args);
200
226
  rejectImageGenerationStream(args);
227
+ rejectInvalidCallerSchemas(args);
201
228
  const streamId = crypto.randomUUID();
202
229
  pendingStreams.set(streamId, args);
203
230
  const expiry = setTimeout(() => pendingStreams.delete(streamId), 30_000);