@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.
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -277
- 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 +262 -2
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +142 -373
- 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,86 +1,39 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
1
|
/**
|
|
3
|
-
* QgridDispatcher —
|
|
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
|
-
* -
|
|
9
|
-
* - QuotaError 는 그대로 상위 전파
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
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
|
|
127
|
-
|
|
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 (
|
|
134
|
-
|
|
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.
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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 는
|
|
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
|
-
|
|
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(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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) {
|