@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.
Files changed (26) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +91 -278
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
  3. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
  4. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +39 -96
  5. package/bundle/dist/utils/providers/anthropic/claude-session.js +47 -32
  6. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +3 -36
  7. package/bundle/dist/utils/providers/common/model-cost.js +3 -2
  8. package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
  9. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
  10. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +193 -6
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +140 -364
  12. package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
  13. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
  14. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
  15. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
  16. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +50 -185
  17. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +71 -62
  18. package/bundle/src/utils/providers/anthropic/claude-session.ts +56 -46
  19. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +19 -30
  20. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +24 -101
  21. package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
  22. package/bundle/src/utils/providers/common/model-cost.ts +2 -1
  23. package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
  24. package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
  25. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
  26. package/package.json +1 -1
@@ -1,100 +1,39 @@
1
- import assert from "node:assert";
2
1
  /**
3
- * QgridDispatcher — OAuth 토큰 선택 + claude CLI fresh spawn 디스패처 싱글턴.
2
+ * QgridDispatcher — provider dispatcher 라우팅 + 토큰 캐시/통계 싱글턴.
4
3
  *
5
- * - 매 요청마다 새 claude CLI 프로세스 spawn → 응답 후 종료
6
- * - least-used round-robin 으로 토큰 선택
7
4
  * - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
8
- * - query expires_at 비교 임박 시 preemptive refresh
9
- * - QuotaError 는 그대로 상위 전파 (자동 failover 없음, UI 에서 수동 토글)
10
- *
11
- * env allowlist: PATH, TMPDIR, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CONFIG_DIR
12
- * + CLAUDE_CODE_DISABLE_* + CLAUDE_CODE_ATTRIBUTION_HEADER
5
+ * - OpenAI/Anthropic 요청은 provider dispatcher 로만 실행
6
+ * - QuotaError 는 그대로 상위 전파
13
7
  */
14
- import { spawn } from "node:child_process";
15
- import { mkdirSync, writeFileSync } from "node:fs";
16
-
17
- import { getLogger } from "@logtape/logtape";
18
8
 
19
9
  import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
20
- import { canonicalAnthropicModel } from "../../utils/providers/anthropic/anthropic-constants";
21
10
  import { type AnthropicDispatcher } from "../../utils/providers/anthropic/anthropic-dispatcher";
22
- import {
23
- getAccessToken,
24
- getExpiresAt,
25
- getRefreshToken,
26
- } from "../../utils/providers/common/credentials";
11
+ import { getAccessToken } from "../../utils/providers/common/credentials";
27
12
  import { calculateCostUsd } from "../../utils/providers/common/model-cost";
28
- import { type GenerateResult } from "../../utils/providers/common/provider-dispatcher";
13
+ import {
14
+ type GenerateResult,
15
+ type StreamCallbacks,
16
+ } from "../../utils/providers/common/provider-dispatcher";
29
17
  import { strictify } from "../../utils/providers/common/strictifier";
30
18
  import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
31
19
  import { type TokenSubsetA } from "../sonamu.generated";
32
20
  import { decideConvRouting, issueConvContext } from "./conv-routing";
33
21
  import { type QueryInput, type QueryOutput, type TokenStats } from "./qgrid.types";
34
- import { maskToken, ProcessError, QuotaError, TimeoutError } from "./qgrid.types";
22
+ import { maskToken, ProcessError, QuotaError } from "./qgrid.types";
35
23
  import { type TokenSubscriber } from "./token-subscriber";
36
24
  import { applyToolCallEmulation, buildToolCallSchema } from "./tool-emulation";
37
25
 
