@cartanova/qgrid-cli 2.3.2 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +91 -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 +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 +193 -6
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +140 -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 +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,23 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AnthropicDispatcher
|
|
2
|
+
* AnthropicDispatcher
|
|
3
3
|
*
|
|
4
|
-
* OpenAIDispatcher
|
|
5
|
-
*
|
|
4
|
+
* OpenAIDispatcher와 달리, worker pool을 두는 대신 요청별 fresh spawn
|
|
5
|
+
* 토큰은 인메모리(MAP)으로 관리하고 least-used, round-robin 으로 고른다
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - **compat 는 threadCoord 로 왕복하지 않는다.** sessionCompat 는 이 dispatcher 인스턴스 메모리에만
|
|
13
|
-
* 산다(TTL sweep + 토큰 제거 시 정리). threadCoord.systemHash 는 상위 issueConvContext 가 따로
|
|
14
|
-
* 채우지만 Anthropic resume eligibility 판정에는 쓰이지 않는다.
|
|
15
|
-
* - **락 소유권**: claude session-id 직렬화는 U4 `runClaudeSession` 이 단독 소유한다.
|
|
16
|
-
* dispatcher 는 resume eligibility / token ownership 만 판정하고 `withSessionLock` 으로 감싸지 않는다.
|
|
17
|
-
* 여기서 다시 감싸면 U4 내부 락과 같은 session-id 로 재진입해 deadlock 이 난다.
|
|
18
|
-
* cold 첫 호출 경쟁을 따로 막지 않는 근거는 run() 의 락 주석 참조(IF/owner scope 의 순차 호출 전제).
|
|
19
|
-
* - 발급한 session-id 와 호환키를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
|
|
20
|
-
* epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다.
|
|
7
|
+
* - 모든 요청은 fresh `--session-id` 로 실행
|
|
8
|
+
* - 멀티턴 문맥은 클라이언트가 매 호출 보내는 full history 를 평탄화해 전달한다.
|
|
9
|
+
* - 발급한 session-id 를 GenerateResult.threadCoord(threadId=session-id, workerId=tokenId,
|
|
10
|
+
* epoch=0)로 올려, 상위(issueConvContext)가 클라에 회송할 좌표를 만든다. 다음 turn 이 좌표를
|
|
11
|
+
* 회송해도 Anthropic 라우팅에는 쓰지 않는다.
|
|
21
12
|
*/
|
|
22
13
|
|
|
23
14
|
import { getLogger } from "@logtape/logtape";
|
|
@@ -31,12 +22,14 @@ import {
|
|
|
31
22
|
type GenerateStreamCallbacks,
|
|
32
23
|
type ProviderDispatcher,
|
|
33
24
|
} from "../common/provider-dispatcher";
|
|
34
|
-
import { canonicalAnthropicModel } from "./anthropic-constants";
|
|
35
|
-
import {
|
|
25
|
+
import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
|
|
26
|
+
import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
|
|
36
27
|
|
|
37
28
|
const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
|
|
38
29
|
|
|
39
|
-
|
|
30
|
+
// 1M context 정상 생성은 실측상 100s+까지 간다. 240s는 완충 장치일 뿐,
|
|
31
|
+
// 거부+거대 system 재생성 지연을 완전히 해결하는 값은 아니다.
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
40
33
|
// access token 만료 임박 임계 — 기존 standalone 경로(qgrid.dispatcher.ts)와 동일(60s).
|
|
41
34
|
const REFRESH_SAFETY_MS = 60_000;
|
|
42
35
|
|
|
@@ -46,56 +39,12 @@ interface PooledToken {
|
|
|
46
39
|
credentials: AnthropicCredentials;
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
// QgridFrame.refreshToken 은 TokenSubsetA(특히 provider)를 받아 TokenModel.save 에 넘긴다.
|
|
50
|
-
// anthropic 풀의 토큰은 항상 provider="anthropic" 이므로 refresh 시 이 값을 고정으로 채운다.
|
|
51
|
-
// (provider 누락 시 save 실패 → 만료된 access token 으로 진행하는 버그가 난다.)
|
|
52
|
-
const ANTHROPIC_PROVIDER = "anthropic" as const;
|
|
53
|
-
|
|
54
|
-
// session-id → compat 매핑 TTL. 서버 thread idle TTL(10분)과 정합 — 그보다 오래된 세션은
|
|
55
|
-
// 어차피 resume 불가하므로 폐기해도 안전(sessionCompat 무한 증가 방지).
|
|
56
|
-
const SESSION_COMPAT_TTL_MS = 10 * 60 * 1000;
|
|
57
|
-
|
|
58
|
-
interface CompatEntry {
|
|
59
|
-
compat: string;
|
|
60
|
-
tokenId: number;
|
|
61
|
-
lastUsedAt: number;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isRetryableResumeFailure(error: unknown): boolean {
|
|
65
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
66
|
-
const lower = message.toLowerCase();
|
|
67
|
-
if (
|
|
68
|
-
lower.includes("timeout") ||
|
|
69
|
-
lower.includes("aborted") ||
|
|
70
|
-
lower.includes("spawn error") ||
|
|
71
|
-
lower.includes("quota") ||
|
|
72
|
-
lower.includes("you've hit") ||
|
|
73
|
-
lower.includes("unauthorized") ||
|
|
74
|
-
lower.includes("authentication") ||
|
|
75
|
-
lower.includes("invalid oauth") ||
|
|
76
|
-
lower.includes("permission denied") ||
|
|
77
|
-
lower.includes("max_turns") ||
|
|
78
|
-
lower.includes("max-turns") ||
|
|
79
|
-
lower.includes("schema") ||
|
|
80
|
-
lower.includes("model_error") ||
|
|
81
|
-
lower.includes("model error")
|
|
82
|
-
) {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
return lower.includes("closed without result") || /\b(resume|transcript)\b/.test(lower);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
42
|
export class AnthropicDispatcher implements ProviderDispatcher {
|
|
89
|
-
// tokenId → 풀 항목. start()/token 이벤트로
|
|
43
|
+
// tokenId → 풀 항목. start()/token 이벤트로 채움
|
|
90
44
|
private tokenPool = new Map<number, PooledToken>();
|
|
91
|
-
// tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌
|
|
45
|
+
// tokenId 별 사용 카운터(least-used RR). name 이 아니라 id 기준 — 이름 충돌 안전
|
|
92
46
|
private requestCounts = new Map<number, number>();
|
|
93
47
|
private rrIndex = 0;
|
|
94
|
-
// claude session-id → 그 세션을 만든 요청의 호환키(+tokenId/lastUsedAt). resume eligibility 판정.
|
|
95
|
-
// TTL sweep + 토큰 제거 시 정리로 무한 증가 방지.
|
|
96
|
-
private sessionCompat = new Map<string, CompatEntry>();
|
|
97
|
-
|
|
98
|
-
// ── lifecycle ──────────────────────────────────────────────────
|
|
99
48
|
|
|
100
49
|
async start(): Promise<void> {
|
|
101
50
|
// OpenAIDispatcher.start() 와 동일하게 DB 에서 기존 anthropic 토큰을 self-bootstrap 한다.
|
|
@@ -109,13 +58,12 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
109
58
|
name: t.name,
|
|
110
59
|
credentials: t.credentials as AnthropicCredentials,
|
|
111
60
|
});
|
|
61
|
+
logger.info(`worker spawned: ${t.name}`);
|
|
112
62
|
}
|
|
113
|
-
logger.info(`anthropic dispatcher started (${this.tokenPool.size} tokens)`);
|
|
114
63
|
}
|
|
115
64
|
|
|
116
65
|
async stop(): Promise<void> {
|
|
117
66
|
this.tokenPool.clear();
|
|
118
|
-
this.sessionCompat.clear();
|
|
119
67
|
// lifecycle 정합: RR 카운터/인덱스도 비워 stop→start 재등록 시 이전 카운트가 안 남게 한다.
|
|
120
68
|
this.requestCounts.clear();
|
|
121
69
|
this.rrIndex = 0;
|
|
@@ -128,9 +76,9 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
128
76
|
|
|
129
77
|
// DB active anthropic 토큰 목록으로 풀을 재동기화한다(periodic reconcile/재연결용).
|
|
130
78
|
// LISTEN/NOTIFY 가 끊긴 동안 유실된 추가/삭제/비활성화를 DB 기준으로 다시 맞춘다.
|
|
131
|
-
// 이벤트 핸들러를 재사용해
|
|
132
|
-
// - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
|
|
133
|
-
// - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
|
|
79
|
+
// 이벤트 핸들러를 재사용해 풀 갱신이 일관되게 적용되도록 한다:
|
|
80
|
+
// - DB 에 없는데 풀에 있는 토큰 → onTokenRemoved
|
|
81
|
+
// - DB 에 있는 토큰 → 신규면 onTokenAdded, 기존이면 onTokenUpdated
|
|
134
82
|
replaceTokens(
|
|
135
83
|
rows: Array<{ id: number; name: string; credentials: AnthropicCredentials }>,
|
|
136
84
|
): void {
|
|
@@ -146,44 +94,25 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
146
94
|
}
|
|
147
95
|
}
|
|
148
96
|
|
|
149
|
-
//
|
|
150
|
-
|
|
97
|
+
// token events (token-subscriber 가 호출)
|
|
151
98
|
onTokenAdded(id: number, name: string, credentials: AnthropicCredentials): void {
|
|
152
99
|
this.tokenPool.set(id, { id, name, credentials });
|
|
153
100
|
}
|
|
101
|
+
|
|
154
102
|
onTokenUpdated(id: number, name: string, credentials: AnthropicCredentials): void {
|
|
155
|
-
// access/refresh token rotation(같은 계정)이면 in-place 갱신으로 세션을 유지한다.
|
|
156
|
-
// 하지만 accountUuid 가 바뀐 재로그인/계정 교체면, 같은 tokenId 아래 살아남은 sessionCompat 가
|
|
157
|
-
// 이전 계정으로 만든 session-id 를 새 계정 credentials 로 resume 시키는 격리 위반이 된다.
|
|
158
|
-
// OpenAI dispatcher 가 login identity 변경을 worker restart 로 구분하는 것과 동일한 처리:
|
|
159
|
-
// identity 가 바뀌면 그 tokenId 의 세션 compat 을 폐기해 이후 호출이 cold 로 떨어지게 한다.
|
|
160
|
-
const existing = this.tokenPool.get(id);
|
|
161
|
-
const identityChanged =
|
|
162
|
-
existing !== undefined && existing.credentials.accountUuid !== credentials.accountUuid;
|
|
163
|
-
if (identityChanged) this.clearSessionCompatFor(id);
|
|
164
103
|
this.tokenPool.set(id, { id, name, credentials });
|
|
165
104
|
}
|
|
105
|
+
|
|
166
106
|
onTokenRemoved(id: number): void {
|
|
167
107
|
this.tokenPool.delete(id);
|
|
168
108
|
this.requestCounts.delete(id);
|
|
169
|
-
// 이 토큰으로 만든 세션의 compat 엔트리도 정리.
|
|
170
|
-
this.clearSessionCompatFor(id);
|
|
171
109
|
}
|
|
172
110
|
|
|
173
|
-
// 특정 tokenId 로 만든 모든 sessionCompat 엔트리 폐기(토큰 제거 / identity 변경 시 공통).
|
|
174
|
-
private clearSessionCompatFor(tokenId: number): void {
|
|
175
|
-
for (const [sid, e] of this.sessionCompat) {
|
|
176
|
-
if (e.tokenId === tokenId) this.sessionCompat.delete(sid);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ── token selection (least-used RR, tokenId 기준) ────────────────
|
|
181
|
-
|
|
182
111
|
private countOf(id: number): number {
|
|
183
112
|
return this.requestCounts.get(id) ?? 0;
|
|
184
113
|
}
|
|
185
114
|
|
|
186
|
-
//
|
|
115
|
+
// 요청마다 least-used RR 로 토큰을 고른다(동점이면 rrIndex 로 회전).
|
|
187
116
|
private selectToken(): PooledToken | null {
|
|
188
117
|
const rows = [...this.tokenPool.values()];
|
|
189
118
|
if (rows.length === 0) return null;
|
|
@@ -194,37 +123,19 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
194
123
|
return this.charge(picked);
|
|
195
124
|
}
|
|
196
125
|
|
|
197
|
-
// resume 경로: 세션을 만든 토큰을 id 로 고정 선택한다. 풀에 없으면 null(→ cold fallback).
|
|
198
|
-
// resume 세션은 그 토큰의 CLAUDE_CONFIG_DIR 에 transcript 가 묶여 있어, 다른 토큰으로 resume 하면
|
|
199
|
-
// transcript 가 없어 깨진다. 그래서 RR 을 쓰지 않고 소유 토큰을 그대로 잡아야 한다.
|
|
200
|
-
private pickToken(id: number): PooledToken | null {
|
|
201
|
-
const token = this.tokenPool.get(id);
|
|
202
|
-
return token ? this.charge(token) : null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
126
|
// 선택된 토큰의 사용 카운트를 await 전에 선반영(동시 요청이 다른 토큰을 고르도록).
|
|
206
127
|
private charge(token: PooledToken): PooledToken {
|
|
207
128
|
this.requestCounts.set(token.id, this.countOf(token.id) + 1);
|
|
208
129
|
return token;
|
|
209
130
|
}
|
|
210
131
|
|
|
211
|
-
// 만료된 compat 엔트리 정리(lazy sweep — generate 진입 시 호출).
|
|
212
|
-
private sweepSessionCompat(): void {
|
|
213
|
-
const now = Date.now();
|
|
214
|
-
for (const [sid, e] of this.sessionCompat) {
|
|
215
|
-
if (now - e.lastUsedAt > SESSION_COMPAT_TTL_MS) this.sessionCompat.delete(sid);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ── generate ──────────────────────────────────────────────────
|
|
220
|
-
|
|
221
132
|
async generate(req: GenerateRequest): Promise<GenerateResult> {
|
|
222
133
|
return this.run(req, () => {});
|
|
223
134
|
}
|
|
224
135
|
|
|
225
136
|
async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
|
|
226
137
|
try {
|
|
227
|
-
const result = await this.run(req, cb.onDelta);
|
|
138
|
+
const result = await this.run(req, cb.onDelta, { includePartialMessages: true });
|
|
228
139
|
cb.onThreadId?.(result.threadCoord.threadId);
|
|
229
140
|
cb.onComplete(result);
|
|
230
141
|
} catch (e) {
|
|
@@ -233,47 +144,22 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
233
144
|
}
|
|
234
145
|
|
|
235
146
|
// generate/generateStream 공통 실행. onDelta 는 스트림이면 점진 방출, 비스트림이면 no-op.
|
|
236
|
-
private async run(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
147
|
+
private async run(
|
|
148
|
+
req: GenerateRequest,
|
|
149
|
+
onDelta: (t: string) => void,
|
|
150
|
+
opts?: { includePartialMessages?: boolean },
|
|
151
|
+
): Promise<GenerateResult> {
|
|
152
|
+
// model 정규화를 dispatcher 진입점에서 한 번 한다: result.model / runClaudeSession 전부
|
|
240
153
|
// canonical(prefix 없는 cost 키) 을 쓰게 통일. 미지정이면 ANTHROPIC_DEFAULT_MODEL — qgrid.dispatcher
|
|
241
154
|
// 의 "sonnet" 별칭 우회 차단. 정규화 규칙은 fallback 경로와 공유(canonicalAnthropicModel).
|
|
155
|
+
assertSupportedOneMillionSuffix(req.model);
|
|
242
156
|
const model = canonicalAnthropicModel(req.model);
|
|
243
157
|
const jsonSchema =
|
|
244
158
|
req.outputSchema !== undefined ? JSON.stringify(req.outputSchema) : undefined;
|
|
245
|
-
const compat = compatibilityKey({ system: req.systemPrompt, model, outputSchema: jsonSchema });
|
|
246
|
-
|
|
247
|
-
// ── resume eligibility + 토큰 선택 (resume 는 세션 소유 토큰으로만) ──
|
|
248
|
-
// resume 가 성립하려면 세 조건이 모두 참이어야 한다:
|
|
249
|
-
// (a) reuse.threadId 의 저장된 호환키 == 이번 요청 호환키 (모델/system/schema 변경 오염 방지)
|
|
250
|
-
// (b) 저장된 tokenId == reuse.workerId (coord 왕복 무결성) 이고
|
|
251
|
-
// (c) 그 토큰이 아직 풀에 살아있음.
|
|
252
|
-
// 하나라도 어긋나면 resume 불가 → cold 로 떨어지고 RR 로 새 토큰을 고른다.
|
|
253
|
-
// resume 일 때는 반드시 세션을 만든 그 토큰(stored.tokenId)을 고정 선택한다 — RR 로 다른 토큰을 잡으면
|
|
254
|
-
// 그 토큰의 CLAUDE_CONFIG_DIR 에 transcript 가 없어 resume 이 깨진다.
|
|
255
|
-
const candidate = req.reuse?.threadId;
|
|
256
|
-
const stored = candidate !== undefined ? this.sessionCompat.get(candidate) : undefined;
|
|
257
|
-
const ownerMatches = stored !== undefined && stored.tokenId === req.reuse?.workerId;
|
|
258
|
-
const resumeToken =
|
|
259
|
-
stored?.compat === compat && ownerMatches ? this.pickToken(stored.tokenId) : null;
|
|
260
159
|
|
|
261
|
-
const
|
|
262
|
-
const resumeSessionId = isResume ? candidate : undefined;
|
|
263
|
-
|
|
264
|
-
// resume 면 소유 토큰 고정, 아니면 cold RR 선택.
|
|
265
|
-
const token = resumeToken ?? this.selectToken();
|
|
160
|
+
const token = this.selectToken();
|
|
266
161
|
if (!token) throw new Error("No anthropic tokens available");
|
|
267
162
|
|
|
268
|
-
// 락 소유권:
|
|
269
|
-
// session-id 단위 직렬화 락은 **U4 runClaudeSession 이 단독 소유**한다. dispatcher 가 여기서
|
|
270
|
-
// withSessionLock(resumeSessionId) 으로 한 번 더 감싸면, exec() 안의 runClaudeSession 이 같은
|
|
271
|
-
// session-id 로 락에 재진입하는데 withSessionLock 은 reentrant 가 아니라 자기 자신을 기다리는
|
|
272
|
-
// deadlock 이 난다(첫 resume 호출이 영구 정지). U1/U5 dispatcher 는 token ownership / compat 만
|
|
273
|
-
// 판정하고, transcript 직렬화는 U4 락에 위임한다.
|
|
274
|
-
// cold 첫-호출 동시성을 dispatcher 가 따로 막지 않는 근거: sessionKey 는 서버로 오지 않아(클라
|
|
275
|
-
// store 관리, 좌표만 회송) cold 별 락의 입력 자체가 없고, 현재 IF/owner 는 LLM 호출을 전부 순차로
|
|
276
|
-
// 돌려 동시 첫 호출이 나지 않는다(2026-06-06 L21/L248). 병렬화/서버전달로 바뀌면 재검토.
|
|
277
163
|
const exec = async (): Promise<GenerateResult> => {
|
|
278
164
|
// 만료 임박 토큰 preemptive refresh(기존 standalone 경로와 동일). 실패해도 진행.
|
|
279
165
|
let accessToken = token.credentials.accessToken;
|
|
@@ -289,7 +175,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
289
175
|
// refreshToken(TokenSubsetA) 는 id/provider/credentials/name 만 사용하므로 그 4개만 넘긴다.
|
|
290
176
|
accessToken = await QgridFrame.refreshToken({
|
|
291
177
|
id: token.id,
|
|
292
|
-
provider:
|
|
178
|
+
provider: "anthropic",
|
|
293
179
|
name: token.name,
|
|
294
180
|
credentials: token.credentials,
|
|
295
181
|
} as Parameters<typeof QgridFrame.refreshToken>[0]);
|
|
@@ -298,35 +184,23 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
298
184
|
}
|
|
299
185
|
}
|
|
300
186
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
);
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
let session;
|
|
322
|
-
try {
|
|
323
|
-
session = await runSession(isResume);
|
|
324
|
-
} catch (e) {
|
|
325
|
-
if (!isResume || !isRetryableResumeFailure(e)) throw e;
|
|
326
|
-
if (candidate) this.sessionCompat.delete(candidate);
|
|
327
|
-
logger.warn(`resume failed for ${token.name}; retrying cold once: ${(e as Error).message}`);
|
|
328
|
-
session = await runSession(false);
|
|
329
|
-
}
|
|
187
|
+
logger.info(`→ ${token.name} (model: ${model})`);
|
|
188
|
+
const session = await runClaudeSession(
|
|
189
|
+
{
|
|
190
|
+
tokenId: token.id,
|
|
191
|
+
token: accessToken,
|
|
192
|
+
model,
|
|
193
|
+
system: req.systemPrompt,
|
|
194
|
+
jsonSchema,
|
|
195
|
+
effort: req.effort,
|
|
196
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
197
|
+
coldHistory: req.coldHistory,
|
|
198
|
+
input: req.coldInput,
|
|
199
|
+
abortSignal: req.abortSignal,
|
|
200
|
+
includePartialMessages: opts?.includePartialMessages,
|
|
201
|
+
},
|
|
202
|
+
onDelta,
|
|
203
|
+
);
|
|
330
204
|
|
|
331
205
|
if (session.quotaExhausted) throw new Error(`quota exhausted (${token.name})`);
|
|
332
206
|
if (session.isError) {
|
|
@@ -335,15 +209,6 @@ export class AnthropicDispatcher implements ProviderDispatcher {
|
|
|
335
209
|
throw new Error(`claude error (${token.name}): ${detail}`);
|
|
336
210
|
}
|
|
337
211
|
|
|
338
|
-
// 발급한 session-id 의 호환키 저장(다음 resume 판정용). compat 는 여기 dispatcher-local 에만
|
|
339
|
-
// 보존한다 — threadCoord.systemHash 로 왕복하지 않는다. 상위 issueConvContext 가 systemHash 를
|
|
340
|
-
// 따로 채우지만 Anthropic resume eligibility 는 이 sessionCompat 가 단독 판정.
|
|
341
|
-
this.sessionCompat.set(session.sessionId, {
|
|
342
|
-
compat,
|
|
343
|
-
tokenId: token.id,
|
|
344
|
-
lastUsedAt: Date.now(),
|
|
345
|
-
});
|
|
346
|
-
|
|
347
212
|
return {
|
|
348
213
|
text: session.text,
|
|
349
214
|
tokenName: token.name,
|
|
@@ -4,62 +4,17 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
|
|
5
5
|
import { ANTHROPIC_CONFIG_DIR_BASE, anthropicConfigDir } from "./anthropic-constants";
|
|
6
6
|
import {
|
|
7
|
+
applyOneMillionSuffix,
|
|
7
8
|
buildClaudeArgs,
|
|
8
|
-
compatibilityKey,
|
|
9
9
|
decorateAndSerialize,
|
|
10
10
|
ensureConfigDir,
|
|
11
11
|
makeAnthropicWorkerId,
|
|
12
|
+
oneMillionEnv,
|
|
13
|
+
runClaudeSession,
|
|
12
14
|
withSessionLock,
|
|
13
15
|
} from "./claude-session";
|
|
14
16
|
import { buildStreamJsonInput } from "./stream-json-adapter";
|
|
15
17
|
|
|
16
|
-
describe("compatibilityKey (P1-5)", () => {
|
|
17
|
-
it("같은 system+model+schema → 같은 키", () => {
|
|
18
|
-
const a = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
|
|
19
|
-
const b = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
|
|
20
|
-
expect(a).toBe(b);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("model 다르면 다른 키 (sessionKey 재사용 오염 방지)", () => {
|
|
24
|
-
const a = compatibilityKey({ system: "s", model: "claude-sonnet-4-6" });
|
|
25
|
-
const b = compatibilityKey({ system: "s", model: "claude-opus-4-8" });
|
|
26
|
-
expect(a).not.toBe(b);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("text 모드 vs structured 모드 다른 키", () => {
|
|
30
|
-
const text = compatibilityKey({ system: "s", model: "m" });
|
|
31
|
-
const json = compatibilityKey({ system: "s", model: "m", outputSchema: '{"type":"object"}' });
|
|
32
|
-
expect(text).not.toBe(json);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("다른 schema 내용 → 다른 키 (codex P1-2: boolean 으로는 못 잡던 오염)", () => {
|
|
36
|
-
const schemaA = compatibilityKey({
|
|
37
|
-
system: "s",
|
|
38
|
-
model: "m",
|
|
39
|
-
outputSchema: '{"type":"object","properties":{"a":{"type":"string"}}}',
|
|
40
|
-
});
|
|
41
|
-
const schemaB = compatibilityKey({
|
|
42
|
-
system: "s",
|
|
43
|
-
model: "m",
|
|
44
|
-
outputSchema: '{"type":"object","properties":{"b":{"type":"integer"}}}',
|
|
45
|
-
});
|
|
46
|
-
expect(schemaA).not.toBe(schemaB);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("같은 schema 내용 → 같은 키", () => {
|
|
50
|
-
const s = '{"type":"object"}';
|
|
51
|
-
expect(compatibilityKey({ system: "s", model: "m", outputSchema: s })).toBe(
|
|
52
|
-
compatibilityKey({ system: "s", model: "m", outputSchema: s }),
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("system 다르면 다른 키", () => {
|
|
57
|
-
const a = compatibilityKey({ system: "a", model: "m" });
|
|
58
|
-
const b = compatibilityKey({ system: "b", model: "m" });
|
|
59
|
-
expect(a).not.toBe(b);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
18
|
describe("makeAnthropicWorkerId (coord 매핑 P0-2)", () => {
|
|
64
19
|
it("tokenId 기반 안정 합성", () => {
|
|
65
20
|
expect(makeAnthropicWorkerId(7)).toBe(7);
|
|
@@ -164,11 +119,24 @@ describe("withSessionLock (per-session 락 P0-3)", () => {
|
|
|
164
119
|
});
|
|
165
120
|
|
|
166
121
|
describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
167
|
-
it("
|
|
168
|
-
|
|
122
|
+
it("applyOneMillionSuffix: 필요한 모델에만 [1m] suffix 를 단일 부착", () => {
|
|
123
|
+
expect(applyOneMillionSuffix("claude-sonnet-4-6", true)).toBe("claude-sonnet-4-6[1m]");
|
|
124
|
+
expect(applyOneMillionSuffix("claude-sonnet-4-6[1m]", true)).toBe("claude-sonnet-4-6[1m]");
|
|
125
|
+
expect(applyOneMillionSuffix("claude-opus-4-8", false)).toBe("claude-opus-4-8");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("oneMillionEnv: 지원 모델은 DISABLE_1M 을 제거하고 미지원은 유지", () => {
|
|
129
|
+
expect(oneMillionEnv(true)).toEqual({});
|
|
130
|
+
expect(oneMillionEnv(false)).toEqual({ CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("cold-only: 항상 --session-id 를 사용하고 continuation flag 는 쓰지 않는다", () => {
|
|
134
|
+
const args = buildClaudeArgs({ model: "m", sessionId: "uuid-1" });
|
|
135
|
+
const continuationFlag = "--resume";
|
|
169
136
|
expect(args).toContain("--session-id");
|
|
170
137
|
expect(args).toContain("uuid-1");
|
|
171
|
-
expect(args).not.toContain(
|
|
138
|
+
expect(args).not.toContain(continuationFlag);
|
|
139
|
+
expect(args).not.toContain("--include-partial-messages");
|
|
172
140
|
// 멀티턴 필수: --no-session-persistence 없어야 함
|
|
173
141
|
expect(args).not.toContain("--no-session-persistence");
|
|
174
142
|
// 입력 포맷
|
|
@@ -176,35 +144,79 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
|
|
|
176
144
|
expect(args[args.indexOf("--input-format") + 1]).toBe("stream-json");
|
|
177
145
|
});
|
|
178
146
|
|
|
179
|
-
it("
|
|
180
|
-
const args = buildClaudeArgs({
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
147
|
+
it("stream 호출: partial message delta 를 포함한다", () => {
|
|
148
|
+
const args = buildClaudeArgs({
|
|
149
|
+
model: "m",
|
|
150
|
+
sessionId: "uuid-1",
|
|
151
|
+
includePartialMessages: true,
|
|
152
|
+
});
|
|
153
|
+
expect(args).toContain("--include-partial-messages");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("runClaudeSession: 이미 abort 된 signal 은 spawn 전에 실패한다", async () => {
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
controller.abort();
|
|
159
|
+
|
|
160
|
+
await expect(
|
|
161
|
+
runClaudeSession(
|
|
162
|
+
{
|
|
163
|
+
tokenId: 999,
|
|
164
|
+
token: "sk-ant-oat01-test",
|
|
165
|
+
model: "haiku",
|
|
166
|
+
timeoutMs: 1_000,
|
|
167
|
+
input: [{ type: "text", text: "hi", text_elements: [] }],
|
|
168
|
+
abortSignal: controller.signal,
|
|
169
|
+
},
|
|
170
|
+
() => {},
|
|
171
|
+
),
|
|
172
|
+
).rejects.toThrow("aborted");
|
|
184
173
|
});
|
|
185
174
|
|
|
186
175
|
it("structured(jsonSchema 있음): --allowed-tools StructuredOutput + --json-schema (P2-7)", () => {
|
|
187
176
|
const args = buildClaudeArgs({
|
|
188
177
|
model: "m",
|
|
189
178
|
sessionId: "u",
|
|
190
|
-
isResume: false,
|
|
191
179
|
jsonSchema: '{"type":"object"}',
|
|
192
180
|
});
|
|
193
181
|
expect(args).toContain("--allowed-tools");
|
|
194
182
|
expect(args).toContain("StructuredOutput");
|
|
195
183
|
expect(args).toContain("--json-schema");
|
|
184
|
+
expect(args).not.toContain("--max-turns");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("1M suffix 는 CLI --model 인자에만 반영된다", () => {
|
|
188
|
+
const args = buildClaudeArgs({
|
|
189
|
+
model: "claude-sonnet-4-6",
|
|
190
|
+
sessionId: "u",
|
|
191
|
+
needsOneMillionSuffix: true,
|
|
192
|
+
});
|
|
193
|
+
expect(args[args.indexOf("--model") + 1]).toBe("claude-sonnet-4-6[1m]");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("1M 기본 지원 모델은 CLI --model 에 suffix 를 붙이지 않는다", () => {
|
|
197
|
+
const args = buildClaudeArgs({
|
|
198
|
+
model: "claude-opus-4-8",
|
|
199
|
+
sessionId: "u",
|
|
200
|
+
needsOneMillionSuffix: false,
|
|
201
|
+
});
|
|
202
|
+
expect(args[args.indexOf("--model") + 1]).toBe("claude-opus-4-8");
|
|
196
203
|
});
|
|
197
204
|
|
|
198
205
|
it("non-structured(jsonSchema 없음): --allowed-tools 미부여 (P2-7)", () => {
|
|
199
|
-
const args = buildClaudeArgs({ model: "m", sessionId: "u"
|
|
206
|
+
const args = buildClaudeArgs({ model: "m", sessionId: "u" });
|
|
200
207
|
expect(args).not.toContain("--allowed-tools");
|
|
201
208
|
expect(args).not.toContain("--json-schema");
|
|
209
|
+
expect(args).not.toContain("--max-turns");
|
|
202
210
|
// 단 --tools "" 로 전체 차단은 유지
|
|
203
211
|
expect(args).toContain("--tools");
|
|
204
212
|
});
|
|
205
213
|
|
|
206
214
|
it("inline [System] 금지: --system-prompt 정식 채널 사용 (R7)", () => {
|
|
207
|
-
const args = buildClaudeArgs({
|
|
215
|
+
const args = buildClaudeArgs({
|
|
216
|
+
model: "m",
|
|
217
|
+
system: "you are X",
|
|
218
|
+
sessionId: "u",
|
|
219
|
+
});
|
|
208
220
|
expect(args).toContain("--system-prompt");
|
|
209
221
|
expect(args[args.indexOf("--system-prompt") + 1]).toBe("you are X");
|
|
210
222
|
});
|
|
@@ -214,7 +226,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
|
|
|
214
226
|
it("각 라인에 session_id/uuid/parent_tool_use_id:null 부착 + message 보존", () => {
|
|
215
227
|
const lines = buildStreamJsonInput({
|
|
216
228
|
input: [{ type: "text", text: "hi", text_elements: [] }],
|
|
217
|
-
isResume: false,
|
|
218
229
|
});
|
|
219
230
|
const out = decorateAndSerialize(lines, "sess-xyz");
|
|
220
231
|
const parsed = out
|
|
@@ -238,7 +249,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
|
|
|
238
249
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "prev" }] },
|
|
239
250
|
],
|
|
240
251
|
input: [{ type: "text", text: "now", text_elements: [] }],
|
|
241
|
-
isResume: false,
|
|
242
252
|
});
|
|
243
253
|
const out = decorateAndSerialize(lines, "s");
|
|
244
254
|
expect(out.endsWith("\n")).toBe(true);
|
|
@@ -252,7 +262,6 @@ describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
|
|
|
252
262
|
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "a" }] },
|
|
253
263
|
],
|
|
254
264
|
input: [{ type: "text", text: "b", text_elements: [] }],
|
|
255
|
-
isResume: false,
|
|
256
265
|
});
|
|
257
266
|
const parsed = decorateAndSerialize(lines, "s")
|
|
258
267
|
.trim()
|