@cartanova/qgrid-cli 2.5.1 → 2.5.3
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/bundle/dist/application/qgrid/qgrid.dispatcher.js +6 -2
- package/bundle/dist/application/qgrid/qgrid.frame.js +56 -24
- package/bundle/dist/application/qgrid/qgrid.types.js +5 -3
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +2 -2
- package/bundle/dist/utils/providers/anthropic/claude-session.js +9 -3
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +6 -4
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +21 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +5 -3
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +49 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +79 -26
- package/bundle/src/application/qgrid/qgrid.types.ts +4 -1
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +12 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +1 -1
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +19 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +24 -2
- 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/web-dist/client/assets/{index-X4A9Xo_5.js → index-DXdGBfB8.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BK0afqFW.js → logs-D_j1yxC4.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D-lgl5dR.js → monit-CIToyoxW.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-CyRdgbvG.js → routes-Dd21A-wi.js} +1 -1
- package/bundle/web-dist/client/assets/{show-DpxKqqVU.js → show-DfePWRbc.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-9eO1FvFK.js → tokens-B4MV2fdk.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 +8 -0
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +36 -3
- package/skills/qgrid/references/decision-rationale.md +2 -0
|
@@ -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
|
|
|
@@ -144,6 +146,10 @@ describe("QgridFrame.updateToken", () => {
|
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
describe("QgridFrame.query request logging", () => {
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
vi.restoreAllMocks();
|
|
151
|
+
});
|
|
152
|
+
|
|
147
153
|
beforeEach(() => {
|
|
148
154
|
beforeQueryMock.mockReset().mockResolvedValue({ requestLogId: 41, stepIndex: 0 });
|
|
149
155
|
afterQueryMock.mockReset().mockResolvedValue({});
|
|
@@ -237,6 +243,42 @@ describe("QgridFrame.query request logging", () => {
|
|
|
237
243
|
expect(finishRunWithErrorMock).toHaveBeenCalledWith(41, "provider failed", args);
|
|
238
244
|
});
|
|
239
245
|
|
|
246
|
+
it("aborts provider execution and marks the run aborted when the HTTP response closes early", async () => {
|
|
247
|
+
const requestRaw = Object.assign(new EventEmitter(), { aborted: false });
|
|
248
|
+
const responseRaw = Object.assign(new EventEmitter(), {
|
|
249
|
+
destroyed: false,
|
|
250
|
+
writableEnded: false,
|
|
251
|
+
});
|
|
252
|
+
vi.spyOn(Sonamu, "getContext").mockReturnValue({
|
|
253
|
+
transport: "http",
|
|
254
|
+
request: { raw: requestRaw },
|
|
255
|
+
reply: { raw: responseRaw },
|
|
256
|
+
} as never);
|
|
257
|
+
let providerSignal: AbortSignal | undefined;
|
|
258
|
+
dispatcherQueryMock.mockImplementationOnce(
|
|
259
|
+
(_args: unknown, signal: AbortSignal) =>
|
|
260
|
+
new Promise((_resolve, reject) => {
|
|
261
|
+
providerSignal = signal;
|
|
262
|
+
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
|
263
|
+
once: true,
|
|
264
|
+
});
|
|
265
|
+
}),
|
|
266
|
+
);
|
|
267
|
+
const args = { prompt: "hi", model: "anthropic/claude-opus-5" };
|
|
268
|
+
|
|
269
|
+
const queryPromise = QgridFrame.query(args);
|
|
270
|
+
await vi.waitFor(() => expect(providerSignal).toBeDefined());
|
|
271
|
+
responseRaw.destroyed = true;
|
|
272
|
+
responseRaw.emit("close");
|
|
273
|
+
|
|
274
|
+
await expect(queryPromise).rejects.toThrow("provider aborted");
|
|
275
|
+
expect(providerSignal?.aborted).toBe(true);
|
|
276
|
+
expect(finishRunAbortedMock).toHaveBeenCalledWith(41, args);
|
|
277
|
+
expect(finishRunWithErrorMock).not.toHaveBeenCalled();
|
|
278
|
+
expect(requestRaw.listenerCount("aborted")).toBe(0);
|
|
279
|
+
expect(responseRaw.listenerCount("close")).toBe(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
240
282
|
it("returns the provider result when afterQuery persistence fails", async () => {
|
|
241
283
|
const output = queryOutput();
|
|
242
284
|
dispatcherQueryMock.mockResolvedValueOnce(output);
|
|
@@ -266,6 +308,13 @@ describe("QgridFrame.query request logging", () => {
|
|
|
266
308
|
expect(QueryInput.safeParse({ prompt: "hi", logMode: undefined }).success).toBe(false);
|
|
267
309
|
expect(QueryInput.safeParse({ prompt: "hi", logger: false }).success).toBe(true);
|
|
268
310
|
});
|
|
311
|
+
|
|
312
|
+
it("accepts bounded positive integer timeout values only", () => {
|
|
313
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 360_000 }).success).toBe(true);
|
|
314
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 0 }).success).toBe(false);
|
|
315
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 1.5 }).success).toBe(false);
|
|
316
|
+
expect(QueryInput.safeParse({ prompt: "hi", timeout: 1_800_001 }).success).toBe(false);
|
|
317
|
+
});
|
|
269
318
|
});
|
|
270
319
|
|
|
271
320
|
describe("QgridFrame.prepareStream", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import { getLogger } from "@logtape/logtape";
|
|
4
|
-
import { type FastifyReply } from "fastify";
|
|
4
|
+
import { type FastifyReply, type FastifyRequest } from "fastify";
|
|
5
5
|
import { api, BadRequestException, BaseFrameClass, Sonamu, stream } from "sonamu";
|
|
6
6
|
import { getCacheManagerRef } from "sonamu/cache";
|
|
7
7
|
|
|
@@ -100,6 +100,43 @@ function rejectLegacyLogMode(args: QueryInput): void {
|
|
|
100
100
|
);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
function createHttpDisconnectHandle(): {
|
|
104
|
+
signal: AbortSignal;
|
|
105
|
+
dispose: () => void;
|
|
106
|
+
} {
|
|
107
|
+
const abortController = new AbortController();
|
|
108
|
+
let requestRaw: FastifyRequest["raw"] | undefined;
|
|
109
|
+
let responseRaw: FastifyReply["raw"] | undefined;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const ctx = Sonamu.getContext();
|
|
113
|
+
requestRaw = ctx.request?.raw;
|
|
114
|
+
if (ctx.transport === "http") responseRaw = ctx.reply.raw;
|
|
115
|
+
} catch {
|
|
116
|
+
// Direct frame calls (for example unit tests) do not have an HTTP context.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const abort = () => abortController.abort();
|
|
120
|
+
const abortOnIncompleteResponseClose = () => {
|
|
121
|
+
if (!responseRaw?.writableEnded) abort();
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
requestRaw?.once("aborted", abort);
|
|
125
|
+
responseRaw?.once("close", abortOnIncompleteResponseClose);
|
|
126
|
+
|
|
127
|
+
if (requestRaw?.aborted || (responseRaw?.destroyed && !responseRaw.writableEnded)) {
|
|
128
|
+
abort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
signal: abortController.signal,
|
|
133
|
+
dispose: () => {
|
|
134
|
+
requestRaw?.removeListener("aborted", abort);
|
|
135
|
+
responseRaw?.removeListener("close", abortOnIncompleteResponseClose);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
const logger = getLogger(["qgrid"]);
|
|
104
141
|
const oauthLogger = getLogger(["qgrid", "oauth"]);
|
|
105
142
|
|
|
@@ -111,33 +148,49 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
111
148
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
112
149
|
async query(args: QueryInput): Promise<QueryOutput> {
|
|
113
150
|
rejectLegacyLogMode(args);
|
|
114
|
-
|
|
115
|
-
return QgridDispatcher.query(args, args.timeout);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const { requestLogId, stepIndex } = await beforeQuery(args);
|
|
119
|
-
let result: QueryOutput;
|
|
151
|
+
const disconnect = createHttpDisconnectHandle();
|
|
120
152
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
throw e;
|
|
125
|
-
}
|
|
153
|
+
if (args.logger === false) {
|
|
154
|
+
return await QgridDispatcher.query(args, disconnect.signal);
|
|
155
|
+
}
|
|
126
156
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
const { requestLogId, stepIndex } = await beforeQuery(args);
|
|
158
|
+
let result: QueryOutput;
|
|
159
|
+
try {
|
|
160
|
+
result = await QgridDispatcher.query(args, disconnect.signal);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (disconnect.signal.aborted) {
|
|
163
|
+
await finishRunAborted(requestLogId, args);
|
|
164
|
+
} else {
|
|
165
|
+
await finishRunWithError(requestLogId, (e as Error).message, args);
|
|
166
|
+
}
|
|
167
|
+
throw e;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const lifecycle = await afterQuery(requestLogId, stepIndex, args, result);
|
|
172
|
+
if (disconnect.signal.aborted) {
|
|
173
|
+
await finishRunAborted(requestLogId, args);
|
|
174
|
+
}
|
|
175
|
+
// tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
|
|
176
|
+
const threadCoord = result.runContext?.threadCoord;
|
|
177
|
+
const runContext =
|
|
178
|
+
lifecycle.runContext || threadCoord
|
|
179
|
+
? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
|
|
180
|
+
: undefined;
|
|
181
|
+
return { ...result, runContext };
|
|
182
|
+
} catch (e) {
|
|
183
|
+
logger.error(`query afterQuery failed: ${(e as Error).message}`);
|
|
184
|
+
if (disconnect.signal.aborted) {
|
|
185
|
+
await finishRunAborted(requestLogId, args);
|
|
186
|
+
} else {
|
|
187
|
+
await finishRunWithError(requestLogId, (e as Error).message, args);
|
|
188
|
+
}
|
|
189
|
+
// provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
} finally {
|
|
193
|
+
disconnect.dispose();
|
|
141
194
|
}
|
|
142
195
|
}
|
|
143
196
|
|
|
@@ -69,6 +69,9 @@ export const QgridInputPart = z.discriminatedUnion("type", [
|
|
|
69
69
|
]);
|
|
70
70
|
export type QgridInputPart = z.infer<typeof QgridInputPart>;
|
|
71
71
|
|
|
72
|
+
export const MAX_QUERY_TIMEOUT_MS = 30 * 60_000;
|
|
73
|
+
export const QueryTimeoutMs = z.number().int().positive().max(MAX_QUERY_TIMEOUT_MS);
|
|
74
|
+
|
|
72
75
|
// ─── Run Lifecycle Context (SDK ↔ Server contract) ───
|
|
73
76
|
|
|
74
77
|
// codex thread 좌표. thread 재사용으로 conversation_id(=prompt_cache_key)를 고정해
|
|
@@ -103,7 +106,7 @@ export const QueryInput = z
|
|
|
103
106
|
prompt: z.string(),
|
|
104
107
|
input: z.array(QgridInputPart).optional(),
|
|
105
108
|
model: z.string().optional(),
|
|
106
|
-
timeout:
|
|
109
|
+
timeout: QueryTimeoutMs.optional(),
|
|
107
110
|
jsonSchema: z.string().optional(),
|
|
108
111
|
tools: z.array(QgridTool).optional(),
|
|
109
112
|
effort: z.string().optional(),
|
|
@@ -175,6 +175,18 @@ describe("AnthropicDispatcher", () => {
|
|
|
175
175
|
expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it("요청 timeoutMs를 Claude session에 전달하고 미지정 시 240초 기본값을 쓴다", async () => {
|
|
179
|
+
const d = new AnthropicDispatcher();
|
|
180
|
+
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
181
|
+
|
|
182
|
+
await d.generate(baseReq({ timeoutMs: 360_000 }));
|
|
183
|
+
expect(firstRunRequest().timeoutMs).toBe(360_000);
|
|
184
|
+
|
|
185
|
+
runClaudeSessionMock.mockClear();
|
|
186
|
+
await d.generate(baseReq());
|
|
187
|
+
expect(firstRunRequest().timeoutMs).toBe(240_000);
|
|
188
|
+
});
|
|
189
|
+
|
|
178
190
|
it("quota 소진 → 에러", async () => {
|
|
179
191
|
const d = new AnthropicDispatcher();
|
|
180
192
|
d.onTokenAdded(1, "tok-A", creds(), null, 1);
|
|
@@ -326,7 +326,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
326
326
|
system: req.systemPrompt,
|
|
327
327
|
jsonSchema,
|
|
328
328
|
effort: req.effort,
|
|
329
|
-
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
329
|
+
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
330
330
|
coldHistory: req.coldHistory,
|
|
331
331
|
input: req.coldInput,
|
|
332
332
|
abortSignal: req.abortSignal,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
makeAnthropicWorkerId,
|
|
12
12
|
oneMillionEnv,
|
|
13
13
|
runClaudeSession,
|
|
14
|
+
shouldPinStructuredRetries,
|
|
14
15
|
structuredOutputRetriesEnv,
|
|
15
16
|
thinkingEnv,
|
|
16
17
|
SYSTEM_PROMPT_ARGV_MAX_BYTES,
|
|
@@ -161,6 +162,24 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
|
161
162
|
expect(structuredOutputRetriesEnv("5")).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "5" });
|
|
162
163
|
});
|
|
163
164
|
|
|
165
|
+
// SON-495 의 retry 고정 사유(스트림 retry = wall-clock 2배)는 스트리밍에만 성립한다.
|
|
166
|
+
// 논스트림 generate 까지 묶으면 1회 reject 가 곧 실패라 Claude 계열 structured 실패율이 치솟는다.
|
|
167
|
+
it("shouldPinStructuredRetries: structured + 스트리밍일 때만 retry 를 고정한다", () => {
|
|
168
|
+
expect(shouldPinStructuredRetries({ useStructured: true, includePartialMessages: true })).toBe(
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
// 논스트림 structured: CC 기본 retry 허용
|
|
172
|
+
expect(shouldPinStructuredRetries({ useStructured: true, includePartialMessages: false })).toBe(
|
|
173
|
+
false,
|
|
174
|
+
);
|
|
175
|
+
expect(shouldPinStructuredRetries({ useStructured: true })).toBe(false);
|
|
176
|
+
// text 모드는 스트리밍 여부와 무관하게 영향 없음
|
|
177
|
+
expect(shouldPinStructuredRetries({ useStructured: false, includePartialMessages: true })).toBe(
|
|
178
|
+
false,
|
|
179
|
+
);
|
|
180
|
+
expect(shouldPinStructuredRetries({ useStructured: false })).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
164
183
|
it("cold-only: 항상 --session-id 를 사용하고 continuation flag 는 쓰지 않는다", () => {
|
|
165
184
|
const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1" });
|
|
166
185
|
const continuationFlag = "--resume";
|
|
@@ -171,6 +171,22 @@ export function structuredOutputRetriesEnv(raw = process.env.MAX_STRUCTURED_OUTP
|
|
|
171
171
|
return { MAX_STRUCTURED_OUTPUT_RETRIES: String(Math.max(parsed, 1)) };
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// SON-495 의 retry 고정은 "스트리밍 + structured" 조합 한정 정책이다. 스트림에서 retry 가 돌면
|
|
175
|
+
// wall-clock 이 2배가 되어 실용성이 없기 때문이다(원 결정 사유). 논스트림 generate 는 애초에 완료를
|
|
176
|
+
// 기다리므로 그 사유가 성립하지 않는다. 반면 Claude 계열은 OpenAI 대비 structured output 준수율이
|
|
177
|
+
// 낮아, 논스트림까지 retry=1 로 묶으면 1회 reject 가 곧바로
|
|
178
|
+
// `error_max_structured_output_retries` 로 직결된다(실측: deti_production opus-4-8 실패율 39.6%).
|
|
179
|
+
// 스트리밍 여부는 `--include-partial-messages` 를 붙이는 generateStream 경로에서만 true 가 되므로
|
|
180
|
+
// (anthropic-dispatcher 의 generate/generateStream 분기) 이를 실질 구분자로 쓴다.
|
|
181
|
+
// 비성공 subtype 을 항상 isError 로 올리는 SON-495 의 나머지 절반은 그대로 유지된다 — retry 후에도
|
|
182
|
+
// degenerate 출력이면 조용히 통과시키지 않고 정직하게 실패한다.
|
|
183
|
+
export function shouldPinStructuredRetries(opts: {
|
|
184
|
+
useStructured: boolean;
|
|
185
|
+
includePartialMessages?: boolean;
|
|
186
|
+
}): boolean {
|
|
187
|
+
return opts.useStructured && opts.includePartialMessages === true;
|
|
188
|
+
}
|
|
189
|
+
|
|
174
190
|
// Linux 단일 argv 인자 한계(MAX_ARG_STRLEN = PAGE_SIZE×32 = 128KB, 커널 하드코딩 상수)
|
|
175
191
|
// 시스템 프롬프트가 이를 넘으면 execve 가 E2BIG 로 거부된다. deti 등 대형 프롬프트는 실측
|
|
176
192
|
// 평균 343KB·최대 485KB(opus-4-8)라 일상적으로 초과한다. 한계의 절반(64KB)을 임계값으로 잡아
|
|
@@ -311,8 +327,14 @@ export function runClaudeSession(
|
|
|
311
327
|
CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
|
|
312
328
|
CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
|
|
313
329
|
CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
|
|
314
|
-
// structured
|
|
315
|
-
|
|
330
|
+
// structured + 스트리밍일 때만 retry 를 고정한다(SON-495). text 모드와 논스트림
|
|
331
|
+
// generate 는 CC 기본 retry 를 그대로 쓴다.
|
|
332
|
+
...(shouldPinStructuredRetries({
|
|
333
|
+
useStructured,
|
|
334
|
+
includePartialMessages: req.includePartialMessages,
|
|
335
|
+
})
|
|
336
|
+
? structuredOutputRetriesEnv()
|
|
337
|
+
: {}),
|
|
316
338
|
},
|
|
317
339
|
});
|
|
318
340
|
|
|
@@ -265,6 +265,72 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
|
|
|
265
265
|
expect(result!.costUsd).toBe(0.0042);
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
+
it("structured retry 성공: 첫 거절 입력이 아니라 최종 structured_output을 반환", () => {
|
|
269
|
+
const rejected = {
|
|
270
|
+
l2: "en",
|
|
271
|
+
line_id: "s1_l1",
|
|
272
|
+
speaker: "Minjae",
|
|
273
|
+
title: "첫 시도",
|
|
274
|
+
scenes: [],
|
|
275
|
+
};
|
|
276
|
+
const accepted = {
|
|
277
|
+
title: "두 번째 시도",
|
|
278
|
+
opening_line: "스키마를 만족한 최종 응답",
|
|
279
|
+
scenes: [],
|
|
280
|
+
};
|
|
281
|
+
const { result } = runLines(
|
|
282
|
+
[
|
|
283
|
+
JSON.stringify({
|
|
284
|
+
type: "assistant",
|
|
285
|
+
message: {
|
|
286
|
+
content: [{ type: "tool_use", name: "StructuredOutput", input: rejected }],
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
JSON.stringify({
|
|
290
|
+
type: "user",
|
|
291
|
+
message: {
|
|
292
|
+
content: [
|
|
293
|
+
{
|
|
294
|
+
type: "tool_result",
|
|
295
|
+
is_error: true,
|
|
296
|
+
content: "Output does not match required schema",
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
JSON.stringify({
|
|
302
|
+
type: "assistant",
|
|
303
|
+
message: {
|
|
304
|
+
content: [{ type: "tool_use", name: "StructuredOutput", input: accepted }],
|
|
305
|
+
},
|
|
306
|
+
}),
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
type: "user",
|
|
309
|
+
message: {
|
|
310
|
+
content: [
|
|
311
|
+
{
|
|
312
|
+
type: "tool_result",
|
|
313
|
+
content: "Structured output provided successfully",
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
type: "result",
|
|
320
|
+
subtype: "success",
|
|
321
|
+
is_error: false,
|
|
322
|
+
structured_output: accepted,
|
|
323
|
+
usage: { input_tokens: 10, output_tokens: 20 },
|
|
324
|
+
}),
|
|
325
|
+
],
|
|
326
|
+
{ structuredOutput: true },
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
expect(JSON.parse(result!.text)).toEqual(accepted);
|
|
330
|
+
expect(result!.isError).toBe(false);
|
|
331
|
+
expect(result!.subtype).toBe("success");
|
|
332
|
+
});
|
|
333
|
+
|
|
268
334
|
// SON-495: error_max_structured_output_retries 는 모델이 schema 를 못 맞춘 비정상 종료다.
|
|
269
335
|
// CC structured output 은 constrained decoding 이 아니라 사후 AJV 검증이라, 모델이 가끔
|
|
270
336
|
// placeholder($PARAMETER_NAME)·거부("just kidding")·필드 누락 같은 발작/불완전 출력을 낸다.
|
|
@@ -406,9 +406,20 @@ export function handleStreamJsonLine(
|
|
|
406
406
|
}
|
|
407
407
|
|
|
408
408
|
if (j.type === "result") {
|
|
409
|
+
const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
|
|
410
|
+
const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
|
|
411
|
+
const resultSucceeded =
|
|
412
|
+
j.is_error !== true &&
|
|
413
|
+
terminalReason !== "model_error" &&
|
|
414
|
+
(subtype === undefined || subtype === "success");
|
|
409
415
|
let text: string;
|
|
410
416
|
const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
|
|
411
|
-
|
|
417
|
+
// Claude Code는 StructuredOutput 스키마 검증 실패 후 내부 재시도를 할 수 있다. assistant
|
|
418
|
+
// 이벤트의 첫 tool input은 거절된 시도일 수 있으므로, 성공 result가 제공한 최종 검증값을
|
|
419
|
+
// 우선한다. 첫 입력 보존값은 max_turns/retry 초과처럼 성공 result가 없는 진단 경로용이다.
|
|
420
|
+
if (structuredOutput && resultSucceeded && j.structured_output !== undefined) {
|
|
421
|
+
text = JSON.stringify(j.structured_output);
|
|
422
|
+
} else if (preservedStructuredOutput !== undefined) {
|
|
412
423
|
text = preservedStructuredOutput;
|
|
413
424
|
} else if (j.structured_output !== undefined) {
|
|
414
425
|
text = JSON.stringify(j.structured_output);
|
|
@@ -429,8 +440,6 @@ export function handleStreamJsonLine(
|
|
|
429
440
|
}
|
|
430
441
|
const usage = toUsageBreakdown(rawUsage as ClaudeUsage);
|
|
431
442
|
const quotaExhausted = text.startsWith("You've hit");
|
|
432
|
-
const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
|
|
433
|
-
const terminalReason = typeof j.terminal_reason === "string" ? j.terminal_reason : undefined;
|
|
434
443
|
|
|
435
444
|
// SON-495: 비정상 종료(error_max_structured_output_retries 등)는 정직하게 에러로 처리한다.
|
|
436
445
|
// CC structured output 은 constrained decoding 이 아니라 "tool input 생성 후 사후 AJV 검증"이라
|
|
@@ -55,6 +55,8 @@ export interface GenerateRequest {
|
|
|
55
55
|
verbosity?: string;
|
|
56
56
|
reasoningSummary?: string;
|
|
57
57
|
serviceTier?: string;
|
|
58
|
+
// provider 실행 제한(ms). 현재 Anthropic Claude session 타이머가 사용한다.
|
|
59
|
+
timeoutMs?: number;
|
|
58
60
|
abortSignal?: AbortSignal;
|
|
59
61
|
// 첫 turn / 재사용 폴백 시 보낼 input — 전체 prompt. 항상 설정.
|
|
60
62
|
coldInput: Array<UserInput>;
|