@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
|
@@ -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
|
-
|
|
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
|
|
14
|
-
|
|
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?:
|
|
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
|
-
|
|
62
|
+
interface EmulationImage {
|
|
21
63
|
data: string;
|
|
22
64
|
revisedPrompt?: string | null;
|
|
23
65
|
}
|
|
24
66
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
74
|
+
result: EmulationResult,
|
|
94
75
|
tools?: QgridTool[],
|
|
95
|
-
|
|
96
|
-
images?: EmulationImage[],
|
|
76
|
+
options: ToolCallEmulationOptions = {},
|
|
97
77
|
): QueryOutput {
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
return { ...result, content: appendImages(content, images), finishReason: "stop", runContext };
|
|
82
|
+
return answerOutput(result, result.text, images, runContext);
|
|
105
83
|
}
|
|
106
84
|
|
|
107
|
-
|
|
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
|
|
110
|
-
} catch (
|
|
111
|
-
logger.warn(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
}
|
|
178
|
+
})),
|
|
179
|
+
images,
|
|
180
|
+
),
|
|
181
|
+
finishReason: "tool-calls",
|
|
182
|
+
runContext,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
141
185
|
|
|
142
|
-
|
|
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
|
-
|
|
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 =
|
|
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"]
|