@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
@@ -1,6 +1,16 @@
1
+ import { Buffer } from "node:buffer";
2
+
1
3
  import { getLogger } from "@logtape/logtape";
4
+ import { z } from "zod";
2
5
 
3
- import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
6
+ // 응답(모델 출력) envelope 의 자원 한도. caller 스키마 전처리 한도(CALLER_SCHEMA_LIMITS)와 값이
7
+ // 같아 보여도 서로 다른 자원을 지키는 독립 상수다 — 스키마 깊이는 인스턴스 깊이를 bound 하지
8
+ // 않으므로(재귀 $ref 허용) 캘러 한도를 조정해도 응답 수용 기준이 따라 움직여선 안 된다.
9
+ const STRUCTURED_ENVELOPE_LIMITS = {
10
+ maxUtf8Bytes: 512 * 1024,
11
+ maxNodes: 20_000,
12
+ maxDepth: 132,
13
+ } as const;
4
14
  import {
5
15
  type QgridContent,
6
16
  type QgridThreadCoord,
@@ -10,136 +20,175 @@ import {
10
20
 
11
21
  const logger = getLogger(["qgrid", "tool-emulation"]);
12
22
 
13
- type ToolCallResponse = {
14
- action: "answer" | "tool_call";
23
+ type EmulationResult = Omit<QueryOutput, "content" | "finishReason" | "runContext">;
24
+ type ToolCall = { toolName: string; args: string };
25
+ type LegacyToolCallResponse = {
26
+ action?: "answer" | "tool_call";
15
27
  answer?: string | null;
16
- toolCalls?: Array<{ toolName: string; args: string }> | null;
28
+ toolCalls?: ToolCall[] | null;
17
29
  };
18
30
 
31
+ const StructuredToolCallResponse = z.discriminatedUnion("action", [
32
+ z.strictObject({
33
+ action: z.literal("answer"),
34
+ answer: z.json().refine((answer) => answer !== null, {
35
+ message: "answer must be non-null",
36
+ }),
37
+ toolCalls: z.union([z.null(), z.tuple([])]),
38
+ }),
39
+ z.strictObject({
40
+ action: z.literal("tool_call"),
41
+ answer: z.null(),
42
+ toolCalls: z
43
+ .array(
44
+ z.strictObject({
45
+ toolName: z.string(),
46
+ args: z.string(),
47
+ }),
48
+ )
49
+ .min(1),
50
+ }),
51
+ ]);
52
+ type StructuredResponse = z.infer<typeof StructuredToolCallResponse>;
53
+
54
+ export class ToolCallEmulationError extends Error {
55
+ constructor(message: string) {
56
+ super(`tool-call emulation: ${message}`);
57
+ this.name = "ToolCallEmulationError";
58
+ }
59
+ }
60
+
19
61
  // dispatcher 가 넘기는 이미지 결과. qgrid 는 base64 payload 만 전달하고 포맷은 보장하지 않는다.
20
- export interface EmulationImage {
62
+ interface EmulationImage {
21
63
  data: string;
22
64
  revisedPrompt?: string | null;
23
65
  }
24
66
 
25
- // 조립된 content 뒤에 이미지 파트를 append 한다. 모든 반환 분기가 공유.
26
- function appendImages(content: QgridContent[], images?: EmulationImage[]): QgridContent[] {
27
- if (!images?.length) return content;
28
- return [
29
- ...content,
30
- ...images.map(
31
- (img): QgridContent => ({
32
- type: "image",
33
- data: img.data,
34
- revisedPrompt: img.revisedPrompt ?? null,
35
- }),
36
- ),
37
- ];
38
- }
39
-
40
- export function buildToolCallSchema(tools: QgridTool[]): JsonValue {
41
- const toolDescriptions = tools
42
- .map((tool) => {
43
- const schema = JSON.stringify(tool.inputSchema);
44
- return `- ${tool.name}: ${tool.description ?? ""}\n inputSchema: ${schema}`;
45
- })
46
- .join("\n");
47
-
48
- return {
49
- type: "object",
50
- description:
51
- "Use this schema only through StructuredOutput. Do not invoke listed client tools as native Claude Code tools. To request client-side tool execution, set action to tool_call and include toolCalls. Use action answer only when no further client tool result is needed.",
52
- properties: {
53
- action: {
54
- type: "string",
55
- enum: ["answer", "tool_call"],
56
- description:
57
- "Use tool_call to request client-side tool execution through this structured output. Use answer only for a final answer.",
58
- },
59
- answer: { anyOf: [{ type: "string" }, { type: "null" }] },
60
- toolCalls: {
61
- description:
62
- "Client-side tool calls requested through structured output. Do not call these tools as native Claude Code tools.",
63
- anyOf: [
64
- {
65
- type: "array",
66
- items: {
67
- type: "object",
68
- properties: {
69
- toolName: {
70
- type: "string",
71
- enum: tools.map((tool) => tool.name),
72
- description: `Client-side tool name to request through structured output. Do not call this as a native Claude Code tool.\n${toolDescriptions}`,
73
- },
74
- args: {
75
- type: "string",
76
- description: "Tool arguments as a JSON string for the client-side tool.",
77
- },
78
- },
79
- required: ["toolName", "args"],
80
- additionalProperties: false,
81
- },
82
- },
83
- { type: "null" },
84
- ],
85
- },
86
- },
87
- required: ["action", "answer", "toolCalls"],
88
- additionalProperties: false,
89
- };
67
+ interface ToolCallEmulationOptions {
68
+ threadCoord?: QgridThreadCoord;
69
+ images?: EmulationImage[];
70
+ answerMode?: "legacy" | "structured";
90
71
  }
91
72
 
92
73
  export function applyToolCallEmulation(
93
- result: Omit<QueryOutput, "content" | "finishReason" | "runContext">,
74
+ result: EmulationResult,
94
75
  tools?: QgridTool[],
95
- threadCoord?: QgridThreadCoord,
96
- images?: EmulationImage[],
76
+ options: ToolCallEmulationOptions = {},
97
77
  ): QueryOutput {
98
- // thread 재사용 좌표를 runContext 로 실어 올린다 (auto 모드는 requestLogId 없이 threadCoord 만).
78
+ const { threadCoord, images, answerMode = "legacy" } = options;
99
79
  const runContext = threadCoord ? { threadCoord } : undefined;
100
- // 이미지는 finishReason 과 직교하는 content 파트. 조립된 content 뒤에 append 한다.
101
80
 
102
81
  if (!tools?.length) {
103
- const content: QgridContent[] = [{ type: "text", text: result.text }];
104
- return { ...result, content: appendImages(content, images), finishReason: "stop", runContext };
82
+ return answerOutput(result, result.text, images, runContext);
105
83
  }
106
84
 
107
- let parsed: ToolCallResponse;
85
+ if (answerMode === "legacy") {
86
+ return applyLegacyResponse(result, tools, images, runContext);
87
+ }
88
+
89
+ const parsed = parseStructuredResponse(result.text, tools);
90
+ if (parsed.action === "tool_call") {
91
+ return toolCallOutput(result, parsed.toolCalls, images, runContext);
92
+ }
93
+
94
+ return answerOutput(result, JSON.stringify(parsed.answer), images, runContext);
95
+ }
96
+
97
+ /**
98
+ * Tools-only requests keep the permissive 2.5.3 contract. Structured-output requests use the
99
+ * strict decoder below because their outer action envelope is an internal server contract.
100
+ */
101
+ function applyLegacyResponse(
102
+ result: EmulationResult,
103
+ tools: QgridTool[],
104
+ images: EmulationImage[] | undefined,
105
+ runContext: QueryOutput["runContext"],
106
+ ): QueryOutput {
107
+ let parsed: LegacyToolCallResponse;
108
108
  try {
109
- parsed = JSON.parse(result.text) as ToolCallResponse;
110
- } catch (e) {
111
- logger.warn(`tool-call emulation parse failed, falling back to text: ${(e as Error).message}`);
112
- return {
113
- ...result,
114
- content: appendImages([{ type: "text", text: result.text }], images),
115
- finishReason: "stop",
116
- runContext,
117
- };
109
+ parsed = JSON.parse(result.text) as LegacyToolCallResponse;
110
+ } catch (error) {
111
+ logger.warn(
112
+ `tool-call emulation parse failed, falling back to text: ${(error as Error).message}`,
113
+ );
114
+ return answerOutput(result, result.text, images, runContext);
118
115
  }
119
116
 
120
117
  if (parsed.action === "tool_call") {
121
118
  const toolCalls = parsed.toolCalls ?? [];
122
- const content: QgridContent[] = toolCalls.map((toolCall) => {
123
- if (!tools.some((tool) => tool.name === toolCall.toolName)) {
124
- throw new Error(`unknown emulated tool: ${toolCall.toolName}`);
125
- }
119
+ const unknownTool = findUnknownTool(toolCalls, tools);
120
+ if (unknownTool !== undefined) throw new Error(`unknown emulated tool: ${unknownTool}`);
121
+ return toolCallOutput(result, toolCalls, images, runContext);
122
+ }
123
+
124
+ return answerOutput(result, parsed.answer ?? result.text, images, runContext);
125
+ }
126
126
 
127
- return {
127
+ function parseStructuredResponse(text: string, tools: QgridTool[]) {
128
+ if (Buffer.byteLength(text, "utf8") > STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes) {
129
+ throw new ToolCallEmulationError(
130
+ `structured-output envelope exceeds UTF-8 byte limit of ${STRUCTURED_ENVELOPE_LIMITS.maxUtf8Bytes}`,
131
+ );
132
+ }
133
+
134
+ let value: unknown;
135
+ try {
136
+ value = JSON.parse(text);
137
+ } catch (error) {
138
+ throw new ToolCallEmulationError(
139
+ `invalid structured-output envelope: ${(error as Error).message}`,
140
+ );
141
+ }
142
+
143
+ assertStructuredEnvelopeComplexity(value);
144
+ const validation = StructuredToolCallResponse.safeParse(value);
145
+ if (!validation.success) {
146
+ const issue = validation.error.issues[0];
147
+ const path = issue && issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
148
+ const detail = issue?.message ?? "unknown validation issue";
149
+ throw new ToolCallEmulationError(`invalid structured-output envelope${path}: ${detail}`);
150
+ }
151
+
152
+ // Zod clones JSON objects and drops own "__proto__" keys. The parsed JSON is safe to return
153
+ // after validation and preserves the caller's exact structured value.
154
+ const response = value as StructuredResponse;
155
+ if (response.action === "tool_call") {
156
+ const unknownTool = findUnknownTool(response.toolCalls, tools);
157
+ if (unknownTool !== undefined) {
158
+ throw new ToolCallEmulationError(`unknown emulated tool: ${unknownTool}`);
159
+ }
160
+ }
161
+ return response;
162
+ }
163
+
164
+ function toolCallOutput(
165
+ result: EmulationResult,
166
+ toolCalls: ToolCall[],
167
+ images: EmulationImage[] | undefined,
168
+ runContext: QueryOutput["runContext"],
169
+ ): QueryOutput {
170
+ return {
171
+ ...result,
172
+ content: appendImages(
173
+ toolCalls.map((toolCall) => ({
128
174
  type: "tool-call",
129
175
  toolCallId: `call_${Math.random().toString(36).slice(2, 10)}`,
130
176
  toolName: toolCall.toolName,
131
177
  input: toolCall.args,
132
- };
133
- });
134
- return {
135
- ...result,
136
- content: appendImages(content, images),
137
- finishReason: "tool-calls",
138
- runContext,
139
- };
140
- }
178
+ })),
179
+ images,
180
+ ),
181
+ finishReason: "tool-calls",
182
+ runContext,
183
+ };
184
+ }
141
185
 
142
- const text = parsed.answer ?? result.text;
186
+ function answerOutput(
187
+ result: EmulationResult,
188
+ text: string,
189
+ images: EmulationImage[] | undefined,
190
+ runContext: QueryOutput["runContext"],
191
+ ): QueryOutput {
143
192
  return {
144
193
  ...result,
145
194
  text,
@@ -148,3 +197,51 @@ export function applyToolCallEmulation(
148
197
  runContext,
149
198
  };
150
199
  }
200
+
201
+ function findUnknownTool(toolCalls: ToolCall[], tools: QgridTool[]): string | undefined {
202
+ const knownToolNames = new Set(tools.map((tool) => tool.name));
203
+ return toolCalls.find((toolCall) => !knownToolNames.has(toolCall.toolName))?.toolName;
204
+ }
205
+
206
+ function appendImages(content: QgridContent[], images?: EmulationImage[]): QgridContent[] {
207
+ if (!images?.length) return content;
208
+ return [
209
+ ...content,
210
+ ...images.map(
211
+ (image): QgridContent => ({
212
+ type: "image",
213
+ data: image.data,
214
+ revisedPrompt: image.revisedPrompt ?? null,
215
+ }),
216
+ ),
217
+ ];
218
+ }
219
+
220
+ function assertStructuredEnvelopeComplexity(root: unknown): void {
221
+ const maxDepth = STRUCTURED_ENVELOPE_LIMITS.maxDepth;
222
+ const stack = [{ value: root, depth: 0 }];
223
+ let nodes = 0;
224
+
225
+ while (stack.length > 0) {
226
+ const current = stack.pop()!;
227
+ nodes += 1;
228
+ if (nodes > STRUCTURED_ENVELOPE_LIMITS.maxNodes) {
229
+ throw new ToolCallEmulationError(
230
+ `structured-output envelope exceeds node limit of ${STRUCTURED_ENVELOPE_LIMITS.maxNodes}`,
231
+ );
232
+ }
233
+ if (current.depth > maxDepth) {
234
+ throw new ToolCallEmulationError(
235
+ `structured-output envelope exceeds depth limit of ${maxDepth}`,
236
+ );
237
+ }
238
+ if (typeof current.value === "number" && !Number.isFinite(current.value)) {
239
+ throw new ToolCallEmulationError("answer must be valid JSON");
240
+ }
241
+ if (current.value === null || typeof current.value !== "object") continue;
242
+
243
+ for (const value of Object.values(current.value)) {
244
+ stack.push({ value, depth: current.depth + 1 });
245
+ }
246
+ }
247
+ }
@@ -0,0 +1,22 @@
1
+ import { getLogger } from "@logtape/logtape";
2
+ import { type FastifyReply } from "fastify";
3
+ import { isSoException } from "sonamu";
4
+
5
+ // statusCode 를 신뢰하는 범위를 Sonamu 예외 계약(isSoException)으로 한정한다. qgrid 는 프록시라
6
+ // upstream HTTP 오류(fetch/undici 류)도 statusCode 를 달고 나오는데, 그걸 duck-typing 으로
7
+ // 그대로 에코하면 upstream 의 401/429 가 호출자 자신의 오류로 둔갑한다. Sonamu 예외가 아니면 500.
8
+ export function handleServerError(error: Error, reply: FastifyReply): void {
9
+ getLogger(["qgrid"]).error(`${error}`);
10
+ const statusCode =
11
+ isSoException(error) &&
12
+ Number.isInteger(error.statusCode) &&
13
+ error.statusCode >= 400 &&
14
+ error.statusCode <= 599
15
+ ? error.statusCode
16
+ : 500;
17
+
18
+ reply.status(statusCode).send({
19
+ name: error.name,
20
+ message: error.message,
21
+ });
22
+ }
@@ -0,0 +1,37 @@
1
+ import { BadRequestException } from "sonamu";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { handleServerError } from "./server-error-handler";
5
+
6
+ function createReply() {
7
+ const send = vi.fn();
8
+ const status = vi.fn(() => ({ send }));
9
+ return { reply: { status } as never, send, status };
10
+ }
11
+
12
+ describe("handleServerError", () => {
13
+ it("preserves explicit client error status codes", () => {
14
+ const { reply, send, status } = createReply();
15
+ const error = new BadRequestException("invalid schema" as never);
16
+
17
+ handleServerError(error, reply);
18
+
19
+ expect(status).toHaveBeenCalledWith(400);
20
+ expect(send).toHaveBeenCalledWith({
21
+ name: error.name,
22
+ message: "invalid schema",
23
+ });
24
+ });
25
+
26
+ it.each([
27
+ ["ordinary errors", new Error("provider failed")],
28
+ ["invalid success-like status codes", Object.assign(new Error("wrong status"), { statusCode: 200 })],
29
+ ["out-of-range status codes", Object.assign(new Error("wrong status"), { statusCode: 700 })],
30
+ ])("keeps %s as internal server errors", (_label, error) => {
31
+ const { reply, status } = createReply();
32
+
33
+ handleServerError(error, reply);
34
+
35
+ expect(status).toHaveBeenCalledWith(500);
36
+ });
37
+ });
@@ -11,6 +11,7 @@ import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
11
11
  import { QgridFrame } from "./application/qgrid/qgrid.frame";
12
12
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
13
13
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
14
+ import { handleServerError } from "./server-error-handler";
14
15
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
15
16
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
16
17
 
@@ -219,11 +220,7 @@ export default defineConfig({
219
220
  log.info("graceful shutdown");
220
221
  },
221
222
  onError: (error, _request, reply) => {
222
- getLogger(["qgrid"]).error(`${error}`);
223
- reply.status(500).send({
224
- name: error.name,
225
- message: error.message,
226
- });
223
+ handleServerError(error, reply);
227
224
  },
228
225
  },
229
226
  },
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
4
+ import { ToolCallEmulationError } from "../../../application/qgrid/tool-emulation";
4
5
  import { type AnthropicCredentials } from "../../../application/token/token.types";
5
6
  import { type GenerateRequest } from "../common/provider-dispatcher";
6
7
  import { ANTHROPIC_DEFAULT_MODEL } from "./anthropic-constants";
@@ -175,6 +176,23 @@ describe("AnthropicDispatcher", () => {
175
176
  expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
176
177
  });
177
178
 
179
+ it("argv 안전 한도를 넘는 structured schema를 session 실행 전에 거부한다", async () => {
180
+ const d = new AnthropicDispatcher();
181
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
182
+
183
+ await expect(
184
+ d.generate(
185
+ baseReq({
186
+ outputSchema: {
187
+ type: "object",
188
+ description: "x".repeat(64 * 1024),
189
+ },
190
+ }),
191
+ ),
192
+ ).rejects.toThrow("Anthropic dispatch schema exceeds argv UTF-8 byte limit");
193
+ expect(runClaudeSessionMock).not.toHaveBeenCalled();
194
+ });
195
+
178
196
  it("요청 timeoutMs를 Claude session에 전달하고 미지정 시 240초 기본값을 쓴다", async () => {
179
197
  const d = new AnthropicDispatcher();
180
198
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -470,6 +488,32 @@ describe("AnthropicDispatcher", () => {
470
488
  expect(firstRunRequest().includePartialMessages).toBe(true);
471
489
  });
472
490
 
491
+ it("generateStream: completion callback throw를 onError로 전달하고 정상 종료한다", async () => {
492
+ const d = new AnthropicDispatcher();
493
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
494
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ text: "malformed envelope" }));
495
+ const completionError = new ToolCallEmulationError(
496
+ "response envelope is missing required keys: toolCalls",
497
+ );
498
+ const onComplete = vi.fn(() => {
499
+ throw completionError;
500
+ });
501
+ const onError = vi.fn();
502
+
503
+ await expect(
504
+ d.generateStream(baseReq(), {
505
+ onDelta: vi.fn(),
506
+ onComplete,
507
+ onError,
508
+ }),
509
+ ).resolves.toBeUndefined();
510
+
511
+ expect(onComplete).toHaveBeenCalledOnce();
512
+ expect(onError).toHaveBeenCalledOnce();
513
+ expect(onError).toHaveBeenCalledWith(completionError);
514
+ expect(firstRunRequest().includePartialMessages).toBe(true);
515
+ });
516
+
473
517
  it("generateStream: Claude server error 는 onError 로 전달하고 complete 하지 않는다", async () => {
474
518
  const d = new AnthropicDispatcher();
475
519
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -23,6 +23,7 @@ import {
23
23
  type GenerateStreamCallbacks,
24
24
  type ProviderDispatcher,
25
25
  } from "../common/provider-dispatcher";
26
+ import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
26
27
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
27
28
  import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
28
29
  import { readAnthropicQuotaUsage, type AnthropicQuotaUsageResult } from "./anthropic-quota";
@@ -287,8 +288,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
287
288
  // 의 "sonnet" 별칭 우회 차단. 정규화 규칙은 fallback 경로와 공유(canonicalAnthropicModel).
288
289
  assertSupportedOneMillionSuffix(req.model);
289
290
  const model = canonicalAnthropicModel(req.model);
290
- const jsonSchema =
291
- req.outputSchema !== undefined ? JSON.stringify(req.outputSchema) : undefined;
291
+ const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
292
292
 
293
293
  const token = await this.selectToken();
294
294
  if (!token) throw new Error("No anthropic tokens available");
@@ -234,6 +234,16 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
234
234
  expect(args).not.toContain("--max-turns");
235
235
  });
236
236
 
237
+ it("argv 안전 한도를 넘는 structured schema를 spawn 전에 거부한다", () => {
238
+ expect(() =>
239
+ buildClaudeArgs({
240
+ model: "m",
241
+ sessionId: "u",
242
+ jsonSchema: "x".repeat(SYSTEM_PROMPT_ARGV_MAX_BYTES + 1),
243
+ }),
244
+ ).toThrow("Anthropic dispatch schema exceeds argv UTF-8 byte limit");
245
+ });
246
+
237
247
  it("1M suffix 는 CLI --model 인자에만 반영된다", () => {
238
248
  const args = buildClaudeArgs({
239
249
  model: "claude-sonnet-4-6",
@@ -21,6 +21,10 @@ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
22
22
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
23
23
  import { type UserInput } from "../../../codex-protocol/v2/UserInput";
24
+ import {
25
+ ARGV_SAFE_MAX_UTF8_BYTES,
26
+ assertAnthropicSchemaArgvSize,
27
+ } from "../common/schema-validation";
24
28
  import { createTtftTracker } from "../common/ttft";
25
29
  import {
26
30
  ANTHROPIC_CLAUDE_CWD,
@@ -191,7 +195,7 @@ export function shouldPinStructuredRetries(opts: {
191
195
  // 시스템 프롬프트가 이를 넘으면 execve 가 E2BIG 로 거부된다. deti 등 대형 프롬프트는 실측
192
196
  // 평균 343KB·최대 485KB(opus-4-8)라 일상적으로 초과한다. 한계의 절반(64KB)을 임계값으로 잡아
193
197
  // UTF-8 멀티바이트·예측 못 한 여유분까지 안전 마진을 둔다.
194
- export const SYSTEM_PROMPT_ARGV_MAX_BYTES = 64 * 1024;
198
+ export const SYSTEM_PROMPT_ARGV_MAX_BYTES = ARGV_SAFE_MAX_UTF8_BYTES;
195
199
 
196
200
  export function buildClaudeArgs(opts: {
197
201
  model: string;
@@ -209,6 +213,7 @@ export function buildClaudeArgs(opts: {
209
213
  includePartialMessages?: boolean;
210
214
  }): Array<string> {
211
215
  const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
216
+ if (useStructured) assertAnthropicSchemaArgvSize(opts.jsonSchema!);
212
217
  // --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
213
218
  const toolArgs = useStructured
214
219
  ? ["--tools", "", "--allowed-tools", "StructuredOutput"]
@@ -0,0 +1,72 @@
1
+ /**
2
+ * JSON Schema 하위 스키마 위치 목록 — qgrid 의 스키마 순회 3곳이 공유하는 단일 정본.
3
+ *
4
+ * - `tool-emulation-schema.rewriteSchemaNode`: 사용자 스키마의 local `$ref` 를 namespace 로 rebase
5
+ * - `strictifier.assertNormalizationCompatibility`: provider 표현 가능성 검사
6
+ * - `strictifier.strictifyNode`: strict 재작성
7
+ *
8
+ * 세 소비자가 각자 테이블을 들고 있으면 새 keyword 를 한쪽에만 추가하는 순간 조용히 어긋난다.
9
+ * 재작성 패스가 훑지 않는 위치의 `$ref` 는 rebase 되지 않은 채 남고, 검사 패스가 놓친 위치의
10
+ * tuple 은 정규화 없이 provider 로 샌다. 실제로 통합 전 `additionalItems` 가 그 상태였다.
11
+ * 위치 목록은 여기서만 관리한다.
12
+ *
13
+ * 왜 이런 순회가 필요한지는 스킬 문서 참고:
14
+ * packages/cli/skills/qgrid/references/tool-calling-and-multiturn.md
15
+ *
16
+ * 소비자별로 다른 것은 위치 목록이 아니라 **각 위치를 어떻게 다루는가** 이므로(strictifier 의
17
+ * `normalizable` 플래그 등) 그 판단은 각 소비자가 이 목록 위에 얹는다.
18
+ */
19
+
20
+ /** 이름 → 하위 스키마 맵. 예: `properties`, `$defs`. */
21
+ export const SCHEMA_MAP_KEYWORDS = [
22
+ "$defs",
23
+ "definitions",
24
+ "dependentSchemas",
25
+ "patternProperties",
26
+ "properties",
27
+ ] as const;
28
+
29
+ /** 하위 스키마 배열. 예: `anyOf`, `prefixItems`. */
30
+ export const SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
31
+
32
+ /**
33
+ * 하위 스키마 하나를 직접 갖는 위치. `items` 와 `additionalItems` 는 Draft-7 tuple 형식에서
34
+ * 배열이 올 수 있으므로 소비자가 배열 분기를 따로 처리한다.
35
+ */
36
+ export const SCHEMA_SINGLE_KEYWORDS = [
37
+ "additionalItems",
38
+ "additionalProperties",
39
+ "contains",
40
+ "contentSchema",
41
+ "else",
42
+ "if",
43
+ "items",
44
+ "not",
45
+ "propertyNames",
46
+ "then",
47
+ "unevaluatedItems",
48
+ "unevaluatedProperties",
49
+ ] as const;
50
+
51
+ /**
52
+ * Draft-7 `dependencies` 는 값이 스키마이거나 property 이름 배열이라 위 분류에 들어가지 않는다.
53
+ * 배열 형태는 스키마가 아니므로 순회 대상이 아니다.
54
+ */
55
+ export const SCHEMA_DEPENDENCIES_KEYWORD = "dependencies";
56
+
57
+ /**
58
+ * qgrid 가 거부하는 참조/식별 keyword.
59
+ *
60
+ * 사용자 스키마는 envelope 의 `$defs` 아래로 옮겨지고 정의는 전역적으로 정규화되므로, base URI 를
61
+ * 새로 선언하거나(`$id`) 런타임 스코프로 대상이 정해지는 참조(`$dynamicRef` 등)는 옮긴 뒤의 의미를
62
+ * 보장할 수 없다. 조용히 잘못 rebase 하는 대신 명시적으로 거부한다.
63
+ */
64
+ export const UNSUPPORTED_REFERENCE_KEYWORDS = [
65
+ "$id",
66
+ "id",
67
+ "$anchor",
68
+ "$dynamicAnchor",
69
+ "$recursiveAnchor",
70
+ "$dynamicRef",
71
+ "$recursiveRef",
72
+ ] as const;