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