@cartanova/qgrid-cli 2.3.2 → 2.3.4
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 -278
- package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +42 -98
- package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
- 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 +235 -7
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -364
- package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
- package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +61 -188
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
- package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
- 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,100 +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
|
-
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
|
-
//
|
|
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,188 @@ 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
|
|
|
70
|
+
const route = parseProviderRoute(input.model);
|
|
140
71
|
const outputSchema = buildStrictOutputSchema(input);
|
|
141
72
|
|
|
142
73
|
// provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
|
|
143
|
-
if (
|
|
144
|
-
|
|
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
|
-
});
|
|
162
|
-
|
|
163
|
-
const issuedCoord = issueConvContext(result.threadCoord, decision);
|
|
164
|
-
return applyToolCallEmulation(toEmulationResult(result), input.tools, issuedCoord);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
74
|
+
if (route.provider === "openai") {
|
|
75
|
+
if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
|
|
167
76
|
|
|
168
|
-
// Anthropic — AnthropicDispatcher(멀티턴 session resume) 경로. OpenAI 경로와 동형.
|
|
169
|
-
if (this.anthropicDispatcher) {
|
|
170
77
|
const decision = decideConvRouting(input);
|
|
171
|
-
const result = await this.
|
|
172
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
126
|
+
const route = parseProviderRoute(input.model);
|
|
127
|
+
const outputSchema = buildStrictOutputSchema(input);
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const outputSchema = buildStrictOutputSchema(input);
|
|
194
|
+
throw directLlmApiFallbackNotImplemented(input);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
277
197
|
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
// 모든 provider(OpenAI/Anthropic)가 동일하게 strict output schema 를 쓴다.
|
|
216
|
+
//
|
|
217
|
+
// SON-495 교훈: 한때 Anthropic route 만 required 를 제거(optionalize)해 partial/rejected attempt 가
|
|
218
|
+
// 섞여도 통과하게 했으나, 실측 결과 그게 오히려 "모델이 비는 필드를 키째 생략"하도록 유도했다
|
|
219
|
+
// (required 살린 schema 는 누락 0, loose 는 누락 다발). strict 로 두면 모델이 빠짐없이 채우고,
|
|
220
|
+
// 드물게 어겨도 retry=1 + 비정상 종료는 정직한 에러(stream-json-adapter)로 받아준다.
|
|
221
|
+
export function buildStrictOutputSchema(
|
|
222
|
+
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
223
|
+
): JsonValue | undefined {
|
|
224
|
+
const outputSchema = buildRawOutputSchema(input);
|
|
225
|
+
|
|
226
|
+
return outputSchema
|
|
227
|
+
? (strictify(outputSchema as Parameters<typeof strictify>[0]) as JsonValue)
|
|
228
|
+
: undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildRawOutputSchema(
|
|
232
|
+
input: Pick<QueryInput, "tools" | "jsonSchema">,
|
|
233
|
+
): JsonValue | undefined {
|
|
234
|
+
return input.tools?.length
|
|
235
|
+
? buildToolCallSchema(input.tools)
|
|
236
|
+
: input.jsonSchema
|
|
237
|
+
? (JSON.parse(input.jsonSchema) as JsonValue)
|
|
238
|
+
: undefined;
|
|
307
239
|
}
|
|
308
240
|
|
|
309
241
|
// GenerateResult(provider dispatcher 응답)를 applyToolCallEmulation 입력 shape 로 매핑한다.
|
|
310
242
|
// OpenAI/Anthropic, query/queryStream 4 경로가 동일하게 쓴다.
|
|
311
243
|
// Anthropic adapter 는 cache creation 을 inputTokens 에 포함해 표준화하고, 별도 필드에도 보존한다.
|
|
312
|
-
// per-request cost 는
|
|
244
|
+
// per-request cost 는 provider 가 준 값을 우선 사용하고, 없으면 공통 cost 함수를 쓴다.
|
|
313
245
|
function toEmulationResult(
|
|
314
246
|
result: GenerateResult,
|
|
315
247
|
): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
|
|
@@ -336,148 +268,4 @@ function toEmulationResult(
|
|
|
336
268
|
};
|
|
337
269
|
}
|
|
338
270
|
|
|
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
271
|
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) {
|