@cartanova/qgrid-cli 2.2.1 → 2.3.2

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 (35) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +2 -6
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
  3. package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
  4. package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
  5. package/bundle/dist/application/request-log/request-log.model.js +54 -8
  6. package/bundle/dist/sonamu.config.js +11 -2
  7. package/bundle/dist/testing/global.js +5 -2
  8. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
  9. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
  10. package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
  11. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
  12. package/bundle/dist/utils/providers/common/model-cost.js +35 -18
  13. package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
  14. package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
  15. package/bundle/src/application/qgrid/conv-routing.ts +3 -2
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +110 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
  18. package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
  19. package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
  20. package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
  21. package/bundle/src/application/request-log/request-log.model.ts +98 -13
  22. package/bundle/src/sonamu.config.ts +13 -2
  23. package/bundle/src/testing/global.ts +16 -2
  24. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
  25. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
  26. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
  27. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
  28. package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
  29. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
  30. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
  31. package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
  32. package/bundle/src/utils/providers/common/model-cost.ts +107 -19
  33. package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
  34. package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
  35. package/package.json +1 -1
@@ -0,0 +1,327 @@
1
+ /**
2
+ * claude-session — Claude CLI 세션 spawn + QgridThreadCoord 매핑 + per-session 락.
3
+ *
4
+ * 멀티턴: 첫 호출 `--session-id <uuid>`, 후속 `--resume <uuid>`. 입력은 U2 어댑터가 만든
5
+ * JSONL 을 stdin 으로 흘리고(`--input-format stream-json`), 출력은 U3 파서로 처리한다.
6
+ *
7
+ * 핵심 계약:
8
+ * - coord 는 임의 저장이 아니라 기존 QgridThreadCoord{workerId,threadId,epoch,systemHash} 에
9
+ * 매핑한다. threadId=세션 uuid, epoch=0 고정(worker restart 개념 없음), workerId=token 기반
10
+ * 안정 합성, systemHash=요청 system 해시. issueConvContext/decideConvRouting 과 round-trip.
11
+ * - resume eligibility 는 systemHash 만으로 부족 → 확장 호환키(system+model+structured 모드)로
12
+ * 판정. 불일치면 cold fallback.
13
+ * - 같은 세션의 동시 turn 은 per-session 락(session-id 단위 직렬화)으로 transcript 오염 방지.
14
+ * - stream-json 직렬화 시 SDK envelope(uuid/session_id/parent_tool_use_id) decorate.
15
+ * - 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
+ */
22
+
23
+ import { spawn } from "node:child_process";
24
+ import { createHash, randomUUID } from "node:crypto";
25
+ import { mkdirSync, writeFileSync } from "node:fs";
26
+
27
+ import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
28
+ import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
29
+ import { type UserInput } from "../../../codex-protocol/v2/UserInput";
30
+ import {
31
+ ANTHROPIC_CLAUDE_CWD,
32
+ anthropicConfigDir,
33
+ ANTHROPIC_DEFAULT_EFFORT,
34
+ ANTHROPIC_DISALLOWED_TOOLS,
35
+ canonicalAnthropicModel,
36
+ } from "./anthropic-constants";
37
+ import {
38
+ buildStreamJsonInput,
39
+ type ClaudeStreamJsonState,
40
+ type ClaudeStreamJsonLine,
41
+ type ClaudeStreamResult,
42
+ handleStreamJsonLine,
43
+ } from "./stream-json-adapter";
44
+
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
+ // ── per-session 락 ─────────────────────────────────────────────────
63
+
64
+ // session-id 단위 직렬화. 같은 세션의 동시 turn 이 같은 transcript 에 동시 resume·append 하는 것 방지.
65
+ const sessionChains = new Map<string, Promise<unknown>>();
66
+
67
+ export async function withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
68
+ const prev = sessionChains.get(sessionId) ?? Promise.resolve();
69
+ // 이전 turn 의 성패와 무관하게 직렬 체인을 잇는다.
70
+ const next = prev.then(fn, fn);
71
+ // tail: 성패 무관 settle 되는 핸들. 이걸 맵에 두고, finally 에서 "여전히 내가 끝"일 때만 삭제.
72
+ // (set 직후 undefined 검사로 하면 절대 삭제 안 돼 세션 id 가 누수됨 — tail 일치 검사로 회피.)
73
+ const tail = next.then(
74
+ () => {},
75
+ () => {},
76
+ );
77
+ sessionChains.set(sessionId, tail);
78
+ try {
79
+ return await next;
80
+ } finally {
81
+ // 그 사이 다른 turn 이 체인을 이어받았으면 tail 이 교체돼 있다 — 그때는 삭제하지 않는다.
82
+ if (sessionChains.get(sessionId) === tail) sessionChains.delete(sessionId);
83
+ }
84
+ }
85
+
86
+ // ── coord 매핑 ──────────────────────────────────────────────────────
87
+
88
+ // token 기반 안정 workerId 합성. Anthropic 은 worker pool 이 없으므로 tokenId 를 그대로 쓴다.
89
+ export function makeAnthropicWorkerId(tokenId: number): number {
90
+ return tokenId;
91
+ }
92
+
93
+ // ── envelope decorate ──────────────────────────────────────────────
94
+
95
+ // stream-json 입력 라인에 SDK envelope 부착 후 직렬화. session 소유자인 여기서 한다.
96
+ export function decorateAndSerialize(lines: Array<ClaudeStreamJsonLine>, sessionId: string): string {
97
+ return (
98
+ lines
99
+ .map((line) =>
100
+ JSON.stringify({
101
+ ...line,
102
+ session_id: sessionId,
103
+ uuid: randomUUID(),
104
+ parent_tool_use_id: null,
105
+ }),
106
+ )
107
+ .join("\n") + "\n"
108
+ );
109
+ }
110
+
111
+ // ── spawn + consume ────────────────────────────────────────────────
112
+
113
+ export interface ClaudeSessionRequest {
114
+ tokenId: number;
115
+ token: string; // OAuth access token
116
+ model: string;
117
+ system?: string;
118
+ jsonSchema?: string; // 있으면 structured output
119
+ effort?: string;
120
+ timeoutMs: number;
121
+ // 입력: cold(coldHistory+coldInput) 또는 resume(reuseInput).
122
+ coldHistory?: Array<JsonValue>;
123
+ input: Array<UserInput>;
124
+ // resume 경로면 기존 session-id, cold 면 undefined(새로 발급).
125
+ resumeSessionId?: string;
126
+ abortSignal?: AbortSignal;
127
+ }
128
+
129
+ export interface ClaudeSessionResult extends ClaudeStreamResult {
130
+ sessionId: string;
131
+ workerId: number;
132
+ }
133
+
134
+ // per-token CLAUDE_CONFIG_DIR 보장(격리). 경로 규칙은 anthropicConfigDir(순수 함수)이 소유 —
135
+ // 여기선 그 경로에 빈 settings 를 깔아 claude-mem hook 등을 차단한다. (R10 격리)
136
+ // 이 seed 파일은 claude-mem hook / 사용자 설정 오염 차단을 위한 격리 경계다. 매 호출마다 다시 써서
137
+ // 외부 변조·삭제 후에도 다음 실행에서 self-healing 되도록 기존 semantics 를 유지한다.
138
+ export function ensureConfigDir(tokenId: number): string {
139
+ const dir = anthropicConfigDir(tokenId);
140
+ mkdirSync(dir, { recursive: true });
141
+ writeFileSync(`${dir}/.claude.json`, "{}");
142
+ writeFileSync(`${dir}/settings.json`, "{}");
143
+ return dir;
144
+ }
145
+
146
+ let cwdEnsured = false;
147
+ function ensureCwd(): void {
148
+ if (cwdEnsured) return;
149
+ mkdirSync(`${ANTHROPIC_CLAUDE_CWD}/.claude`, { recursive: true });
150
+ writeFileSync(`${ANTHROPIC_CLAUDE_CWD}/.claude/settings.json`, "{}");
151
+ cwdEnsured = true;
152
+ }
153
+
154
+ // spawn args 구성. 멀티턴/격리/structured 모드 반영.
155
+ export function buildClaudeArgs(opts: {
156
+ model: string;
157
+ system?: string;
158
+ effort?: string;
159
+ jsonSchema?: string;
160
+ sessionId: string;
161
+ isResume: boolean;
162
+ }): Array<string> {
163
+ const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
164
+ // --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
165
+ const toolArgs = useStructured
166
+ ? ["--tools", "", "--allowed-tools", "StructuredOutput"]
167
+ : ["--tools", ""];
168
+
169
+ const args: Array<string> = [
170
+ "-p",
171
+ ...toolArgs,
172
+ "--disallowedTools",
173
+ ...ANTHROPIC_DISALLOWED_TOOLS,
174
+ "--input-format",
175
+ "stream-json",
176
+ "--output-format",
177
+ "stream-json",
178
+ "--verbose",
179
+ "--max-turns",
180
+ useStructured ? "2" : "1",
181
+ "--permission-mode",
182
+ "bypassPermissions",
183
+ "--setting-sources",
184
+ "project",
185
+ "--model",
186
+ opts.model,
187
+ // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
188
+ "--system-prompt",
189
+ opts.system ?? "",
190
+ "--thinking",
191
+ "disabled",
192
+ "--effort",
193
+ opts.effort ?? ANTHROPIC_DEFAULT_EFFORT,
194
+ "--disable-slash-commands",
195
+ // 멀티턴: 첫 호출은 session-id 발급, 후속은 resume. --no-session-persistence 는 쓰지 않는다.
196
+ ...(opts.isResume ? ["--resume", opts.sessionId] : ["--session-id", opts.sessionId]),
197
+ ];
198
+ if (useStructured) args.push("--json-schema", opts.jsonSchema!);
199
+ return args;
200
+ }
201
+
202
+ /**
203
+ * Claude 세션을 spawn 해 한 turn 을 실행한다. per-session 락으로 직렬화된다.
204
+ * @param onDelta 점진 텍스트 delta(streamText) 또는 structured 부분 JSON(streamObject).
205
+ */
206
+ export function runClaudeSession(
207
+ req: ClaudeSessionRequest,
208
+ onDelta: (text: string) => void,
209
+ ): Promise<ClaudeSessionResult> {
210
+ // 정규화 규칙은 canonicalAnthropicModel 이 단독 소유 — dispatcher 가 이미 canonical 을 넘기지만,
211
+ // 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
212
+ const model = canonicalAnthropicModel(req.model);
213
+ const isResume = Boolean(req.resumeSessionId);
214
+ const sessionId = req.resumeSessionId ?? randomUUID();
215
+ const useStructured = Boolean(req.jsonSchema && req.jsonSchema.length > 0);
216
+ const workerId = makeAnthropicWorkerId(req.tokenId);
217
+
218
+ return withSessionLock(sessionId, () => {
219
+ ensureCwd();
220
+ const configDir = ensureConfigDir(req.tokenId);
221
+
222
+ const args = buildClaudeArgs({
223
+ model,
224
+ system: req.system,
225
+ effort: req.effort,
226
+ jsonSchema: req.jsonSchema,
227
+ sessionId,
228
+ isResume,
229
+ });
230
+
231
+ const inputLines = buildStreamJsonInput({
232
+ coldHistory: req.coldHistory,
233
+ input: req.input,
234
+ isResume,
235
+ });
236
+ const stdinPayload = decorateAndSerialize(inputLines, sessionId);
237
+
238
+ return new Promise<ClaudeSessionResult>((resolve, reject) => {
239
+ const child = spawn("claude", args, {
240
+ // stderr 도 캡처: invalid OAuth / 잘못된 플래그 / CLI 검증 실패가
241
+ // "closed without result" 로 뭉개지지 않게 close/error 에 stderr 를 실어 보낸다.
242
+ stdio: ["pipe", "pipe", "pipe"],
243
+ cwd: ANTHROPIC_CLAUDE_CWD,
244
+ env: {
245
+ PATH: process.env.PATH,
246
+ TMPDIR: process.env.TMPDIR,
247
+ CLAUDE_CODE_OAUTH_TOKEN: req.token,
248
+ CLAUDE_CONFIG_DIR: configDir,
249
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
250
+ CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
251
+ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1",
252
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
253
+ CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
254
+ },
255
+ });
256
+
257
+ let settled = false;
258
+ let buffer = "";
259
+ const streamState: ClaudeStreamJsonState = {};
260
+ // bounded stderr 버퍼(최근 ~4KB만 유지 — 무한 누적 방지).
261
+ let stderrBuf = "";
262
+ const STDERR_CAP = 4096;
263
+ child.stderr?.on("data", (d: Buffer) => {
264
+ stderrBuf = (stderrBuf + d.toString()).slice(-STDERR_CAP);
265
+ });
266
+ const stderrSuffix = () => (stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim()}` : "");
267
+ const timer = setTimeout(() => {
268
+ if (settled) return;
269
+ settled = true;
270
+ child.kill();
271
+ reject(new Error(`Claude session timeout after ${req.timeoutMs / 1000}s`));
272
+ }, req.timeoutMs);
273
+
274
+ const finish = (result: ClaudeStreamResult) => {
275
+ if (settled) return;
276
+ settled = true;
277
+ clearTimeout(timer);
278
+ resolve({ ...result, sessionId, workerId });
279
+ };
280
+
281
+ req.abortSignal?.addEventListener("abort", () => {
282
+ if (settled) return;
283
+ settled = true;
284
+ clearTimeout(timer);
285
+ child.kill();
286
+ reject(new Error("aborted"));
287
+ });
288
+
289
+ child.stdout?.on("data", (d: Buffer) => {
290
+ if (settled) return;
291
+ buffer += d.toString();
292
+ const lines = buffer.split("\n");
293
+ buffer = lines.pop() ?? "";
294
+ for (const line of lines) {
295
+ const result = handleStreamJsonLine(line, onDelta, {
296
+ structuredOutput: useStructured,
297
+ state: streamState,
298
+ });
299
+ if (result) {
300
+ finish(result);
301
+ return;
302
+ }
303
+ }
304
+ });
305
+
306
+ child.on("close", () => {
307
+ if (settled) return;
308
+ settled = true;
309
+ clearTimeout(timer);
310
+ reject(new Error(`Claude session closed without result${stderrSuffix()}`));
311
+ });
312
+ child.on("error", (err) => {
313
+ if (settled) return;
314
+ settled = true;
315
+ clearTimeout(timer);
316
+ reject(new Error(`Claude session spawn error: ${err.message}`));
317
+ });
318
+
319
+ // stdin 으로 입력 JSONL 흘리고 닫는다.
320
+ child.stdin?.write(stdinPayload);
321
+ child.stdin?.end();
322
+ });
323
+ });
324
+ }
325
+
326
+ // usage 재노출(테스트 편의).
327
+ export type { TokenUsageBreakdown };