@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.
- 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 +41 -15
- package/bundle/dist/application/qgrid/qgrid.frame.js +74 -28
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
- 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 +5 -4
- package/bundle/dist/utils/providers/anthropic/claude-session.js +7 -5
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
- 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 +358 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +50 -28
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +629 -8
- package/bundle/src/application/qgrid/qgrid.frame.ts +107 -27
- package/bundle/src/application/qgrid/qgrid.types.ts +4 -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 +56 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +3 -3
- 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/anthropic/stream-json-adapter.test.ts +66 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +12 -3
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
- package/bundle/src/utils/providers/common/schema-validation.test.ts +284 -0
- package/bundle/src/utils/providers/common/schema-validation.ts +308 -0
- package/bundle/src/utils/providers/common/strictifier.test.ts +746 -0
- package/bundle/src/utils/providers/common/strictifier.ts +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-BQuKeBC5.js → index-DiY5V2e3.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BSsHMIKT.js → logs-UZ4Cvdmc.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D5ypGmGi.js → monit-C5cJo-HD.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-BsCbz225.js → routes-DEKEmoQN.js} +1 -1
- package/bundle/web-dist/client/assets/{show-CXPhLZCl.js → show-DnX6b8KG.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-gEGH52O8.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 +42 -2
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +31 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
- package/skills/qgrid/references/decision-rationale.md +11 -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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
1
3
|
import { Sonamu } from "sonamu";
|
|
2
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
5
|
|
|
@@ -54,15 +56,19 @@ vi.mock("../token/token.model", () => ({
|
|
|
54
56
|
},
|
|
55
57
|
}));
|
|
56
58
|
|
|
57
|
-
vi.mock("./qgrid.dispatcher", () =>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
},
|
|
63
69
|
},
|
|
64
|
-
}
|
|
65
|
-
})
|
|
70
|
+
};
|
|
71
|
+
});
|
|
66
72
|
|
|
67
73
|
vi.mock("./qgrid-run-lifecycle", () => ({
|
|
68
74
|
beforeQuery: beforeQueryMock,
|
|
@@ -88,6 +94,10 @@ const tokenEntry = {
|
|
|
88
94
|
weight: 1,
|
|
89
95
|
};
|
|
90
96
|
|
|
97
|
+
function deeplyNestedInputSchema(depth: number): unknown {
|
|
98
|
+
return JSON.parse(`${"[".repeat(depth)}0${"]".repeat(depth)}`) as unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
describe("QgridFrame.updateToken", () => {
|
|
92
102
|
beforeEach(() => {
|
|
93
103
|
findOneMock.mockReset();
|
|
@@ -144,6 +154,10 @@ describe("QgridFrame.updateToken", () => {
|
|
|
144
154
|
});
|
|
145
155
|
|
|
146
156
|
describe("QgridFrame.query request logging", () => {
|
|
157
|
+
afterEach(() => {
|
|
158
|
+
vi.restoreAllMocks();
|
|
159
|
+
});
|
|
160
|
+
|
|
147
161
|
beforeEach(() => {
|
|
148
162
|
beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
|
|
149
163
|
afterQueryMock.mockReset().mockResolvedValue({});
|
|
@@ -203,6 +217,34 @@ describe("QgridFrame.query request logging", () => {
|
|
|
203
217
|
expect(result.runContext).toEqual(queryOutput().runContext);
|
|
204
218
|
});
|
|
205
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
|
+
|
|
206
248
|
it("does not create or update logs when logger is false", async () => {
|
|
207
249
|
const output = queryOutput();
|
|
208
250
|
dispatcherQueryMock.mockResolvedValueOnce(output);
|
|
@@ -237,6 +279,59 @@ describe("QgridFrame.query request logging", () => {
|
|
|
237
279
|
expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
|
|
238
280
|
});
|
|
239
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
|
+
|
|
299
|
+
it("aborts provider execution and marks the run aborted when the HTTP response closes early", async () => {
|
|
300
|
+
const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
|
|
301
|
+
const responseRaw = Object.assign(new EventEmitter(), {
|
|
302
|
+
destroyed: false,
|
|
303
|
+
writableEnded: false,
|
|
304
|
+
});
|
|
305
|
+
vi.spyOn(Sonamu, "getContext").mockReturnValue({
|
|
306
|
+
transport: "http",
|
|
307
|
+
request: { raw: requestRaw },
|
|
308
|
+
reply: { raw: responseRaw },
|
|
309
|
+
} as never);
|
|
310
|
+
let providerSignal: AbortSignal | undefined;
|
|
311
|
+
dispatcherQueryMock.mockImplementationOnce(
|
|
312
|
+
(_args: unknown, signal: AbortSignal) =>
|
|
313
|
+
new Promise((_resolve, reject) => {
|
|
314
|
+
providerSignal = signal;
|
|
315
|
+
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
|
316
|
+
once: true,
|
|
317
|
+
});
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
const args = { prompt: "hi", model: "anthropic/claude-opus-5" };
|
|
321
|
+
|
|
322
|
+
const queryPromise = QgridFrame.query(args);
|
|
323
|
+
await vi.waitFor(() => expect(providerSignal).toBeDefined());
|
|
324
|
+
responseRaw.destroyed = true;
|
|
325
|
+
responseRaw.emit("close");
|
|
326
|
+
|
|
327
|
+
await expect(queryPromise).rejects.toThrow("provider aborted");
|
|
328
|
+
expect(providerSignal?.aborted).toBe(true);
|
|
329
|
+
expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args);
|
|
330
|
+
expect(finishRunWithErrorMock).not.toHaveBeenCalled();
|
|
331
|
+
expect(requestRaw.listenerCount("aborted")).toBe(0);
|
|
332
|
+
expect(responseRaw.listenerCount("close")).toBe(0);
|
|
333
|
+
});
|
|
334
|
+
|
|
240
335
|
it("returns the provider result when afterQuery persistence fails", async () => {
|
|
241
336
|
const output = queryOutput();
|
|
242
337
|
dispatcherQueryMock.mockResolvedValueOnce(output);
|
|
@@ -261,11 +356,365 @@ describe("QgridFrame.query request logging", () => {
|
|
|
261
356
|
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
262
357
|
});
|
|
263
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
|
+
|
|
264
706
|
it("rejects legacy logMode in the wire schema", () => {
|
|
265
707
|
expect(QueryInput.safeParse({ prompt: "hi", logMode: "none" }).success).toBe(false);
|
|
266
708
|
expect(QueryInput.safeParse({ prompt: "hi", logMode: undefined }).success).toBe(false);
|
|
267
709
|
expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
|
|
268
710
|
});
|
|
711
|
+
|
|
712
|
+
it("accepts bounded positive integer timeout values only", () => {
|
|
713
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
|
|
714
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
|
|
715
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 1.5 }).success).toBe(false);
|
|
716
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 1_800_001 }).success).toBe(false);
|
|
717
|
+
});
|
|
269
718
|
});
|
|
270
719
|
|
|
271
720
|
describe("QgridFrame.prepareStream", () => {
|
|
@@ -286,6 +735,96 @@ describe("QgridFrame.prepareStream", () => {
|
|
|
286
735
|
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
287
736
|
expect(requestLogSaveMock).not.toHaveBeenCalled();
|
|
288
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
|
+
});
|
|
289
828
|
});
|
|
290
829
|
|
|
291
830
|
describe("QgridFrame raw lifecycle API", () => {
|
|
@@ -471,6 +1010,88 @@ describe("QgridFrame.queryStream request logging", () => {
|
|
|
471
1010
|
expect(sse.publish).toHaveBeenCalledWith("error", { message: "provider failed" });
|
|
472
1011
|
});
|
|
473
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
|
+
|
|
474
1095
|
it("marks the run aborted on close regardless of the provider error message", async () => {
|
|
475
1096
|
const sse = installSseContext();
|
|
476
1097
|
dispatcherQueryStreamMock.mockImplementationOnce(
|