@cartanova/qgrid-cli 2.10.2 → 2.10.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/monit/log-buffer.js +7 -11
- package/bundle/dist/application/monit/monit.frame.js +6 -11
- package/bundle/dist/application/qgrid/oauth.js +2 -2
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
- package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
- package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
- package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
- package/bundle/dist/application/request-log/request-log.model.js +7 -5
- package/bundle/dist/application/setting/setting.model.js +1 -1
- package/bundle/dist/sonamu.config.js +6 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
- package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
- package/bundle/dist/utils/providers/common/effort.js +1 -1
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
- package/bundle/dist/utils/providers/common/thinking.js +12 -0
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
- package/bundle/src/application/monit/log-buffer.test.ts +3 -4
- package/bundle/src/application/monit/log-buffer.ts +13 -27
- package/bundle/src/application/monit/monit.frame.test.ts +7 -8
- package/bundle/src/application/monit/monit.frame.ts +9 -14
- package/bundle/src/application/qgrid/oauth.ts +1 -1
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
- package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
- package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
- package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
- package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
- package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
- package/bundle/src/application/request-log/request-log.model.ts +5 -5
- package/bundle/src/application/setting/setting.model.ts +1 -1
- package/bundle/src/sonamu.config.ts +5 -5
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
- package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
- package/bundle/src/utils/providers/common/effort.ts +1 -1
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
- package/bundle/src/utils/providers/common/thinking.ts +10 -0
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
- package/bundle/web-dist/client/assets/{index-DtRTSTes.js → index-BIC1sQxw.js} +3 -3
- package/bundle/web-dist/client/assets/{logs-BhVYDEzC.js → logs-zvdujZWn.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-BxN4JWqK.js → monit-CraVV3MQ.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-CBCqRp2o.js → routes-B3ZBoJ60.js} +1 -1
- package/bundle/web-dist/client/assets/{settings-hBTEC7Ki.js → settings-DwbzT5jo.js} +1 -1
- package/bundle/web-dist/client/assets/{show-BzYft5N4.js → show-ItTExdAh.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-PMybtI8X.js → tokens-BynKx8Vn.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-C51_d6-6.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +1 -1
- package/package.json +1 -1
- package/skills/qgrid/SKILL.md +1 -0
- package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
- package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
- package/skills/qgrid/references/decision-rationale.md +9 -0
- package/skills/qgrid/references/openai-codex-runtime.md +6 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
- package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
- package/skills/qgrid/references/verification-and-debugging.md +1 -1
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* OpenAIDispatcher와 달리, worker pool을 두는 대신 요청별 fresh spawn
|
|
5
|
-
* 토큰은 인메모리(MAP)으로 관리하고 smooth weighted round-robin 으로 고른다
|
|
6
|
-
*
|
|
7
|
-
* - 모든 요청은 fresh `--session-id` 로 실행
|
|
8
|
-
* - 멀티턴 문맥은 클라이언트가 매 호출 보내는 full history 를 평탄화해 전달한다.
|
|
9
|
-
* - 발급한 session-id 를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
|
|
10
|
-
* epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다. 다음 turn 이 좌표를
|
|
11
|
-
* 회송해도 Anthropic 라우팅에는 쓰지 않는다.
|
|
2
|
+
* 요청마다 새 Claude 프로세스에 전체 대화 이력을 전달한다.
|
|
3
|
+
* 반환한 threadCoord는 다음 요청의 세션 재사용이나 토큰 선택에 쓰지 않는다.
|
|
12
4
|
*/
|
|
13
5
|
|
|
14
6
|
import { getLogger } from "@logtape/logtape";
|
|
@@ -26,19 +18,14 @@ import {
|
|
|
26
18
|
import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
|
|
27
19
|
import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
|
|
28
20
|
import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
|
|
29
|
-
import {
|
|
30
|
-
|
|
31
|
-
readAnthropicQuotaUsage,
|
|
32
|
-
type AnthropicQuotaUsageResult,
|
|
33
|
-
} from "./anthropic-quota";
|
|
21
|
+
import { invalidateAnthropicQuotaUsage, readAnthropicQuotaUsage } from "./anthropic-quota";
|
|
22
|
+
import { assertAnthropicThinkingRuntime, resolveAnthropicThinking } from "./anthropic-thinking";
|
|
34
23
|
import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
|
|
35
24
|
|
|
36
25
|
const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
|
|
37
26
|
|
|
38
|
-
// 1M context
|
|
39
|
-
// 거부+거대 system 재생성 지연을 완전히 해결하는 값은 아니다.
|
|
27
|
+
// 1M context 생성은 100초 이상 걸릴 수 있다.
|
|
40
28
|
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
41
|
-
// access token 만료 임박 임계 — 기존 standalone 경로(qgrid.dispatcher.ts)와 동일(60s).
|
|
42
29
|
const REFRESH_SAFETY_MS = 60_000;
|
|
43
30
|
|
|
44
31
|
interface PooledToken {
|
|
@@ -49,30 +36,15 @@ interface PooledToken {
|
|
|
49
36
|
weight: number;
|
|
50
37
|
}
|
|
51
38
|
|
|
52
|
-
function quotaThresholdExceededMessage(
|
|
53
|
-
provider: string,
|
|
54
|
-
tokens: Array<{ tokenName: string; threshold: number }>,
|
|
55
|
-
): string {
|
|
56
|
-
const details = tokens
|
|
57
|
-
.map((token) => `${token.tokenName} (threshold ${token.threshold}%)`)
|
|
58
|
-
.join(", ");
|
|
59
|
-
return details.length > 0
|
|
60
|
-
? `All ${provider} tokens exceeded quota threshold: ${details}`
|
|
61
|
-
: `All ${provider} tokens exceeded quota threshold`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
39
|
export class AnthropicDispatcher implements ProviderDispatcher {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
40
|
+
tokenPool = new Map<number, PooledToken>();
|
|
41
|
+
readonly weightedSelector = new SmoothWeightedRoundRobin();
|
|
42
|
+
inFlight = 0;
|
|
68
43
|
|
|
69
44
|
async start(): Promise<void> {
|
|
70
|
-
//
|
|
71
|
-
// (서버 재시작 시 NOTIFY 가 안 오므로 부트스트랩이 없으면 풀이 빈 채로 남는다.)
|
|
72
|
-
// 이후 변경분은 token-subscriber 가 onTokenAdded/Updated/Removed 로 전달한다.
|
|
73
|
-
// findActiveByProvider 가 active=true + provider 필터를 DB 에서 처리 → inactive 는 애초에 안 온다.
|
|
45
|
+
// NOTIFY는 기존 토큰을 재전송하지 않으므로 시작 시 DB에서 읽는다.
|
|
74
46
|
const tokens = await TokenModel.findActiveByProvider("A", "anthropic");
|
|
75
|
-
|
|
47
|
+
tokens.forEach((t) => {
|
|
76
48
|
this.tokenPool.set(t.id, {
|
|
77
49
|
id: t.id,
|
|
78
50
|
name: t.name,
|
|
@@ -82,37 +54,17 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
82
54
|
});
|
|
83
55
|
this.weightedSelector.setToken(t.id, t.weight);
|
|
84
56
|
logger.info(`worker spawned: ${t.name}`);
|
|
85
|
-
}
|
|
57
|
+
});
|
|
86
58
|
}
|
|
87
59
|
|
|
88
60
|
async stop(): Promise<void> {
|
|
89
61
|
const tokenIds = [...this.tokenPool.keys()];
|
|
90
|
-
|
|
62
|
+
tokenIds.forEach((id) => this.weightedSelector.removeToken(id));
|
|
91
63
|
this.tokenPool.clear();
|
|
92
64
|
this.weightedSelector.resetScores();
|
|
93
65
|
}
|
|
94
66
|
|
|
95
|
-
//
|
|
96
|
-
get tokenCount(): number {
|
|
97
|
-
return this.tokenPool.size;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// 지금 실행 중인 Claude Code 프로세스 수 — fresh-spawn 이라 이게 곧 동시 요청 수다.
|
|
101
|
-
private inFlightCount = 0;
|
|
102
|
-
get inFlight(): number {
|
|
103
|
-
return this.inFlightCount;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Monit 표시용 — 풀에 있는 토큰 이름 스냅샷 (이름순 정렬)
|
|
107
|
-
get tokenNames(): string[] {
|
|
108
|
-
return [...this.tokenPool.values()].map((token) => token.name).toSorted();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
|
|
112
|
-
// LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
|
|
113
|
-
// 이벤트 핸들러를 재사용해 풀 갱신이 일관되게 적용되도록 한다:
|
|
114
|
-
// - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
|
|
115
|
-
// - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
|
|
67
|
+
// LISTEN/NOTIFY 연결이 끊긴 동안 놓친 변경을 DB 기준으로 맞춘다.
|
|
116
68
|
replaceTokens(
|
|
117
69
|
rows: Array<{
|
|
118
70
|
id: number;
|
|
@@ -123,21 +75,20 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
123
75
|
}>,
|
|
124
76
|
): void {
|
|
125
77
|
const next = new Set(rows.map((r) => r.id));
|
|
126
|
-
//
|
|
78
|
+
// 삭제할 키는 순회 전에 스냅샷한다.
|
|
127
79
|
const currentIds = Array.from(this.tokenPool.keys());
|
|
128
|
-
|
|
80
|
+
currentIds.forEach((id) => {
|
|
129
81
|
if (!next.has(id)) this.onTokenRemoved(id);
|
|
130
|
-
}
|
|
131
|
-
|
|
82
|
+
});
|
|
83
|
+
rows.forEach((r) => {
|
|
132
84
|
if (this.tokenPool.has(r.id)) {
|
|
133
85
|
this.onTokenUpdated(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
|
|
134
86
|
} else {
|
|
135
87
|
this.onTokenAdded(r.id, r.name, r.credentials, r.quotaThreshold, r.weight);
|
|
136
88
|
}
|
|
137
|
-
}
|
|
89
|
+
});
|
|
138
90
|
}
|
|
139
91
|
|
|
140
|
-
// token events (token-subscriber 가 호출)
|
|
141
92
|
onTokenAdded(
|
|
142
93
|
id: number,
|
|
143
94
|
name: string,
|
|
@@ -167,7 +118,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
167
118
|
|
|
168
119
|
// 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
|
|
169
120
|
// 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
|
|
170
|
-
|
|
121
|
+
async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
|
|
171
122
|
const preferred =
|
|
172
123
|
preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
|
|
173
124
|
if (preferredTokenId !== undefined && !preferred) {
|
|
@@ -185,8 +136,13 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
185
136
|
overThresholdTokens,
|
|
186
137
|
reason: "all_exceeded",
|
|
187
138
|
});
|
|
139
|
+
const details = overThresholdTokens
|
|
140
|
+
.map((token) => `${token.tokenName} (threshold ${token.threshold}%)`)
|
|
141
|
+
.join(", ");
|
|
188
142
|
throw new QuotaThresholdExceededError(
|
|
189
|
-
|
|
143
|
+
details
|
|
144
|
+
? `All anthropic tokens exceeded quota threshold: ${details}`
|
|
145
|
+
: "All anthropic tokens exceeded quota threshold",
|
|
190
146
|
);
|
|
191
147
|
}
|
|
192
148
|
|
|
@@ -197,7 +153,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
197
153
|
return this.tokenPool.get(selectedId) ?? null;
|
|
198
154
|
}
|
|
199
155
|
|
|
200
|
-
|
|
156
|
+
async filterEligibleTokens(
|
|
201
157
|
rows: PooledToken[],
|
|
202
158
|
model: string,
|
|
203
159
|
): Promise<{
|
|
@@ -219,24 +175,35 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
219
175
|
);
|
|
220
176
|
|
|
221
177
|
checks.forEach((check, index) => {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const token = check.value.token;
|
|
231
|
-
const result = check.value.result;
|
|
178
|
+
const token = check.status === "rejected" ? thresholded[index] : check.value.token;
|
|
179
|
+
if (!token) return;
|
|
180
|
+
const result =
|
|
181
|
+
check.status === "fulfilled"
|
|
182
|
+
? check.value.result
|
|
183
|
+
: { kind: "lookup_failed" as const, reason: String(check.reason) };
|
|
232
184
|
if (result.kind === "lookup_failed") {
|
|
233
|
-
|
|
185
|
+
logger.warn("quota_threshold gate: lookup_fail_open", {
|
|
186
|
+
tokenId: token.id,
|
|
187
|
+
tokenName: token.name,
|
|
188
|
+
provider: "anthropic",
|
|
189
|
+
threshold: token.quotaThreshold,
|
|
190
|
+
reason: "lookup_fail_open",
|
|
191
|
+
lookupReason: result.reason,
|
|
192
|
+
});
|
|
234
193
|
eligibleIds.add(token.id);
|
|
235
194
|
return;
|
|
236
195
|
}
|
|
237
196
|
|
|
238
197
|
if (result.utilizationPct >= token.quotaThreshold) {
|
|
239
|
-
|
|
198
|
+
logger.info("quota_threshold gate: over_threshold", {
|
|
199
|
+
tokenId: token.id,
|
|
200
|
+
tokenName: token.name,
|
|
201
|
+
provider: "anthropic",
|
|
202
|
+
threshold: token.quotaThreshold,
|
|
203
|
+
cachedUtilization: result.utilizationPct,
|
|
204
|
+
cacheAge: result.cacheAgeMs,
|
|
205
|
+
reason: "over_threshold",
|
|
206
|
+
});
|
|
240
207
|
overThresholdTokens.push({ tokenName: token.name, threshold: token.quotaThreshold });
|
|
241
208
|
return;
|
|
242
209
|
}
|
|
@@ -247,39 +214,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
247
214
|
return { eligible: rows.filter((token) => eligibleIds.has(token.id)), overThresholdTokens };
|
|
248
215
|
}
|
|
249
216
|
|
|
250
|
-
|
|
217
|
+
hasQuotaThreshold(token: PooledToken): token is PooledToken & { quotaThreshold: number } {
|
|
251
218
|
return token.quotaThreshold !== undefined && token.quotaThreshold !== null;
|
|
252
219
|
}
|
|
253
220
|
|
|
254
|
-
private logQuotaOverThreshold(
|
|
255
|
-
token: PooledToken & { quotaThreshold: number },
|
|
256
|
-
result: Extract<AnthropicQuotaUsageResult, { kind: "ok" }>,
|
|
257
|
-
): void {
|
|
258
|
-
logger.info("quota_threshold gate: over_threshold", {
|
|
259
|
-
tokenId: token.id,
|
|
260
|
-
tokenName: token.name,
|
|
261
|
-
provider: "anthropic",
|
|
262
|
-
threshold: token.quotaThreshold,
|
|
263
|
-
cachedUtilization: result.utilizationPct,
|
|
264
|
-
cacheAge: result.cacheAgeMs,
|
|
265
|
-
reason: "over_threshold",
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private logQuotaLookupFailOpen(
|
|
270
|
-
token: PooledToken & { quotaThreshold: number },
|
|
271
|
-
reason: string,
|
|
272
|
-
): void {
|
|
273
|
-
logger.warn("quota_threshold gate: lookup_fail_open", {
|
|
274
|
-
tokenId: token.id,
|
|
275
|
-
tokenName: token.name,
|
|
276
|
-
provider: "anthropic",
|
|
277
|
-
threshold: token.quotaThreshold,
|
|
278
|
-
reason: "lookup_fail_open",
|
|
279
|
-
lookupReason: reason,
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
|
-
|
|
283
221
|
async generate(req: GenerateRequest): Promise<GenerateResult> {
|
|
284
222
|
return this.run(req, () => {});
|
|
285
223
|
}
|
|
@@ -294,38 +232,26 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
294
232
|
}
|
|
295
233
|
}
|
|
296
234
|
|
|
297
|
-
|
|
298
|
-
private async run(...args: Parameters<AnthropicDispatcher["runInner"]>): Promise<GenerateResult> {
|
|
299
|
-
this.inFlightCount++;
|
|
300
|
-
try {
|
|
301
|
-
return await this.runInner(...args);
|
|
302
|
-
} finally {
|
|
303
|
-
this.inFlightCount--;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private async runInner(
|
|
235
|
+
async run(
|
|
308
236
|
req: GenerateRequest,
|
|
309
237
|
onDelta: (t: string) => void,
|
|
310
238
|
opts?: { includePartialMessages?: boolean },
|
|
311
239
|
): Promise<GenerateResult> {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
|
|
240
|
+
this.inFlight++;
|
|
241
|
+
try {
|
|
242
|
+
if (req.imageGeneration) {
|
|
243
|
+
throw new Error("image generation is not supported on the Anthropic route");
|
|
244
|
+
}
|
|
245
|
+
assertSupportedOneMillionSuffix(req.model);
|
|
246
|
+
const model = canonicalAnthropicModel(req.model);
|
|
247
|
+
const thinking = resolveAnthropicThinking(model, req.thinking, req.effort);
|
|
248
|
+
assertAnthropicThinkingRuntime(thinking.extraBody);
|
|
249
|
+
const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
|
|
323
250
|
|
|
324
|
-
|
|
325
|
-
|
|
251
|
+
const token = await this.selectToken(model, req.preferredTokenId);
|
|
252
|
+
if (!token) throw new Error("No anthropic tokens available");
|
|
326
253
|
|
|
327
|
-
|
|
328
|
-
// 만료 임박 토큰 preemptive refresh(기존 standalone 경로와 동일). 실패해도 진행.
|
|
254
|
+
// 만료 직전에 갱신하되 실패하면 기존 토큰으로 진행한다.
|
|
329
255
|
let accessToken = token.credentials.accessToken;
|
|
330
256
|
const expiresAt = getExpiresAt(token.credentials);
|
|
331
257
|
if (
|
|
@@ -336,7 +262,6 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
336
262
|
try {
|
|
337
263
|
const { QgridFrame } = await import("../../../application/qgrid/qgrid.frame");
|
|
338
264
|
// provider 를 반드시 채워야 refreshToken 내부 TokenModel.save 가 성공한다.
|
|
339
|
-
// refreshToken(TokenSubsetA) 는 id/provider/credentials/name 만 사용하므로 그 4개만 넘긴다.
|
|
340
265
|
accessToken = await QgridFrame.refreshToken({
|
|
341
266
|
id: token.id,
|
|
342
267
|
provider: "anthropic",
|
|
@@ -357,6 +282,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
357
282
|
system: req.systemPrompt,
|
|
358
283
|
jsonSchema,
|
|
359
284
|
effort: req.effort,
|
|
285
|
+
thinking: req.thinking,
|
|
360
286
|
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
361
287
|
coldHistory: req.coldHistory,
|
|
362
288
|
input: req.coldInput,
|
|
@@ -374,9 +300,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
374
300
|
throw new Error(`quota exhausted (${token.name})`);
|
|
375
301
|
}
|
|
376
302
|
if (session.isError) {
|
|
377
|
-
//
|
|
378
|
-
// 그동안 detail 이 session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /
|
|
379
|
-
// max turns 인지"를 구분할 수 없었다. text 는 진단에 필요한 만큼만 잘라 덧붙인다.
|
|
303
|
+
// 종료 사유를 먼저 표시하고 응답 본문은 진단에 필요한 만큼만 남긴다.
|
|
380
304
|
const reason =
|
|
381
305
|
session.subtype ?? session.terminalReason ?? (session.isError ? "is_error" : "unknown");
|
|
382
306
|
const body = session.text
|
|
@@ -395,20 +319,18 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
395
319
|
durationMs: session.durationMs,
|
|
396
320
|
ttftMs: session.ttftMs,
|
|
397
321
|
costUsd: session.costUsd,
|
|
398
|
-
// Claude Code
|
|
399
|
-
// 그대로 serving model 로 기록하면 비용/감사 로그가 모두 틀어지므로 둘을 구분한다.
|
|
322
|
+
// Claude Code의 모델 fallback을 비용·감사 로그에 반영한다.
|
|
400
323
|
model: session.servedModel ?? model,
|
|
401
324
|
requestedModel: model,
|
|
402
325
|
modelFallbacks: session.modelFallbacks,
|
|
403
|
-
// systemHash 는 상위 issueConvContext 가 채운다(여기선 비운다).
|
|
404
326
|
threadCoord: {
|
|
405
327
|
workerId: makeAnthropicWorkerId(token.id),
|
|
406
328
|
threadId: session.sessionId,
|
|
407
329
|
epoch: 0,
|
|
408
330
|
},
|
|
409
331
|
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
332
|
+
} finally {
|
|
333
|
+
this.inFlight--;
|
|
334
|
+
}
|
|
413
335
|
}
|
|
414
336
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const version = vi.hoisted(() => vi.fn());
|
|
4
|
+
vi.mock("node:child_process", () => ({ execFileSync: version }));
|
|
5
|
+
|
|
6
|
+
describe("Anthropic native extra-body thinking modes", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.resetModules();
|
|
9
|
+
version.mockReset();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
["claude-haiku-5-5", "disabled"],
|
|
14
|
+
["claude-sonnet-5-5", "between_tools"],
|
|
15
|
+
])("%s only overrides thinking and ignores caller effort", async (model, type) => {
|
|
16
|
+
const { resolveAnthropicThinking, assertAnthropicThinkingRuntime } = await import("./anthropic-thinking");
|
|
17
|
+
version.mockReturnValue("2.1.295 (Claude Code)");
|
|
18
|
+
const policy = resolveAnthropicThinking(model, false, "max");
|
|
19
|
+
expect(policy.mode).toBe("disabled");
|
|
20
|
+
expect(policy.effort).toBe("low");
|
|
21
|
+
expect(JSON.parse(policy.extraBody!)).toEqual({thinking: {type}});
|
|
22
|
+
assertAnthropicThinkingRuntime(policy.extraBody);
|
|
23
|
+
assertAnthropicThinkingRuntime(policy.extraBody);
|
|
24
|
+
expect(version).toHaveBeenCalledTimes(1);
|
|
25
|
+
expect(resolveAnthropicThinking(model).extraBody).toBeUndefined();
|
|
26
|
+
expect(resolveAnthropicThinking(model, true).extraBody).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it.each(["2.1.294 (Claude Code)", "unrecognized"])("rejects unverified runtime %s without affecting other models", async (output) => {
|
|
30
|
+
const { assertAnthropicThinkingRuntime } = await import("./anthropic-thinking");
|
|
31
|
+
version.mockReturnValue(output);
|
|
32
|
+
expect(() => assertAnthropicThinkingRuntime(undefined)).not.toThrow();
|
|
33
|
+
expect(version).not.toHaveBeenCalled();
|
|
34
|
+
expect(() => assertAnthropicThinkingRuntime('{"thinking":{"type":"disabled"}}')).toThrow("2.1.295");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { resolveAnthropicEffort } from "../common/effort";
|
|
4
|
+
import { ThinkingValidationError } from "../common/thinking";
|
|
5
|
+
import {
|
|
6
|
+
ANTHROPIC_DEFAULT_EFFORT,
|
|
7
|
+
canonicalAnthropicModel,
|
|
8
|
+
usesAdaptiveThinking,
|
|
9
|
+
} from "./anthropic-constants";
|
|
10
|
+
|
|
11
|
+
const THINKING_OFF_MODELS = new Set([
|
|
12
|
+
"claude-haiku-4-5",
|
|
13
|
+
"claude-sonnet-4-5",
|
|
14
|
+
"claude-sonnet-4-6",
|
|
15
|
+
"claude-sonnet-5",
|
|
16
|
+
"claude-opus-4-5",
|
|
17
|
+
"claude-opus-4-6",
|
|
18
|
+
"claude-opus-4-7",
|
|
19
|
+
"claude-opus-4-8",
|
|
20
|
+
"claude-opus-5",
|
|
21
|
+
"claude-haiku-5-5",
|
|
22
|
+
"claude-sonnet-5-5",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
let extraBodyRuntimeSupported: boolean | undefined;
|
|
26
|
+
|
|
27
|
+
/** These modes were verified through native extra-body delivery on Claude Code 2.1.295. */
|
|
28
|
+
export function assertAnthropicThinkingRuntime(extraBody: string | undefined): void {
|
|
29
|
+
if (!extraBody) return;
|
|
30
|
+
if (extraBodyRuntimeSupported === undefined) {
|
|
31
|
+
try {
|
|
32
|
+
const version = execFileSync("claude", ["--version"], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
timeout: 5000,
|
|
35
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
36
|
+
env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR },
|
|
37
|
+
}).match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
38
|
+
const [major, minor, patch] = version?.slice(1).map(Number) ?? [];
|
|
39
|
+
extraBodyRuntimeSupported =
|
|
40
|
+
major !== undefined &&
|
|
41
|
+
minor !== undefined &&
|
|
42
|
+
patch !== undefined &&
|
|
43
|
+
(major > 2 || (major === 2 && (minor > 1 || (minor === 1 && patch >= 295))));
|
|
44
|
+
} catch {
|
|
45
|
+
extraBodyRuntimeSupported = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!extraBodyRuntimeSupported) {
|
|
49
|
+
throw new ThinkingValidationError(
|
|
50
|
+
"thinking:false for Haiku 5.5 and Sonnet 5.5 requires Claude Code 2.1.295 or later; runtime version could not be verified",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveAnthropicThinking(model: string, thinking?: boolean, effort?: string) {
|
|
56
|
+
const canonical = canonicalAnthropicModel(model);
|
|
57
|
+
if (thinking === false && !THINKING_OFF_MODELS.has(canonical)) {
|
|
58
|
+
const reason = usesAdaptiveThinking(canonical)
|
|
59
|
+
? "this model requires thinking"
|
|
60
|
+
: "thinking-off support has not been verified for this model";
|
|
61
|
+
throw new ThinkingValidationError(
|
|
62
|
+
`thinking:false is not supported for anthropic/${canonical}: ${reason}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
// Never accept caller-provided extra body data or inherit it from the host environment.
|
|
67
|
+
extraBody:
|
|
68
|
+
thinking === false && (canonical === "claude-haiku-5-5" || canonical === "claude-sonnet-5-5")
|
|
69
|
+
? JSON.stringify({
|
|
70
|
+
thinking: { type: canonical === "claude-sonnet-5-5" ? "between_tools" : "disabled" },
|
|
71
|
+
})
|
|
72
|
+
: undefined,
|
|
73
|
+
mode:
|
|
74
|
+
thinking === true
|
|
75
|
+
? ("adaptive" as const)
|
|
76
|
+
: thinking === false || !usesAdaptiveThinking(canonical)
|
|
77
|
+
? ("disabled" as const)
|
|
78
|
+
: undefined,
|
|
79
|
+
effort:
|
|
80
|
+
thinking === false
|
|
81
|
+
? ANTHROPIC_DEFAULT_EFFORT
|
|
82
|
+
: (resolveAnthropicEffort(effort) ?? ANTHROPIC_DEFAULT_EFFORT),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
1
3
|
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { PassThrough } from "node:stream";
|
|
2
5
|
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
7
|
|
|
5
8
|
import { ANTHROPIC_CONFIG_DIR_BASE, anthropicConfigDir } from "./anthropic-constants";
|
|
6
9
|
import {
|
|
@@ -13,12 +16,17 @@ import {
|
|
|
13
16
|
runClaudeSession,
|
|
14
17
|
shouldPinStructuredRetries,
|
|
15
18
|
structuredOutputRetriesEnv,
|
|
16
|
-
thinkingEnv,
|
|
17
19
|
SYSTEM_PROMPT_ARGV_MAX_BYTES,
|
|
18
20
|
withSessionLock,
|
|
19
21
|
} from "./claude-session";
|
|
20
22
|
import { buildStreamJsonInput } from "./stream-json-adapter";
|
|
21
23
|
|
|
24
|
+
vi.mock("node:child_process", async (importOriginal) => ({
|
|
25
|
+
...await importOriginal<typeof import("node:child_process")>(),
|
|
26
|
+
spawn: vi.fn(),
|
|
27
|
+
execFileSync: vi.fn(() => "2.1.295 (Claude Code)"),
|
|
28
|
+
}));
|
|
29
|
+
|
|
22
30
|
describe("makeAnthropicWorkerId (coord 매핑 P0-2)", () => {
|
|
23
31
|
it("tokenId 기반 안정 합성", () => {
|
|
24
32
|
expect(makeAnthropicWorkerId(7)).toBe(7);
|
|
@@ -74,6 +82,54 @@ describe("ensureConfigDir (R10 격리 seed self-healing)", () => {
|
|
|
74
82
|
});
|
|
75
83
|
});
|
|
76
84
|
|
|
85
|
+
describe("runClaudeSession thinking and environment boundary", () => {
|
|
86
|
+
it.each([
|
|
87
|
+
{model: "claude-opus-4-8", thinking: undefined},
|
|
88
|
+
{model: "claude-opus-4-8", thinking: true},
|
|
89
|
+
{model: "claude-opus-4-8", thinking: false},
|
|
90
|
+
{model: "claude-haiku-5-5", thinking: false},
|
|
91
|
+
{model: "claude-sonnet-5-5", thinking: false},
|
|
92
|
+
])("$model thinking=$thinking preserves isolation without competing env controls", async ({model, thinking}) => {
|
|
93
|
+
const tokenId = -990002;
|
|
94
|
+
const child = Object.assign(new EventEmitter(), {
|
|
95
|
+
stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), kill: vi.fn(),
|
|
96
|
+
});
|
|
97
|
+
const spawn = vi.mocked(childProcess.spawn).mockImplementation(() => {
|
|
98
|
+
queueMicrotask(() => child.stdout.write(`${JSON.stringify({
|
|
99
|
+
type: "result", subtype: "success", result: "OK", is_error: false,
|
|
100
|
+
usage: {input_tokens: 1, output_tokens: 1},
|
|
101
|
+
})}\n`));
|
|
102
|
+
return child as unknown as ReturnType<typeof childProcess.spawn>;
|
|
103
|
+
});
|
|
104
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "must-not-inherit");
|
|
105
|
+
vi.stubEnv("MAX_THINKING_TOKENS", "0");
|
|
106
|
+
vi.stubEnv("CLAUDE_CODE_EXTRA_BODY", '{"model":"must-not-inherit"}');
|
|
107
|
+
try {
|
|
108
|
+
const result = await runClaudeSession({
|
|
109
|
+
tokenId, token: "test-oauth", model, thinking, effort: "high", timeoutMs: 1000,
|
|
110
|
+
input: [{type: "text", text: "OK", text_elements: []}],
|
|
111
|
+
}, () => {});
|
|
112
|
+
expect(result.text).toBe("OK");
|
|
113
|
+
const [, args, options] = spawn.mock.calls[0]!;
|
|
114
|
+
expect(args).toContain("--thinking");
|
|
115
|
+
expect(args).toContain(thinking === true ? "adaptive" : "disabled");
|
|
116
|
+
expect(options?.env).toMatchObject({
|
|
117
|
+
CLAUDE_CODE_OAUTH_TOKEN: "test-oauth", CLAUDE_CONFIG_DIR: anthropicConfigDir(tokenId),
|
|
118
|
+
CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
|
|
119
|
+
CLAUDE_CODE_DISABLE_BUNDLED_SKILLS: "1", CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
|
|
120
|
+
});
|
|
121
|
+
for (const key of ["ANTHROPIC_API_KEY", "MAX_THINKING_TOKENS", "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"]) {
|
|
122
|
+
expect(options?.env).not.toHaveProperty(key);
|
|
123
|
+
}
|
|
124
|
+
const extraBody = options?.env?.CLAUDE_CODE_EXTRA_BODY;
|
|
125
|
+
if (model === "claude-opus-4-8") expect(extraBody).toBeUndefined();
|
|
126
|
+
else expect(JSON.parse(extraBody!)).toEqual({thinking: {type: model === "claude-sonnet-5-5" ? "between_tools" : "disabled"}});
|
|
127
|
+
} finally {
|
|
128
|
+
spawn.mockRestore(); vi.unstubAllEnvs(); rmSync(anthropicConfigDir(tokenId), {recursive: true, force: true});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
77
133
|
describe("withSessionLock (per-session 락 P0-3)", () => {
|
|
78
134
|
it("같은 session-id 의 동시 호출은 직렬화된다", async () => {
|
|
79
135
|
const order: Array<string> = [];
|
|
@@ -134,19 +190,24 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
|
134
190
|
expect(oneMillionEnv(false)).toEqual({ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" });
|
|
135
191
|
});
|
|
136
192
|
|
|
137
|
-
it("
|
|
138
|
-
|
|
139
|
-
expect(
|
|
140
|
-
expect(
|
|
141
|
-
expect(thinkingEnv("anthropic/claude-fable-5")).toEqual({});
|
|
142
|
-
expect(thinkingEnv("claude-opus-5")).toEqual({});
|
|
143
|
-
expect(thinkingEnv("anthropic/claude-opus-5")).toEqual({});
|
|
144
|
-
expect(thinkingEnv("claude-opus-4-8")).toEqual({
|
|
145
|
-
CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
|
|
146
|
-
MAX_THINKING_TOKENS: "0",
|
|
147
|
-
});
|
|
193
|
+
it("thinking true enables a legacy default-off model and preserves effort", () => {
|
|
194
|
+
const args = buildClaudeArgs({model: "claude-opus-4-8", sessionId: "u", thinking: true, effort: "high"});
|
|
195
|
+
expect(args[args.indexOf("--thinking") + 1]).toBe("adaptive");
|
|
196
|
+
expect(args[args.indexOf("--effort") + 1]).toBe("high");
|
|
148
197
|
});
|
|
149
198
|
|
|
199
|
+
it("thinking false disables a supported default-adaptive model and ignores effort", () => {
|
|
200
|
+
const args = buildClaudeArgs({model: "anthropic/claude-opus-5", sessionId: "u", thinking: false, effort: "max"});
|
|
201
|
+
expect(args[args.indexOf("--thinking") + 1]).toBe("disabled");
|
|
202
|
+
expect(args[args.indexOf("--effort") + 1]).toBe("low");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each(["claude-fable-5-1", "claude-fable-5", "claude-opus-5-5", "claude-opus-4-1", "unknown"])(
|
|
206
|
+
"thinking false rejects %s instead of silently changing its model or thinking mode", (model) => {
|
|
207
|
+
expect(() => buildClaudeArgs({model, sessionId: "u", thinking: false})).toThrow(`anthropic/${model}`);
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
150
211
|
// SON-495: structured output retry 를 1 로 고정한다.
|
|
151
212
|
it("structuredOutputRetriesEnv: 미설정/비정상 값은 1 로 기본 고정", () => {
|
|
152
213
|
expect(structuredOutputRetriesEnv(undefined)).toEqual({ MAX_STRUCTURED_OUTPUT_RETRIES: "1" });
|