38
- const logger = getLogger(["qgrid"]);
39
-
40
- const DEFAULT_MODEL = "sonnet";
41
- const DEFAULT_TIMEOUT_MS = 600_000;
42
- const DEFAULT_EFFORT = "high";
43
-
44
- // claude CLI 의 cwd. 이 경로의 .claude/settings.json 이 project scope 로 로드되어
45
- // 혹시라도 있을 user scope (~/.claude/settings.json)를 덮어씀 (--setting-sources project 와 함께).
46
- const CLAUDE_CWD = "/tmp/qgrid";
47
-
48
- // 글로벌 user config 격리 경로. (CC에서 환경변수로 읽는변수)
49
- const CLAUDE_CONFIG_DIR = "/tmp/qgrid-config";
50
-
51
- // qgrid 전용 project/global settings — user scope 격리용
52
- const QGRID_CLAUDE_SETTINGS = {
53
- alwaysThinkingEnabled: false, // thinking block 차단
54
- includeGitInstructions: false, // system prompt 의 git 가이드 제거
55
- cleanupPeriodDays: 1,
56
- };
57
-
58
- // 토큰 만료 임박 임계값 — token.expiredAt이 1분 안에 만료된다면 query 시 체크하고 refresh.
59
- const REFRESH_SAFETY_MS = 60_000;
60
-
61
- export function buildStrictOutputSchema(
62
- input: Pick<QueryInput, "tools" | "jsonSchema">,
63
- ): JsonValue | undefined {
64
- const outputSchema = input.tools?.length
65
- ? buildToolCallSchema(input.tools)
66
- : input.jsonSchema
67
- ? (JSON.parse(input.jsonSchema) as JsonValue)
68
- : undefined;
69
-
70
- return outputSchema
71
- ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
72
- : undefined;
73
- }
74
-
75
26
  export class QgridDispatcherClass {
76
27
  tokens = new Map<number, TokenSubsetA>();
77
28
 
78
- // key기반(tokenName) 누적 카운터. token OAuth refresh 로테이트되도 tokenName 은 불변이라 카운터 유지됨
29
+ // TokenStats shape 보존용. 실제 provider 요청 카운팅은 dispatcher 이동했다.
79
30
  requestCounts = new Map<string, number>();
80
- rrIndex = 0;
81
31
 
82
32
  // sonamu.config onStart 에서 처리하는 변수
83
33
  subscriber: TokenSubscriber | null = null;
84
34
  openaiDispatcher: OpenAIDispatcher | null = null;
85
35
  anthropicDispatcher: AnthropicDispatcher | null = null;
86
36
 
87
- constructor() {
88
- const settingsJson = JSON.stringify(QGRID_CLAUDE_SETTINGS, null, 2);
89
-
90
- mkdirSync(`${CLAUDE_CWD}/.claude`, { recursive: true });
91
- writeFileSync(`${CLAUDE_CWD}/.claude/settings.json`, settingsJson);
92
-
93
- mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
94
- writeFileSync(`${CLAUDE_CONFIG_DIR}/.claude.json`, "{}");
95
- writeFileSync(`${CLAUDE_CONFIG_DIR}/settings.json`, settingsJson);
96
- }
97
-
98
37
  countOf(name: string): number {
99
38
  return this.requestCounts.get(name) ?? 0;
100
39
  }
@@ -121,195 +60,176 @@ export class QgridDispatcherClass {
121
60
  }));
122
61
  }
123
62
 
