@cartanova/qgrid-cli 2.5.3 → 2.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +7 -2
- package/README.md +7 -2
- package/bundle/dist/application/monit/monit.frame.js +3 -3
- package/bundle/dist/application/qgrid/oauth.js +1 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +36 -14
- package/bundle/dist/application/qgrid/qgrid.frame.js +19 -5
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +121 -0
- package/bundle/dist/application/qgrid/tool-emulation.js +116 -96
- package/bundle/dist/application/request-log/request-log.model.js +3 -3
- package/bundle/dist/application/request-log-step/request-log-step.model.js +2 -2
- package/bundle/dist/application/token/token.model.js +2 -2
- package/bundle/dist/server-error-handler.js +15 -0
- package/bundle/dist/sonamu.config.js +4 -7
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +4 -3
- package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
- package/bundle/dist/utils/providers/common/schema-validation.js +178 -0
- package/bundle/dist/utils/providers/common/strictifier.js +329 -42
- package/bundle/dist/utils/providers/openai/codex-worker.js +16 -8
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +5 -37
- package/bundle/dist/utils/providers/openai/openai-worker-pool-config.js +37 -0
- package/bundle/src/application/monit/monit.frame.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +103 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +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 +176 -0
- package/bundle/src/application/qgrid/tool-emulation.test.ts +285 -67
- package/bundle/src/application/qgrid/tool-emulation.ts +199 -102
- package/bundle/src/server-error-handler.ts +22 -0
- package/bundle/src/sonamu.config.test.ts +37 -0
- package/bundle/src/sonamu.config.ts +2 -5
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +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/schema-validation.test.ts +284 -0
- package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
- package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
- package/bundle/src/utils/providers/common/strictifier.ts +513 -52
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +31 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +12 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +6 -4
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +6 -79
- package/bundle/src/utils/providers/openai/openai-worker-pool-config.ts +80 -0
- package/bundle/web-dist/client/assets/{index-DXdGBfB8.js → index-DiY5V2e3.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-D_j1yxC4.js → logs-UZ4Cvdmc.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CIToyoxW.js → monit-C5cJo-HD.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Dd21A-wi.js → routes-DEKEmoQN.js} +1 -1
- package/bundle/web-dist/client/assets/{show-DfePWRbc.js → show-DnX6b8KG.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-B4MV2fdk.js → tokens-Ct0FGD4b.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/references/ai-sdk-provider-contract.md +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/repo-map.md +2 -1
- package/skills/qgrid/references/tool-calling-and-multiturn.md +33 -6
- package/skills/qgrid/references/verification-and-debugging.md +16 -1
|
@@ -56,15 +56,19 @@ vi.mock("../token/token.model", () => ({
|
|
|
56
56
|
},
|
|
57
57
|
}));
|
|
58
58
|
|
|
59
|
-
vi.mock("./qgrid.dispatcher", () =>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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);
|