@cartanova/qgrid-cli 2.3.2 → 2.3.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 +91 -278
- package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +39 -96
- package/bundle/dist/utils/providers/anthropic/claude-session.js +47 -32
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +3 -36
- package/bundle/dist/utils/providers/common/model-cost.js +3 -2
- package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +193 -6
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +140 -364
- package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +50 -185
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +71 -62
- package/bundle/src/utils/providers/anthropic/claude-session.ts +56 -46
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +19 -30
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +24 -101
- package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
- package/bundle/src/utils/providers/common/model-cost.ts +2 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
- package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
- package/package.json +1 -1
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* claude-session — Claude CLI 세션 spawn + QgridThreadCoord 매핑 + per-session 락.
|
|
3
3
|
*
|
|
4
|
-
* 멀티턴:
|
|
5
|
-
*
|
|
4
|
+
* 멀티턴: 매 호출 fresh `--session-id <uuid>`로 실행한다. 입력은 어댑터가 만든 JSONL 을
|
|
5
|
+
* stdin 으로 흘리고(`--input-format stream-json`), 출력은 파서로 처리한다.
|
|
6
6
|
*
|
|
7
7
|
* 핵심 계약:
|
|
8
8
|
* - coord 는 임의 저장이 아니라 기존 QgridThreadCoord{workerId,threadId,epoch,systemHash} 에
|
|
9
9
|
* 매핑한다. threadId=세션 uuid, epoch=0 고정(worker restart 개념 없음), workerId=token 기반
|
|
10
10
|
* 안정 합성, systemHash=요청 system 해시. issueConvContext/decideConvRouting 과 round-trip.
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
13
|
-
* - 같은 세션의 동시 turn 은 per-session 락(session-id 단위 직렬화)으로 transcript 오염 방지.
|
|
11
|
+
* - withSessionLock 은 exported primitive 로 유지한다. 기본 경로에서는 매 호출 새 session-id 라
|
|
12
|
+
* 사실상 no-contention 이지만, 직접 호출자/테스트의 직렬화 보장은 보존한다.
|
|
14
13
|
* - stream-json 직렬화 시 SDK envelope(uuid/session_id/parent_tool_use_id) decorate.
|
|
15
14
|
* - structured output 일 때 출력 파서에 { structuredOutput: true } 전달(없으면 streamObject 깨짐).
|
|
16
|
-
*
|
|
17
|
-
* ⚠️ 첫 호출 동시성: resume 가 아닌 "첫 호출"은 여기서 새 랜덤 session-id 를 발급하므로, 같은
|
|
18
|
-
* qgrid sessionKey 에 대한 두 동시 첫 호출은 서로 다른 id 로 둘 다 실행된다. 현재 IF/owner scope 는
|
|
19
|
-
* LLM 호출을 순차 실행하고 sessionKey 를 서버로 보내지 않으므로, dispatcher 는 cold 락을 두지 않는다.
|
|
20
|
-
* 병렬 첫 호출을 서버가 지원하게 되면 sessionKey 전달/락 설계부터 다시 잡아야 한다.
|
|
21
15
|
*/
|
|
22
16
|
|
|
23
17
|
import { spawn } from "node:child_process";
|
|
24
|
-
import {
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
25
19
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
26
20
|
|
|
27
21
|
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
@@ -32,7 +26,10 @@ import {
|
|
|
32
26
|
anthropicConfigDir,
|
|
33
27
|
ANTHROPIC_DEFAULT_EFFORT,
|
|
34
28
|
ANTHROPIC_DISALLOWED_TOOLS,
|
|
29
|
+
assertSupportedOneMillionSuffix,
|
|
35
30
|
canonicalAnthropicModel,
|
|
31
|
+
needsCli1mSuffix,
|
|
32
|
+
supports1MContext,
|
|
36
33
|
} from "./anthropic-constants";
|
|
37
34
|
import {
|
|
38
35
|
buildStreamJsonInput,
|
|
@@ -42,26 +39,10 @@ import {
|
|
|
42
39
|
handleStreamJsonLine,
|
|
43
40
|
} from "./stream-json-adapter";
|
|
44
41
|
|
|
45
|
-
// ── 호환키 ──────────────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
// resume 가능 여부를 가르는 키. systemHash 만으로는 같은 sessionKey 를 다른 model/schema 로
|
|
48
|
-
// 재사용할 때 context 오염 → system + model + structured 모드까지 묶는다.
|
|
49
|
-
export function compatibilityKey(opts: {
|
|
50
|
-
system?: string;
|
|
51
|
-
model: string;
|
|
52
|
-
// structured output schema 문자열. 없으면 text 모드. 내용이 다르면 다른 키.
|
|
53
|
-
outputSchema?: string;
|
|
54
|
-
}): string {
|
|
55
|
-
const schemaPart = opts.outputSchema && opts.outputSchema.length > 0 ? opts.outputSchema : "text";
|
|
56
|
-
return createHash("sha256")
|
|
57
|
-
.update(`${opts.system ?? ""}\0${opts.model}\0${schemaPart}`)
|
|
58
|
-
.digest("hex")
|
|
59
|
-
.slice(0, 16);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
// ── per-session 락 ─────────────────────────────────────────────────
|
|
63
43
|
|
|
64
|
-
// session-id 단위
|
|
44
|
+
// session-id 단위 직렬화 primitive. 기본 경로에서는 매 호출 새 uuid 라 사실상 no-op 이지만,
|
|
45
|
+
// 같은 session-id 를 직접 쓰는 호출자는 여전히 직렬화 보장을 받는다.
|
|
65
46
|
const sessionChains = new Map<string, Promise<unknown>>();
|
|
66
47
|
|
|
67
48
|
export async function withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
|
|
@@ -93,7 +74,10 @@ export function makeAnthropicWorkerId(tokenId: number): number {
|
|
|
93
74
|
// ── envelope decorate ──────────────────────────────────────────────
|
|
94
75
|
|
|
95
76
|
// stream-json 입력 라인에 SDK envelope 부착 후 직렬화. session 소유자인 여기서 한다.
|
|
96
|
-
export function decorateAndSerialize(
|
|
77
|
+
export function decorateAndSerialize(
|
|
78
|
+
lines: Array<ClaudeStreamJsonLine>,
|
|
79
|
+
sessionId: string,
|
|
80
|
+
): string {
|
|
97
81
|
return (
|
|
98
82
|
lines
|
|
99
83
|
.map((line) =>
|
|
@@ -118,12 +102,10 @@ export interface ClaudeSessionRequest {
|
|
|
118
102
|
jsonSchema?: string; // 있으면 structured output
|
|
119
103
|
effort?: string;
|
|
120
104
|
timeoutMs: number;
|
|
121
|
-
// 입력: cold(coldHistory+coldInput) 또는 resume(reuseInput).
|
|
122
105
|
coldHistory?: Array<JsonValue>;
|
|
123
106
|
input: Array<UserInput>;
|
|
124
|
-
// resume 경로면 기존 session-id, cold 면 undefined(새로 발급).
|
|
125
|
-
resumeSessionId?: string;
|
|
126
107
|
abortSignal?: AbortSignal;
|
|
108
|
+
includePartialMessages?: boolean;
|
|
127
109
|
}
|
|
128
110
|
|
|
129
111
|
export interface ClaudeSessionResult extends ClaudeStreamResult {
|
|
@@ -152,13 +134,23 @@ function ensureCwd(): void {
|
|
|
152
134
|
}
|
|
153
135
|
|
|
154
136
|
// spawn args 구성. 멀티턴/격리/structured 모드 반영.
|
|
137
|
+
export function applyOneMillionSuffix(model: string, needsSuffix: boolean): string {
|
|
138
|
+
const base = canonicalAnthropicModel(model);
|
|
139
|
+
return needsSuffix ? `${base}[1m]` : base;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function oneMillionEnv(supported: boolean): { CLAUDE_CODE_DISABLE_1M_CONTEXT?: "1" } {
|
|
143
|
+
return supported ? {} : { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" };
|
|
144
|
+
}
|
|
145
|
+
|
|
155
146
|
export function buildClaudeArgs(opts: {
|
|
156
147
|
model: string;
|
|
157
148
|
system?: string;
|
|
158
149
|
effort?: string;
|
|
159
150
|
jsonSchema?: string;
|
|
160
151
|
sessionId: string;
|
|
161
|
-
|
|
152
|
+
needsOneMillionSuffix?: boolean;
|
|
153
|
+
includePartialMessages?: boolean;
|
|
162
154
|
}): Array<string> {
|
|
163
155
|
const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
|
|
164
156
|
// --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
|
|
@@ -176,14 +168,13 @@ export function buildClaudeArgs(opts: {
|
|
|
176
168
|
"--output-format",
|
|
177
169
|
"stream-json",
|
|
178
170
|
"--verbose",
|
|
179
|
-
"--
|
|
180
|
-
useStructured ? "2" : "1",
|
|
171
|
+
...(opts.includePartialMessages ? ["--include-partial-messages"] : []),
|
|
181
172
|
"--permission-mode",
|
|
182
173
|
"bypassPermissions",
|
|
183
174
|
"--setting-sources",
|
|
184
175
|
"project",
|
|
185
176
|
"--model",
|
|
186
|
-
opts.model,
|
|
177
|
+
applyOneMillionSuffix(opts.model, opts.needsOneMillionSuffix ?? false),
|
|
187
178
|
// inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
|
|
188
179
|
"--system-prompt",
|
|
189
180
|
opts.system ?? "",
|
|
@@ -192,8 +183,8 @@ export function buildClaudeArgs(opts: {
|
|
|
192
183
|
"--effort",
|
|
193
184
|
opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
|
|
194
185
|
"--disable-slash-commands",
|
|
195
|
-
|
|
196
|
-
|
|
186
|
+
"--session-id",
|
|
187
|
+
opts.sessionId,
|
|
197
188
|
];
|
|
198
189
|
if (useStructured) args.push("--json-schema", opts.jsonSchema!);
|
|
199
190
|
return args;
|
|
@@ -209,9 +200,11 @@ export function runClaudeSession(
|
|
|
209
200
|
): Promise<ClaudeSessionResult> {
|
|
210
201
|
// 정규화 규칙은 canonicalAnthropicModel 이 단독 소유 — dispatcher 가 이미 canonical 을 넘기지만,
|
|
211
202
|
// 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
|
|
203
|
+
assertSupportedOneMillionSuffix(req.model);
|
|
212
204
|
const model = canonicalAnthropicModel(req.model);
|
|
213
|
-
const
|
|
214
|
-
const
|
|
205
|
+
const supportsOneMillion = supports1MContext(model);
|
|
206
|
+
const needsOneMillionSuffix = needsCli1mSuffix(model);
|
|
207
|
+
const sessionId = randomUUID();
|
|
215
208
|
const useStructured = Boolean(req.jsonSchema && req.jsonSchema.length > 0);
|
|
216
209
|
const workerId = makeAnthropicWorkerId(req.tokenId);
|
|
217
210
|
|
|
@@ -225,17 +218,22 @@ export function runClaudeSession(
|
|
|
225
218
|
effort: req.effort,
|
|
226
219
|
jsonSchema: req.jsonSchema,
|
|
227
220
|
sessionId,
|
|
228
|
-
|
|
221
|
+
needsOneMillionSuffix,
|
|
222
|
+
includePartialMessages: req.includePartialMessages,
|
|
229
223
|
});
|
|
230
224
|
|
|
231
225
|
const inputLines = buildStreamJsonInput({
|
|
232
226
|
coldHistory: req.coldHistory,
|
|
233
227
|
input: req.input,
|
|
234
|
-
isResume,
|
|
235
228
|
});
|
|
236
229
|
const stdinPayload = decorateAndSerialize(inputLines, sessionId);
|
|
237
230
|
|
|
238
231
|
return new Promise<ClaudeSessionResult>((resolve, reject) => {
|
|
232
|
+
if (req.abortSignal?.aborted) {
|
|
233
|
+
reject(new Error("aborted"));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
239
237
|
const child = spawn("claude", args, {
|
|
240
238
|
// stderr 도 캡처: invalid OAuth / 잘못된 플래그 / CLI 검증 실패가
|
|
241
239
|
// "closed without result" 로 뭉개지지 않게 close/error 에 stderr 를 실어 보낸다.
|
|
@@ -248,9 +246,13 @@ export function runClaudeSession(
|
|
|
248
246
|
CLAUDE_CONFIG_DIR: configDir,
|
|
249
247
|
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
|
|
250
248
|
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
|
|
251
|
-
|
|
249
|
+
...oneMillionEnv(supportsOneMillion),
|
|
252
250
|
CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
|
|
251
|
+
CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
|
|
252
|
+
CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1",
|
|
253
|
+
CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
|
|
253
254
|
CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
|
|
255
|
+
MAX_THINKING_TOKENS: "0",
|
|
254
256
|
},
|
|
255
257
|
});
|
|
256
258
|
|
|
@@ -264,9 +266,11 @@ export function runClaudeSession(
|
|
|
264
266
|
stderrBuf = (stderrBuf + d.toString()).slice(-STDERR_CAP);
|
|
265
267
|
});
|
|
266
268
|
const stderrSuffix = () => (stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim()}` : "");
|
|
269
|
+
let cleanupAbort: (() => void) | undefined;
|
|
267
270
|
const timer = setTimeout(() => {
|
|
268
271
|
if (settled) return;
|
|
269
272
|
settled = true;
|
|
273
|
+
cleanupAbort?.();
|
|
270
274
|
child.kill();
|
|
271
275
|
reject(new Error(`Claude session timeout after ${req.timeoutMs / 1000}s`));
|
|
272
276
|
}, req.timeoutMs);
|
|
@@ -275,16 +279,20 @@ export function runClaudeSession(
|
|
|
275
279
|
if (settled) return;
|
|
276
280
|
settled = true;
|
|
277
281
|
clearTimeout(timer);
|
|
282
|
+
cleanupAbort?.();
|
|
278
283
|
resolve({ ...result, sessionId, workerId });
|
|
279
284
|
};
|
|
280
285
|
|
|
281
|
-
|
|
286
|
+
const onAbort = () => {
|
|
282
287
|
if (settled) return;
|
|
283
288
|
settled = true;
|
|
284
289
|
clearTimeout(timer);
|
|
290
|
+
cleanupAbort?.();
|
|
285
291
|
child.kill();
|
|
286
292
|
reject(new Error("aborted"));
|
|
287
|
-
}
|
|
293
|
+
};
|
|
294
|
+
cleanupAbort = () => req.abortSignal?.removeEventListener("abort", onAbort);
|
|
295
|
+
req.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
288
296
|
|
|
289
297
|
child.stdout?.on("data", (d: Buffer) => {
|
|
290
298
|
if (settled) return;
|
|
@@ -307,12 +315,14 @@ export function runClaudeSession(
|
|
|
307
315
|
if (settled) return;
|
|
308
316
|
settled = true;
|
|
309
317
|
clearTimeout(timer);
|
|
318
|
+
cleanupAbort?.();
|
|
310
319
|
reject(new Error(`Claude session closed without result${stderrSuffix()}`));
|
|
311
320
|
});
|
|
312
321
|
child.on("error", (err) => {
|
|
313
322
|
if (settled) return;
|
|
314
323
|
settled = true;
|
|
315
324
|
clearTimeout(timer);
|
|
325
|
+
cleanupAbort?.();
|
|
316
326
|
reject(new Error(`Claude session spawn error: ${err.message}`));
|
|
317
327
|
});
|
|
318
328
|
|
|
@@ -21,7 +21,7 @@ function userLineCount(lines: Array<ClaudeStreamJsonLine>): number {
|
|
|
21
21
|
|
|
22
22
|
describe("buildStreamJsonInput", () => {
|
|
23
23
|
it("happy: 단일 user input → user 1줄", () => {
|
|
24
|
-
const lines = buildStreamJsonInput({ input: userText("안녕")
|
|
24
|
+
const lines = buildStreamJsonInput({ input: userText("안녕") });
|
|
25
25
|
expect(lines).toHaveLength(1);
|
|
26
26
|
expect(userLineCount(lines)).toBe(1);
|
|
27
27
|
expect(lines[0]).toEqual({
|
|
@@ -42,7 +42,6 @@ describe("buildStreamJsonInput", () => {
|
|
|
42
42
|
const lines = buildStreamJsonInput({
|
|
43
43
|
coldHistory,
|
|
44
44
|
input: userText("비밀번호 뭐였지?"),
|
|
45
|
-
isResume: false,
|
|
46
45
|
});
|
|
47
46
|
// assistant context 1줄 + 실행 user 1줄
|
|
48
47
|
expect(lines).toHaveLength(2);
|
|
@@ -57,28 +56,8 @@ describe("buildStreamJsonInput", () => {
|
|
|
57
56
|
});
|
|
58
57
|
});
|
|
59
58
|
|
|
60
|
-
it("resume: reuseInput(delta) user 1줄만, history 미포함", () => {
|
|
61
|
-
const lines = buildStreamJsonInput({
|
|
62
|
-
coldHistory: [
|
|
63
|
-
{
|
|
64
|
-
type: "message",
|
|
65
|
-
role: "assistant",
|
|
66
|
-
content: [{ type: "output_text", text: "이전 답변" }],
|
|
67
|
-
},
|
|
68
|
-
],
|
|
69
|
-
input: userText("다음 질문"),
|
|
70
|
-
isResume: true,
|
|
71
|
-
});
|
|
72
|
-
expect(lines).toHaveLength(1);
|
|
73
|
-
expect(userLineCount(lines)).toBe(1);
|
|
74
|
-
expect(lines[0]).toEqual({
|
|
75
|
-
type: "user",
|
|
76
|
-
message: { role: "user", content: [{ type: "text", text: "다음 질문" }] },
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
59
|
it("정규화: content 는 항상 block 배열 (KTD5 구현계약)", () => {
|
|
81
|
-
const lines = buildStreamJsonInput({ input: userText("x")
|
|
60
|
+
const lines = buildStreamJsonInput({ input: userText("x") });
|
|
82
61
|
expect(Array.isArray(lines[0]!.message.content)).toBe(true);
|
|
83
62
|
expect(lines[0]!.message.content[0]).toHaveProperty("type", "text");
|
|
84
63
|
});
|
|
@@ -91,7 +70,6 @@ describe("buildStreamJsonInput", () => {
|
|
|
91
70
|
const lines = buildStreamJsonInput({
|
|
92
71
|
coldHistory,
|
|
93
72
|
input: userText("결과는?"),
|
|
94
|
-
isResume: false,
|
|
95
73
|
});
|
|
96
74
|
// assistant context 1줄 + 실행 user 1줄
|
|
97
75
|
expect(lines).toHaveLength(2);
|
|
@@ -110,7 +88,6 @@ describe("buildStreamJsonInput", () => {
|
|
|
110
88
|
const lines = buildStreamJsonInput({
|
|
111
89
|
coldHistory: [],
|
|
112
90
|
input: userText("단발"),
|
|
113
|
-
isResume: false,
|
|
114
91
|
});
|
|
115
92
|
expect(lines).toHaveLength(1);
|
|
116
93
|
expect(lines[0]!.type).toBe("user");
|
|
@@ -122,7 +99,7 @@ describe("buildStreamJsonInput", () => {
|
|
|
122
99
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a1" }] },
|
|
123
100
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a2" }] },
|
|
124
101
|
];
|
|
125
|
-
const lines = buildStreamJsonInput({ coldHistory, input: userText("q")
|
|
102
|
+
const lines = buildStreamJsonInput({ coldHistory, input: userText("q") });
|
|
126
103
|
expect(lines).toHaveLength(2);
|
|
127
104
|
expect(userLineCount(lines)).toBe(1);
|
|
128
105
|
expect(lines[0]!.type).toBe("assistant");
|
|
@@ -137,7 +114,7 @@ describe("buildStreamJsonInput", () => {
|
|
|
137
114
|
{ type: "message", role: "user", content: [{ type: "input_text", text: "u2" }] },
|
|
138
115
|
{ type: "function_call_output", call_id: "x", output: "out" },
|
|
139
116
|
];
|
|
140
|
-
const lines = buildStreamJsonInput({ coldHistory, input: userText("final")
|
|
117
|
+
const lines = buildStreamJsonInput({ coldHistory, input: userText("final") });
|
|
141
118
|
expect(userLineCount(lines)).toBe(1);
|
|
142
119
|
expect(lines[lines.length - 1]!.message.content[0]!.text).toBe("final");
|
|
143
120
|
});
|
|
@@ -145,7 +122,7 @@ describe("buildStreamJsonInput", () => {
|
|
|
145
122
|
|
|
146
123
|
describe("serializeStreamJsonInput", () => {
|
|
147
124
|
it("JSONL 한 줄당 하나 + 마지막 개행", () => {
|
|
148
|
-
const lines = buildStreamJsonInput({ input: userText("hi")
|
|
125
|
+
const lines = buildStreamJsonInput({ input: userText("hi") });
|
|
149
126
|
const out = serializeStreamJsonInput(lines);
|
|
150
127
|
expect(out.endsWith("\n")).toBe(true);
|
|
151
128
|
const parsed = out
|
|
@@ -230,7 +207,7 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
|
|
|
230
207
|
expect(deltas).not.toContain(" (thinking)");
|
|
231
208
|
});
|
|
232
209
|
|
|
233
|
-
it("structured tool-call: 첫 StructuredOutput
|
|
210
|
+
it("structured tool-call: 첫 StructuredOutput은 보존하되 max_turns result는 error 유지", () => {
|
|
234
211
|
const first = {
|
|
235
212
|
action: "tool_call",
|
|
236
213
|
answer: null,
|
|
@@ -249,6 +226,18 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
|
|
|
249
226
|
content: [{ type: "tool_use", name: "StructuredOutput", input: first }],
|
|
250
227
|
},
|
|
251
228
|
}),
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
type: "user",
|
|
231
|
+
message: {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: "tool_result",
|
|
235
|
+
is_error: true,
|
|
236
|
+
content: "Output does not match required schema",
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
252
241
|
JSON.stringify({
|
|
253
242
|
type: "assistant",
|
|
254
243
|
message: {
|
|
@@ -269,7 +258,7 @@ describe("handleStreamJsonLine (출력 어댑터)", () => {
|
|
|
269
258
|
);
|
|
270
259
|
|
|
271
260
|
expect(JSON.parse(result!.text)).toEqual(first);
|
|
272
|
-
expect(result!.isError).toBe(
|
|
261
|
+
expect(result!.isError).toBe(true);
|
|
273
262
|
expect(result!.usage.inputTokens).toBe(30);
|
|
274
263
|
expect(result!.usage.outputTokens).toBe(30);
|
|
275
264
|
expect(result!.durationMs).toBe(1234);
|
|
@@ -1,27 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* stream-json
|
|
2
|
+
* Claude CLI stream-json adapter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* → `claude -p --input-format stream-json` 의 JSONL 라인.
|
|
7
|
-
* - 출력: `claude -p --output-format stream-json --verbose` 의 JSONL 라인
|
|
8
|
-
* → qgrid 서버 delta(onDelta) + 최종 result. (U3, 같은 파일 하단)
|
|
4
|
+
* Input: qgrid history/input -> Claude JSONL stdin.
|
|
5
|
+
* Output: Claude JSONL stdout -> qgrid deltas + final result metadata.
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* ai-sdk 가 이미 QueryInput.history(JsonValue[]) 로 변환했고, decideConvRouting 이
|
|
13
|
-
* coldInput/coldHistory/reuseInput 으로 만든 뒤 dispatcher 에 도달한다.
|
|
14
|
-
* - content 는 user/assistant 모두 block 배열 `[{type:"text",text}]` 로 정규화한다.
|
|
15
|
-
* 문자열 content 는 일부 user 단발만 통과하고 history replay 에서 깨진다
|
|
16
|
-
* (`W is not an Object (evaluating '"tool_use_id" in W')`).
|
|
17
|
-
* - stream-json 의 user/assistant 는 비대칭이다: assistant 는 history context 로 push 되고
|
|
18
|
-
* **user 는 새 prompt 로 enqueue(=실행)된다.** 따라서 cold 경로에서 과거 대화(user/assistant/
|
|
19
|
-
* tool)를 그대로 줄로 늘어놓으면 과거 user/tool-output 이 실행 prompt 로 재실행된다. 이를 피하려
|
|
20
|
-
* cold history 는 **단일 assistant context 텍스트로 평탄화**하고, 실행 가능한 `type:"user"` 줄은
|
|
21
|
-
* **마지막 input 하나뿐**이 되도록 한다. full-fidelity role replay 가 필요하면 session resume
|
|
22
|
-
* (reuseInput) 또는 JSONL backstop 으로 간다.
|
|
23
|
-
* - tool 결과는 native Anthropic tool_result 블록을 새로 만들지 않고, 기존 OpenAI 와
|
|
24
|
-
* 동일하게 평탄화된 text 로 처리한다(emulation 공통 계약).
|
|
7
|
+
* Previous conversation items are flattened into one assistant context line.
|
|
8
|
+
* The only executable user line is the current input.
|
|
25
9
|
*/
|
|
26
10
|
|
|
27
11
|
import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
|
|
@@ -30,11 +14,7 @@ import { type ProviderTokenUsageBreakdown } from "../common/provider-dispatcher"
|
|
|
30
14
|
|
|
31
15
|
// ── 입력 어댑터 ────────────────────────────────────────────────────
|
|
32
16
|
|
|
33
|
-
// Claude stream-json 입력 한 줄(JSONL).
|
|
34
|
-
//
|
|
35
|
-
// SDK envelope 필드(uuid / session_id / parent_tool_use_id)는 **여기서 만들지 않는다**.
|
|
36
|
-
// 그 값들은 세션을 발급/소유하는 claude-session 의 책임이다. 이 모듈은 role/content 변환만 하는
|
|
37
|
-
// 순수 함수로 두고, claude-session 이 직렬화 직전에 envelope 를 decorate 한다. 책임 분리.
|
|
17
|
+
// Claude stream-json 입력 한 줄(JSONL). Envelope fields are added in claude-session.
|
|
38
18
|
export interface ClaudeStreamJsonLine {
|
|
39
19
|
type: "user" | "assistant";
|
|
40
20
|
message: {
|
|
@@ -43,7 +23,6 @@ export interface ClaudeStreamJsonLine {
|
|
|
43
23
|
};
|
|
44
24
|
}
|
|
45
25
|
|
|
46
|
-
// UserInput[] 에서 text 만 뽑아 하나의 문자열로. (image/skill/mention 등은 현재 범위 밖 — text 위주)
|
|
47
26
|
function userInputToText(input: Array<UserInput>): string {
|
|
48
27
|
return input
|
|
49
28
|
.filter((i): i is Extract<UserInput, { type: "text" }> => i.type === "text")
|
|
@@ -51,12 +30,11 @@ function userInputToText(input: Array<UserInput>): string {
|
|
|
51
30
|
.join("\n");
|
|
52
31
|
}
|
|
53
32
|
|
|
54
|
-
// content 를 항상 block 배열로 정규화(문자열 content 는 history replay 에서 깨짐).
|
|
55
33
|
function textBlock(text: string): Array<{ type: "text"; text: string }> {
|
|
56
34
|
return [{ type: "text", text }];
|
|
57
35
|
}
|
|
58
36
|
|
|
59
|
-
// codex ResponseItem(coldHistory
|
|
37
|
+
// codex ResponseItem(coldHistory item) 최소 shape.
|
|
60
38
|
// extractPromptAndHistory(ai-sdk/utils.ts) 가 만드는 형식:
|
|
61
39
|
// { type:"message", role:"user", content:[{type:"input_text", text}] }
|
|
62
40
|
// { type:"message", role:"assistant", content:[{type:"output_text", text}] }
|
|
@@ -68,8 +46,6 @@ function asObject(v: JsonValue | undefined): ResponseItem | null {
|
|
|
68
46
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as ResponseItem) : null;
|
|
69
47
|
}
|
|
70
48
|
|
|
71
|
-
// codex message content([{type:input_text|output_text, text}]) 에서 text 추출.
|
|
72
|
-
// 여러 content 블록은 줄바꿈으로 구분(token-adjacent 붙음 방지).
|
|
73
49
|
function responseItemText(item: ResponseItem): string {
|
|
74
50
|
const content = item.content;
|
|
75
51
|
if (!Array.isArray(content)) return "";
|
|
@@ -82,11 +58,6 @@ function responseItemText(item: ResponseItem): string {
|
|
|
82
58
|
.join("\n");
|
|
83
59
|
}
|
|
84
60
|
|
|
85
|
-
/**
|
|
86
|
-
* cold history(codex ResponseItem[])를 **단일 평탄 텍스트**로 변환한다.
|
|
87
|
-
* user/assistant/tool 모두 라벨 붙은 한 덩어리로 — 실행되지 않는 assistant context 로 들어간다.
|
|
88
|
-
* (cold 의 user/tool-output 을 type:"user" 로 내보내면 새 prompt 로 재실행되므로 금지.)
|
|
89
|
-
*/
|
|
90
61
|
function flattenColdHistory(coldHistory: Array<JsonValue>): string {
|
|
91
62
|
const parts: Array<string> = [];
|
|
92
63
|
for (const raw of coldHistory) {
|
|
@@ -111,30 +82,13 @@ function flattenColdHistory(coldHistory: Array<JsonValue>): string {
|
|
|
111
82
|
return parts.join("\n");
|
|
112
83
|
}
|
|
113
84
|
|
|
114
|
-
/**
|
|
115
|
-
* 서버 GenerateRequest payload 를 Claude stream-json JSONL 라인 배열로 변환한다.
|
|
116
|
-
*
|
|
117
|
-
* @param opts.coldHistory cold 경로의 codex ResponseItem 배열(JsonValue[]). resume 경로면 미사용.
|
|
118
|
-
* @param opts.input 실행할 input — cold 면 coldInput, resume 면 reuseInput.
|
|
119
|
-
* @param opts.isResume resume 경로 여부. true 면 history 재주입 없이 input(delta)만.
|
|
120
|
-
*
|
|
121
|
-
* 출력 규칙:
|
|
122
|
-
* - 실행 가능한 `type:"user"` 줄은 **항상 정확히 1개** — 마지막 input.
|
|
123
|
-
* - resume: user(delta) 1줄만. thread 가 이전 turn 을 누적하므로 history 미포함.
|
|
124
|
-
* - cold: coldHistory 전체를 **단일 assistant context 텍스트**로 평탄화(실행 안 됨) + user(input) 1줄.
|
|
125
|
-
* user/assistant/tool 모두 평탄화되므로 과거 user/tool-output 이 재실행되지 않는다.
|
|
126
|
-
* full-fidelity role replay 가 필요하면 session resume 또는 JSONL backstop(범위 밖).
|
|
127
|
-
* - tool(function_call/output): native 블록 없이 평탄 텍스트로.
|
|
128
|
-
*/
|
|
129
85
|
export function buildStreamJsonInput(opts: {
|
|
130
86
|
coldHistory?: Array<JsonValue>;
|
|
131
87
|
input: Array<UserInput>;
|
|
132
|
-
isResume: boolean;
|
|
133
88
|
}): Array<ClaudeStreamJsonLine> {
|
|
134
89
|
const lines: Array<ClaudeStreamJsonLine> = [];
|
|
135
90
|
|
|
136
|
-
|
|
137
|
-
if (!opts.isResume && opts.coldHistory && opts.coldHistory.length > 0) {
|
|
91
|
+
if (opts.coldHistory && opts.coldHistory.length > 0) {
|
|
138
92
|
const flattened = flattenColdHistory(opts.coldHistory);
|
|
139
93
|
if (flattened) {
|
|
140
94
|
lines.push({
|
|
@@ -147,7 +101,6 @@ export function buildStreamJsonInput(opts: {
|
|
|
147
101
|
}
|
|
148
102
|
}
|
|
149
103
|
|
|
150
|
-
// 실행할 input — resume/cold 공통, 항상 마지막의 단일 user 줄.
|
|
151
104
|
lines.push({
|
|
152
105
|
type: "user",
|
|
153
106
|
message: { role: "user", content: textBlock(userInputToText(opts.input)) },
|
|
@@ -156,26 +109,14 @@ export function buildStreamJsonInput(opts: {
|
|
|
156
109
|
return lines;
|
|
157
110
|
}
|
|
158
111
|
|
|
159
|
-
// JSONL 문자열(claude stdin 으로 흘릴 형태)로 직렬화.
|
|
160
112
|
export function serializeStreamJsonInput(lines: Array<ClaudeStreamJsonLine>): string {
|
|
161
113
|
return lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
|
162
114
|
}
|
|
163
115
|
|
|
164
116
|
// ── 출력 어댑터 ────────────────────────────────────────────────────
|
|
165
117
|
//
|
|
166
|
-
//
|
|
167
|
-
// qgrid 서버 delta(onDelta) + 최종 result 로 변환한다.
|
|
168
|
-
//
|
|
169
|
-
// 계약:
|
|
170
|
-
// - 이 어댑터는 qgrid **서버** 계층이다. AI SDK stream part(text-delta 등)는
|
|
171
|
-
// packages/ai-sdk(client provider)가 만든다 — 여기서 만들지 않는다.
|
|
172
|
-
// - text_delta → onDelta(text). structured output 일 때 input_json_delta.partial_json
|
|
173
|
-
// 조각도 onDelta(text) 로 흘린다(client provider 가 partialObjectStream 으로 누적).
|
|
174
|
-
// - 최종 result → structured_output(우선) 또는 result(폴백, 자연어 가능) + usage + 종료.
|
|
175
|
-
// - usage 는 input/cache_creation/cache_read/output 4 카테고리를 받아 TokenUsageBreakdown 으로.
|
|
176
|
-
// payload 크기 비교는 3 카테고리 합으로(과거 학습 — input 단독 비교 무의미).
|
|
118
|
+
// Claude stdout(JSONL)을 qgrid deltas + final result 로 변환한다.
|
|
177
119
|
|
|
178
|
-
// claude stream-json 출력에서 우리가 보는 usage shape(부분). Anthropic 네이티브 필드.
|
|
179
120
|
interface ClaudeUsage {
|
|
180
121
|
input_tokens?: number;
|
|
181
122
|
output_tokens?: number;
|
|
@@ -183,7 +124,6 @@ interface ClaudeUsage {
|
|
|
183
124
|
cache_read_input_tokens?: number;
|
|
184
125
|
}
|
|
185
126
|
|
|
186
|
-
// 최종 result 파싱 산출물. dispatcher(U1)가 GenerateResult 조립에 쓴다.
|
|
187
127
|
export interface ClaudeStreamResult {
|
|
188
128
|
text: string;
|
|
189
129
|
usage: ProviderTokenUsageBreakdown;
|
|
@@ -220,8 +160,6 @@ function toUsageBreakdown(u: ClaudeUsage): ProviderTokenUsageBreakdown {
|
|
|
220
160
|
};
|
|
221
161
|
}
|
|
222
162
|
|
|
223
|
-
// result.result 의 코드펜스(```json ... ```) 제거. structured_output 이 없을 때만 쓴다.
|
|
224
|
-
// 앞뒤 공백/개행을 먼저 trim 해 `\n```json\n...\n```\n` 같은 형태도 깨끗이 제거.
|
|
225
163
|
function stripCodeFence(s: string): string {
|
|
226
164
|
return s
|
|
227
165
|
.trim()
|
|
@@ -237,26 +175,18 @@ function structuredOutputToolUseText(j: ResponseItem): string | undefined {
|
|
|
237
175
|
for (const raw of content) {
|
|
238
176
|
const block = asObject(raw);
|
|
239
177
|
if (!block) continue;
|
|
240
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
block.type === "tool_use" &&
|
|
180
|
+
block.name === "StructuredOutput" &&
|
|
181
|
+
block.input !== undefined
|
|
182
|
+
) {
|
|
241
183
|
return JSON.stringify(block.input);
|
|
242
184
|
}
|
|
243
185
|
}
|
|
244
186
|
return undefined;
|
|
245
187
|
}
|
|
246
188
|
|
|
247
|
-
|
|
248
|
-
* stream-json 출력 라인 하나를 처리한다. 순수 함수 — side effect 는 onDelta 콜백뿐.
|
|
249
|
-
*
|
|
250
|
-
* @param opts.structuredOutput structured output(jsonSchema) 모드 여부.
|
|
251
|
-
* structured 모드에서 Claude 는 자연어 text_delta 와 input_json_delta.partial_json 을
|
|
252
|
-
* **함께** 흘릴 수 있다(PoC 확인). 이때 text_delta 를 그대로 onDelta 로 보내면 client 의
|
|
253
|
-
* streamObject 파서가 prose 를 JSON 앞에서 만나 깨진다. 그래서:
|
|
254
|
-
* - structured 모드: text_delta 무시, partial_json 만 onDelta.
|
|
255
|
-
* - text 모드: text_delta 만 onDelta (partial_json 은 안 옴).
|
|
256
|
-
*
|
|
257
|
-
* @returns result 이벤트면 ClaudeStreamResult, 그 외(delta/system 등)면 null.
|
|
258
|
-
* 호출부는 라인 스트림을 돌며 이 함수를 호출하고, non-null 이 나오면 종료.
|
|
259
|
-
*/
|
|
189
|
+
// Processes one Claude stdout JSONL line. Returns final result on result event.
|
|
260
190
|
export function handleStreamJsonLine(
|
|
261
191
|
line: string,
|
|
262
192
|
onDelta: (text: string) => void,
|
|
@@ -271,23 +201,19 @@ export function handleStreamJsonLine(
|
|
|
271
201
|
try {
|
|
272
202
|
j = JSON.parse(trimmed) as { [key: string]: JsonValue | undefined };
|
|
273
203
|
} catch {
|
|
274
|
-
// 깨진 JSON 라인은 graceful skip.
|
|
275
204
|
return null;
|
|
276
205
|
}
|
|
277
206
|
|
|
278
|
-
// 점진 delta: stream_event > content_block_delta > {text_delta | input_json_delta}.
|
|
279
207
|
if (j.type === "stream_event") {
|
|
280
208
|
const event = asObject(j.event);
|
|
281
209
|
if (event && event.type === "content_block_delta") {
|
|
282
210
|
const delta = asObject(event.delta);
|
|
283
211
|
if (delta) {
|
|
284
212
|
if (structuredOutput) {
|
|
285
|
-
// structured: JSON 조각만. 자연어 text_delta 는 버린다(streamObject 파서 보호).
|
|
286
213
|
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
287
214
|
onDelta(delta.partial_json);
|
|
288
215
|
}
|
|
289
216
|
} else {
|
|
290
|
-
// text: 자연어 delta 만.
|
|
291
217
|
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
292
218
|
onDelta(delta.text);
|
|
293
219
|
}
|
|
@@ -297,13 +223,17 @@ export function handleStreamJsonLine(
|
|
|
297
223
|
return null;
|
|
298
224
|
}
|
|
299
225
|
|
|
300
|
-
if (
|
|
226
|
+
if (
|
|
227
|
+
j.type === "assistant" &&
|
|
228
|
+
structuredOutput &&
|
|
229
|
+
state &&
|
|
230
|
+
state.structuredOutputText === undefined
|
|
231
|
+
) {
|
|
301
232
|
const text = structuredOutputToolUseText(j);
|
|
302
233
|
if (text !== undefined) state.structuredOutputText = text;
|
|
303
234
|
return null;
|
|
304
235
|
}
|
|
305
236
|
|
|
306
|
-
// 최종 result.
|
|
307
237
|
if (j.type === "result") {
|
|
308
238
|
let text: string;
|
|
309
239
|
const preservedStructuredOutput = structuredOutput ? state?.structuredOutputText : undefined;
|
|
@@ -317,18 +247,11 @@ export function handleStreamJsonLine(
|
|
|
317
247
|
|
|
318
248
|
const usage = toUsageBreakdown((asObject(j.usage) ?? {}) as ClaudeUsage);
|
|
319
249
|
const quotaExhausted = text.startsWith("You've hit");
|
|
320
|
-
// 에러 판정: is_error true, terminal model_error,
|
|
321
|
-
// 또는 subtype 이 있고 "success" 가 아니면(error_during_execution / error_max_turns /
|
|
322
|
-
// error_max_budget_usd / error_max_structured_output_retries 등) 에러로 본다.
|
|
323
250
|
const subtype = typeof j.subtype === "string" ? j.subtype : undefined;
|
|
324
|
-
const structuredMaxTurnsCompleted =
|
|
325
|
-
preservedStructuredOutput !== undefined &&
|
|
326
|
-
(subtype === "error_max_turns" || j.terminal_reason === "max_turns");
|
|
327
251
|
const isError =
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
(subtype !== undefined && subtype !== "success"));
|
|
252
|
+
j.is_error === true ||
|
|
253
|
+
j.terminal_reason === "model_error" ||
|
|
254
|
+
(subtype !== undefined && subtype !== "success");
|
|
332
255
|
|
|
333
256
|
return {
|
|
334
257
|
text,
|