124
- selectToken(provider = "anthropic"): TokenSubsetA | null {
125
- const rows = [...this.tokens.values()].filter((r) => r.provider === provider);
126
- if (rows.length === 0) return null;
127
-
128
- const minCount = Math.min(...rows.map((r) => this.countOf(r.name)));
129
- const idle = rows.filter((r) => this.countOf(r.name) === minCount);
130
- const picked = idle[this.rrIndex % idle.length]!;
131
- this.rrIndex++;
132
- return picked;
133
- }
134
-
135
63
  async query(input: QueryInput, timeoutMs?: number): Promise<QueryOutput> {
64
+ void timeoutMs;
65
+
136
66
  if (input.tools?.length && input.jsonSchema) {
137
67
  throw new ProcessError("tools and jsonSchema cannot be used together");
138
68
  }
139
69
 
140
70
  const outputSchema = buildStrictOutputSchema(input);
71
+ const route = parseProviderRoute(input.model);
141
72
 
142
73
  // provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
143
- if (input.model?.includes("/")) {
144
- const [provider, model] = input.model.split("/", 2);
145
- assert(model, "unknown model");
146
- if (provider === "openai") {
147
- if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
148
- const decision = decideConvRouting(input);
149
- const result = await this.openaiDispatcher.generate({
150
- model: model,
151
- systemPrompt: input.system,
152
- outputSchema,
153
- effort: input.effort,
154
- verbosity: input.verbosity,
155
- reasoningSummary: input.reasoningSummary,
156
- serviceTier: input.serviceTier,
157
- coldInput: decision.coldInput,
158
- coldHistory: decision.coldHistory,
159
- reuse: decision.reuse,
160
- reuseInput: decision.reuseInput,
161
- });
74
+ if (route.provider === "openai") {
75
+ if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
162
76
 
163
- const issuedCoord = issueConvContext(result.threadCoord, decision);
164
- return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
165
- }
166
- }
167
-
168
- // Anthropic — AnthropicDispatcher(멀티턴 session resume) 경로. OpenAI 경로와 동형.
169
- if (this.anthropicDispatcher) {
170
77
  const decision = decideConvRouting(input);
171
- const result = await this.anthropicDispatcher.generate({
172
- // model 미지정이면 AnthropicDispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용 — "sonnet" 별칭을
173
- // 강제로 끼워 dispatcher default 를 죽이지 않는다. prefix 정규화도 dispatcher 내부에서.
174
- model: input.model,
78
+ const result = await this.openaiDispatcher.generate({
79
+ model: route.model,
175
80
  systemPrompt: input.system,
176
81
  outputSchema,
177
82
  effort: input.effort,
83
+ verbosity: input.verbosity,
84
+ reasoningSummary: input.reasoningSummary,
85
+ serviceTier: input.serviceTier,
178
86
  coldInput: decision.coldInput,
179
87
  coldHistory: decision.coldHistory,
180
88
  reuse: decision.reuse,
181
89
  reuseInput: decision.reuseInput,
182
90
  });
183
91
 
184
- const issuedCoord = issueConvContext(result.threadCoord, decision);
185
- return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
186
- }
187
-
188
- // 폴백: AnthropicDispatcher 미초기화 시 기존 stateless claude -p 경로(멀티턴 없음).
189
- const executionInput = outputSchema
190
- ? { ...input, jsonSchema: JSON.stringify(outputSchema) }
191
- : input;
92
+ return applyToolCallEmulation(
93
+ toEmulationResult(result),
94
+ input.tools,
95
+ issueConvContext(result.threadCoord, decision),
96
+ );
97
+ } else if (route.provider === "anthropic") {
98
+ if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
192
99
 
193
- const electedToken = this.selectToken();
194
- if (!electedToken) throw new QuotaError("No tokens available");
195
-
196
- // await 전에 count 선반영. 병렬 요청이 동시에 도착해도 각자 다른 토큰을 고르도록.
197
- this.requestCounts.set(electedToken.name, this.countOf(electedToken.name) + 1);
100
+ const decision = decideConvRouting(input);
101
+ const result = await this.anthropicDispatcher.generate({
102
+ // AnthropicDispatcher 내부에서 알아서 provider prefix 를 canonical model 로 정규화함
103
+ model: input.model,
104
+ systemPrompt: input.system,
105
+ outputSchema,
106
+ effort: input.effort,
107
+ coldInput: decision.coldInput,
108
+ coldHistory: decision.coldHistory,
109
+ });
198
110
 
199
- let token = getAccessToken(electedToken.credentials);
200
- // expires_at 임박이면 preemptive refresh
201
- const expiresAt = getExpiresAt(electedToken.credentials);
202
- if (
203
- expiresAt &&
204
- expiresAt - Date.now() < REFRESH_SAFETY_MS &&
205
- getRefreshToken(electedToken.credentials)
206
- ) {
207
- try {
208
- const { QgridFrame } = await import("./qgrid.frame");
209
- token = await QgridFrame.refreshToken(electedToken);
210
- } catch (e) {
211
- logger.warn(`refresh failed for ${electedToken.name}: ${(e as Error).message}`);
212
- }
111
+ return applyToolCallEmulation(
112
+ toEmulationResult(result),
113
+ input.tools,
114
+ issueConvContext(result.threadCoord, decision),
115
+ );
213
116
  }
214
117
 
215
- logger.info(`→ ${electedToken.name} (model: ${input.model ?? DEFAULT_MODEL})`);
216
-
217
- const result = await executeClaude(executionInput, token, timeoutMs ?? DEFAULT_TIMEOUT_MS);
218
- return applyToolCallEmulation(
219
- // model 도 canonical 로 — fallback 도 cost/표기가 main 경로와 일치하게.
220
- { ...result, tokenName: electedToken.name, model: canonicalAnthropicModel(input.model) },
221
- input.tools,
222
- );
118
+ throw directLlmApiFallbackNotImplemented(input);
223
119
  }
224
120
 
225
121
  async queryStream(
226
122
  input: QueryInput,
227
- cb: {
228
- onDelta: (text: string) => void;
229
- onComplete: (result: QueryOutput) => void;
230
- onError: (error: Error) => void;
231
- onThreadId?: (threadId: string) => void;
232
- onTurnId?: (turnId: string) => void;
233
- },
123
+ cb: StreamCallbacks<QueryOutput>,
124
+ abortSignal?: AbortSignal,
234
125
  ): Promise<void> {
235
- // Anthropic 스트리밍: openai/ prefix 가 아니면 anthropic 경로.
236
- if (!input.model?.startsWith("openai/")) {
237
- // AnthropicDispatcher 가 있으면 실제 delta 스트리밍. 없으면 기존 query() 폴백(delta 없음).
238
- if (this.anthropicDispatcher) {
239
- const outputSchema = buildStrictOutputSchema(input);
240
- const decision = decideConvRouting(input);
241
- await this.anthropicDispatcher.generateStream(
242
- {
243
- // model 미지정 시 dispatcher 가 ANTHROPIC_DEFAULT_MODEL 적용. prefix 정규화도 내부에서.
244
- model: input.model,
245
- systemPrompt: input.system,
246
- outputSchema,
247
- effort: input.effort,
248
- coldInput: decision.coldInput,
249
- coldHistory: decision.coldHistory,
250
- reuse: decision.reuse,
251
- reuseInput: decision.reuseInput,
126
+ const outputSchema = buildStrictOutputSchema(input);
127
+ const route = parseProviderRoute(input.model);
128
+
129
+ if (route.provider === "openai") {
130
+ if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
131
+
132
+ const decision = decideConvRouting(input);
133
+ await this.openaiDispatcher.generateStream(
134
+ {
135
+ model: route.model,
136
+ systemPrompt: input.system,
137
+ outputSchema,
138
+ effort: input.effort,
139
+ verbosity: input.verbosity,
140
+ reasoningSummary: input.reasoningSummary,
141
+ serviceTier: input.serviceTier,
142
+ coldInput: decision.coldInput,
143
+ coldHistory: decision.coldHistory,
144
+ reuse: decision.reuse,
145
+ reuseInput: decision.reuseInput,
146
+ abortSignal,
147
+ },
148
+ {
149
+ onDelta: cb.onDelta,
150
+ onThreadId: cb.onThreadId,
151
+ onTurnId: cb.onTurnId,
152
+ onComplete: (turnResult) => {
153
+ cb.onComplete(
154
+ applyToolCallEmulation(
155
+ toEmulationResult(turnResult),
156
+ input.tools,
157
+ issueConvContext(turnResult.threadCoord, decision),
158
+ ),
159
+ );
252
160
  },
253
- {
254
- onDelta: cb.onDelta,
255
- onThreadId: cb.onThreadId,
256
- onComplete: (turnResult) => {
257
- const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
258
- cb.onComplete(
259
- applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
260
- );
261
- },
262
- onError: cb.onError,
161
+ onError: cb.onError,
162
+ },
163
+ );
164
+ return;
165
+ } else if (route.provider === "anthropic") {
166
+ if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
167
+
168
+ const decision = decideConvRouting(input);
169
+ await this.anthropicDispatcher.generateStream(
170
+ {
171
+ model: input.model,
172
+ systemPrompt: input.system,
173
+ outputSchema,
174
+ effort: input.effort,
175
+ coldInput: decision.coldInput,
176
+ coldHistory: decision.coldHistory,
177
+ abortSignal,
178
+ },
179
+ {
180
+ onDelta: cb.onDelta,
181
+ onThreadId: cb.onThreadId,
182
+ onComplete: (turnResult) => {
183
+ const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
184
+ cb.onComplete(
185
+ applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
186
+ );
263
187
  },
264
- );
265
- return;
266
- }
267
- const result = await this.query(input);
268
- cb.onComplete(result);
188
+ onError: cb.onError,
189
+ },
190
+ );
269
191
  return;
270
192
  }
271
193
 
272
- const [, model] = input.model.split("/", 2);
273
- if (!model) throw new ProcessError("unknown model");
274
- if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
275
-
276
- const outputSchema = buildStrictOutputSchema(input);
194
+ throw directLlmApiFallbackNotImplemented(input);
195
+ }
196
+ }
277
197
 
278
- const decision = decideConvRouting(input);
279
- await this.openaiDispatcher.generateStream(
280
- {
281
- model,
282
- systemPrompt: input.system,
283
- outputSchema,
284
- effort: input.effort,
285
- verbosity: input.verbosity,
286
- reasoningSummary: input.reasoningSummary,
287
- serviceTier: input.serviceTier,
288
- coldInput: decision.coldInput,
289
- coldHistory: decision.coldHistory,
290
- reuse: decision.reuse,
291
- reuseInput: decision.reuseInput,
292
- },
293
- {
294
- onDelta: cb.onDelta,
295
- onThreadId: cb.onThreadId,
296
- onTurnId: cb.onTurnId,
297
- onComplete: (turnResult) => {
298
- const issuedCoord = issueConvContext(turnResult.threadCoord, decision);
299
- cb.onComplete(
300
- applyToolCallEmulation(toEmulationResult(turnResult), input.tools, issuedCoord),
301
- );
302
- },
303
- onError: cb.onError,
304
- },
305
- );
198
+ function parseProviderRoute(model: string | undefined): { provider?: string; model: string } {
199
+ if (!model?.includes("/")) {
200
+ return { model: model ?? "" };
306
201
  }
202
+
203
+ const [provider, routedModel] = model.split("/", 2);
204
+ if (!provider || !routedModel) throw new ProcessError("unknown model");
205
+ return { provider, model: routedModel };
206
+ }
207
+
208
+ // qgrid provider dispatcher 에서 처리하지 않는 모델은 향후 직접 LLM API 호출 지점으로 보낸다.
209
+ function directLlmApiFallbackNotImplemented(input: QueryInput): ProcessError {
210
+ return new ProcessError(
211
+ `Direct LLM API fallback not implemented for model: ${input.model ?? "<default>"}`,
212
+ );
213
+ }
214
+
215
+ export function buildStrictOutputSchema(
216
+ input: Pick<QueryInput, "tools" | "jsonSchema">,
217
+ ): JsonValue | undefined {
218
+ const outputSchema = input.tools?.length
219
+ ? buildToolCallSchema(input.tools)
220
+ : input.jsonSchema
221
+ ? (JSON.parse(input.jsonSchema) as JsonValue)
222
+ : undefined;
223
+
224
+ return outputSchema
225
+ ? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
226
+ : undefined;
307
227
  }
308
228
 
309
229
  // GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
310
230
  // OpenAI/Anthropic, query/queryStream 4 경로가 동일하게 쓴다.
311
231
  // Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
312
- // per-request cost 는 Claude Code 가 준 total_cost_usd 우선 사용하고 fallback 계산만 공통 cost 함수를 쓴다.
232
+ // per-request cost 는 provider 가 준 값을 우선 사용하고, 없으면 공통 cost 함수를 쓴다.
313
233
  function toEmulationResult(
314
234
  result: GenerateResult,
315
235
  ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
@@ -336,148 +256,4 @@ function toEmulationResult(
336
256
  };
337
257
  }
338
258
 
339
- async function executeClaude(
340
- input: QueryInput,
341
- token: string,
342
- timeoutMs: number,
343
- ): Promise<Omit<QueryOutput, "content" | "finishReason">> {
344
- const rawModel = input.model ?? DEFAULT_MODEL;
345
- const model = rawModel.includes("/") ? rawModel.split("/").pop()! : rawModel;
346
- const timeout = input.timeout ?? timeoutMs;
347
- const useStructuredOutput = input.jsonSchema && input.jsonSchema.length > 0;
348
-
349
- // --tools "" 로 모든 tool 을 기본 차단. structured output 쓰면 StructuredOutput 만 화이트리스트.
350
- // --tools "" 는 반드시 뒤에 다른 플래그가 와야 CLI 파싱이 빈 문자열로 인식
351
- const toolArgs = useStructuredOutput
352
- ? ["--tools", "", "--allowed-tools", "StructuredOutput"]
353
- : ["--tools", ""];
354
-
355
- const args: string[] = [
356
- "-p",
357
- ...toolArgs,
358
- "--disallowedTools",
359
- // CC에서 자동 활성화되는 deferred 도구. 토큰 최적화를위해 차단
360
- ...(["Monitor", "PushNotification", "RemoteTrigger"] as const),
361
- "--output-format",
362
- "stream-json",
363
- "--verbose",
364
- // --max-turns: structured output 은 tool_use + tool_result 로 2턴 소비
365
- "--max-turns",
366
- useStructuredOutput ? "2" : "1",
367
- "--permission-mode",
368
- "bypassPermissions",
369
- "--setting-sources",
370
- "project",
371
- "--model",
372
- model,
373
- // --system-prompt는 반드시 명시해야함. 옵션 생략 시 CC가 default system prompt를 자동으로 주입함(23k)
374
- "--system-prompt",
375
- input.system ?? "",
376
- "--thinking",
377
- "disabled",
378
- "--effort",
379
- input.effort ?? DEFAULT_EFFORT,
380
- "--no-session-persistence",
381
- // 모든 skills 비활성화
382
- "--disable-slash-commands",
383
- ];
384
- if (useStructuredOutput) {
385
- args.push("--json-schema", input.jsonSchema!);
386
- }
387
- args.push(input.prompt);
388
-
389
- const env: NodeJS.ProcessEnv = {
390
- PATH: process.env.PATH,
391
- TMPDIR: process.env.TMPDIR,
392
- CLAUDE_CODE_OAUTH_TOKEN: token,
393
- // CLAUDE_CONFIG_DIR 환경변수로 주입
394
- CLAUDE_CONFIG_DIR,
395
- CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
396
- CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
397
- CLAUDE_CODE_DISABLE_1M_CONTEXT: "1",
398
- CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
399
- CLAUDE_CODE_ATTRIBUTION_HEADER: "0",
400
- };
401
-
402
- return new Promise<Omit<QueryOutput, "content" | "finishReason">>((resolve, reject) => {
403
- const child = spawn("claude", args, {
404
- stdio: ["ignore", "pipe", "ignore"],
405
- env,
406
- // 격리 (두 단계):
407
- // 1. CLAUDE_CWD — project scope 격리 (cwd 의 .claude/settings.json)
408
- // 2. CLAUDE_CONFIG_DIR — user scope 격리 cwd 격리만으론 부족.
409
- cwd: CLAUDE_CWD,
410
- });
411
-
412
- let buffer = "";
413
- let settled = false;
414
- const timer = setTimeout(() => {
415
- if (settled) return;
416
- settled = true;
417
- child.kill();
418
- reject(new TimeoutError(`Timeout after ${timeout / 1000}s (token: ${maskToken(token)})`));
419
- }, timeout);
420
-
421
- child.stdout?.on("data", (d: Buffer) => {
422
- if (settled) return;
423
- buffer += d.toString();
424
- const lines = buffer.split("\n");
425
- buffer = lines.pop() ?? "";
426
- for (const line of lines) {
427
- if (!line.trim()) continue;
428
- try {
429
- const j = JSON.parse(line);
430
- if (j.type === "result" && !settled) {
431
- // --json-schema 사용 시 structured_output 에 파싱된 객체가 온다 → 우선 사용
432
- let text: string;
433
- if (j.structured_output !== undefined) {
434
- text = JSON.stringify(j.structured_output);
435
- } else {
436
- text = (j.result ?? "")
437
- .replace(/^```(?:json)?\s*\n?/i, "")
438
- .replace(/\n?```\s*$/i, "");
439
- }
440
-
441
- if (text.startsWith("You've hit")) {
442
- settled = true;
443
- clearTimeout(timer);
444
- reject(new QuotaError(`Quota exhausted (token: ${maskToken(token)})`));
445
- return;
446
- }
447
-
448
- const u = j.usage ?? {};
449
- settled = true;
450
- clearTimeout(timer);
451
- resolve({
452
- text,
453
- usage: {
454
- input_tokens: u.input_tokens ?? 0,
455
- output_tokens: u.output_tokens ?? 0,
456
- cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,
457
- cache_read_input_tokens: u.cache_read_input_tokens ?? 0,
458
- },
459
- durationMs: j.duration_ms ?? 0,
460
- costUsd: j.total_cost_usd ?? 0,
461
- });
462
- }
463
- } catch {}
464
- }
465
- });
466
-
467
- child.on("close", () => {
468
- if (settled) return;
469
- settled = true;
470
- clearTimeout(timer);
471
- reject(new ProcessError(`CLI process closed without result (token: ${maskToken(token)})`));
472
- });
473
-
474
- child.on("error", (err) => {
475
- if (settled) return;
476
- settled = true;
477
- clearTimeout(timer);
478
- reject(new ProcessError(`CLI process error: ${err.message} (token: ${maskToken(token)})`));
479
- });
480
- });
481
- }
482
-
483
259
  export const QgridDispatcher = new QgridDispatcherClass();
@@ -190,37 +190,47 @@ class QgridFrameClass extends BaseFrameClass {
190
190
  let turnId: string | undefined;
191
191
  let streamResult: QueryOutput | undefined;
192
192
  let streamError: Error | undefined;
193
+ const abortController = new AbortController();
193
194
 
194
- sse.onClose(() => {
195
+ const interruptOpenAI = () => {
195
196
  if (threadId && turnId) {
196
197
  QgridDispatcher.openaiDispatcher?.interruptWorkerTurn(threadId, turnId).catch(() => {});
197
198
  }
199
+ };
200
+
201
+ sse.onClose(() => {
202
+ abortController.abort();
203
+ interruptOpenAI();
198
204
  });
199
205
 
200
206
  try {
201
- await QgridDispatcher.queryStream(args, {
202
- onDelta: (text) => {
203
- if (!sse.closed) sse.publish("delta", { text });
204
- },
205
- onThreadId: (id) => {
206
- threadId = id;
207
- if (sse.closed && turnId) {
208
- QgridDispatcher.openaiDispatcher?.interruptWorkerTurn(threadId, turnId).catch(() => {});
209
- }
210
- },
211
- onTurnId: (id) => {
212
- turnId = id;
213
- if (sse.closed && threadId) {
214
- QgridDispatcher.openaiDispatcher?.interruptWorkerTurn(threadId, turnId).catch(() => {});
215
- }
216
- },
217
- onComplete: (result) => {
218
- streamResult = result;
219
- },
220
- onError: (err) => {
221
- streamError = err;
207
+ await QgridDispatcher.queryStream(
208
+ args,
209
+ {
210
+ onDelta: (text) => {
211
+ if (!sse.closed) sse.publish("delta", { text });
212
+ },
213
+ onThreadId: (id) => {
214
+ threadId = id;
215
+ if (sse.closed && turnId) {
216
+ interruptOpenAI();
217
+ }
218
+ },
219
+ onTurnId: (id) => {
220
+ turnId = id;
221
+ if (sse.closed && threadId) {
222
+ interruptOpenAI();
223
+ }
224
+ },
225
+ onComplete: (result) => {
226
+ streamResult = result;
227
+ },
228
+ onError: (err) => {
229
+ streamError = err;
230
+ },
222
231
  },
223
- });
232
+ abortController.signal,
233
+ );
224
234
  } catch (e) {
225
235
  streamError = e as Error;
226
236
  }
@@ -249,6 +259,11 @@ class QgridFrameClass extends BaseFrameClass {
249
259
  }
250
260
  if (!sse.closed) sse.publish("done", { ...streamResult, runContext });
251
261
  } else if (streamError) {
262
+ if (sse.closed && streamError.message === "aborted") {
263
+ if (runInfo) await finishRunAborted(runInfo.requestLogId, args);
264
+ await sse.end();
265
+ return;
266
+ }
252
267
  if (runInfo) await finishRunWithError(runInfo.requestLogId, streamError.message, args);
253
268
  if (!sse.closed) sse.publish("error", { message: streamError.message });
254
269
  } else if (sse.closed && runInfo) {