@cartanova/qgrid-cli 2.2.1 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +2 -6
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +74 -43
  3. package/bundle/dist/application/qgrid/token-subscriber.js +14 -1
  4. package/bundle/dist/application/qgrid/tool-emulation.js +25 -20
  5. package/bundle/dist/application/request-log/request-log.model.js +54 -8
  6. package/bundle/dist/sonamu.config.js +11 -2
  7. package/bundle/dist/testing/global.js +5 -2
  8. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +30 -0
  9. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +215 -0
  10. package/bundle/dist/utils/providers/anthropic/claude-session.js +220 -0
  11. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +177 -0
  12. package/bundle/dist/utils/providers/common/model-cost.js +35 -18
  13. package/bundle/dist/utils/providers/openai/openai-refresh.js +21 -9
  14. package/bundle/src/application/qgrid/conv-routing.test.ts +85 -0
  15. package/bundle/src/application/qgrid/conv-routing.ts +3 -2
  16. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +110 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +112 -64
  18. package/bundle/src/application/qgrid/token-subscriber.ts +23 -1
  19. package/bundle/src/application/qgrid/tool-emulation.test.ts +46 -0
  20. package/bundle/src/application/qgrid/tool-emulation.ts +15 -3
  21. package/bundle/src/application/request-log/request-log.model.ts +98 -13
  22. package/bundle/src/sonamu.config.ts +13 -2
  23. package/bundle/src/testing/global.ts +16 -2
  24. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +38 -0
  25. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +564 -0
  26. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +365 -0
  27. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +263 -0
  28. package/bundle/src/utils/providers/anthropic/claude-session.ts +327 -0
  29. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +373 -0
  30. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +344 -0
  31. package/bundle/src/utils/providers/common/model-cost.test.ts +39 -0
  32. package/bundle/src/utils/providers/common/model-cost.ts +107 -19
  33. package/bundle/src/utils/providers/common/provider-dispatcher.ts +13 -2
  34. package/bundle/src/utils/providers/openai/openai-refresh.ts +34 -9
  35. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { QgridFrame } from "./application/qgrid/qgrid.frame";
12
12
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
13
13
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
14
14
  import { TokenModel } from "./application/token/token.model";
15
+ import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
15
16
  import { OpenAIDispatcher } from "./utils/providers/openai/openai-dispatcher";
16
17
 
17
18
  dotenv.config({ path: path.join(import.meta.dirname, "../.env") });
@@ -202,8 +203,15 @@ export default defineConfig({
202
203
  log.warn(`openai dispatcher failed: ${(e as Error).message}`);
203
204
  }
204
205
 
205
- const allTokens = [...QgridDispatcher.tokens.values()];
206
- const anthropicCount = allTokens.filter((t) => t.provider === "anthropic").length;
206
+ try {
207
+ const anthropicDispatcher = new AnthropicDispatcher();
208
+ await anthropicDispatcher.start();
209
+ QgridDispatcher.anthropicDispatcher = anthropicDispatcher;
210
+ } catch (e) {
211
+ log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
212
+ }
213
+
214
+ const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
207
215
  const openaiReady = QgridDispatcher.openaiDispatcher?.readyWorkerCount ?? 0;
208
216
  const openaiTotal = QgridDispatcher.openaiDispatcher?.workerCount ?? 0;
209
217
 
@@ -219,6 +227,9 @@ export default defineConfig({
219
227
  if (QgridDispatcher.openaiDispatcher) {
220
228
  await QgridDispatcher.openaiDispatcher.stop();
221
229
  }
230
+ if (QgridDispatcher.anthropicDispatcher) {
231
+ await QgridDispatcher.anthropicDispatcher.stop();
232
+ }
222
233
  if (QgridDispatcher.subscriber) {
223
234
  await QgridDispatcher.subscriber.stop();
224
235
  }
@@ -2,5 +2,19 @@ import dotenv from "dotenv";
2
2
 
3
3
  dotenv.config();
4
4
 
5
- // sonamu.config.ts의 test 설정을 기반으로 병렬 테스트 환경을 구성합니다.
6
- export { setup } from "sonamu/test";
5
+ type SonamuGlobalSetupModule = {
6
+ setup: () => Promise<(() => Promise<void>) | void>;
7
+ };
8
+
9
+ // sonamu/test 의 barrel export 는 bootstrap.js 를 함께 로드하고, bootstrap.js 는 top-level 에서
10
+ // vitest 를 import 한다. Vitest 4 globalSetup 컨텍스트에서는 이 import 가 internal state 에러를
11
+ // 만들기 때문에 global-setup.js 만 직접 로드한다.
12
+ export async function setup(): Promise<(() => Promise<void>) | void> {
13
+ const globalSetupUrl = new URL(
14
+ "../../node_modules/sonamu/dist/testing/global-setup.js",
15
+ import.meta.url,
16
+ );
17
+ const { setup: sonamuSetup } = (await import(globalSetupUrl.href)) as SonamuGlobalSetupModule;
18
+
19
+ return sonamuSetup();
20
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Anthropic(Claude) provider 상수.
3
+ *
4
+ * 기존 qgrid.dispatcher.ts 의 executeClaude 플래그/env 와 정합을 맞추되, 멀티턴용으로 조정한다:
5
+ * - `--no-session-persistence` 제거 (멀티턴 필수).
6
+ * - `--input-format stream-json` 추가 (stdin 으로 입력 어댑터 JSONL 흘림).
7
+ * - `--session-id` / `--resume` 로 세션 소유.
8
+ */
9
+
10
+ // CC --model 플래그가 받는 official model id (alias "sonnet" 가 아니라 정확한 식별자로 고정).
11
+ // claude-api 스킬 확인: Sonnet 4.6 official id = "claude-sonnet-4-6" (날짜 suffix 없음).
12
+ // model-cost 테이블에도 "claude-sonnet-4-6" 키 존재. (owner 지시)
13
+ export const ANTHROPIC_DEFAULT_MODEL = "claude-sonnet-4-6";
14
+ export const ANTHROPIC_DEFAULT_EFFORT = "high";
15
+
16
+ // model id 를 canonical(provider prefix 없는 CLI/cost 키) 로 정규화한다.
17
+ // ai-sdk 는 'anthropic/claude-*' 형태로 보내는데, prefix 가 compatibilityKey/calculateCostUsd 에
18
+ // 새면 호환키가 갈리거나 cost 가 default 단가로 오계산된다. 미지정이면 default.
19
+ export function canonicalAnthropicModel(model?: string): string {
20
+ const raw = model || ANTHROPIC_DEFAULT_MODEL;
21
+ return raw.includes("/") ? raw.split("/").pop()! : raw;
22
+ }
23
+
24
+ // project scope cwd (settings.json 격리). 토큰별 격리는 CLAUDE_CONFIG_DIR 로 한다.
25
+ export const ANTHROPIC_CLAUDE_CWD = "/tmp/qgrid-anthropic";
26
+
27
+ // per-token CLAUDE_CONFIG_DIR 의 베이스. 실제 경로는 `${BASE}/${tokenId}` (R10 격리).
28
+ export const ANTHROPIC_CONFIG_DIR_BASE = "/tmp/qgrid-anthropic-config";
29
+
30
+ // 토큰별 CLAUDE_CONFIG_DIR 경로 규칙(R10 격리 계약). tokenId 별로 분리되어, 같은 claude session-id
31
+ // 라도 다른 토큰이면 다른 config dir → transcript 가 섞이지 않는다. 부수효과 없는 순수 함수라
32
+ // 격리 계약을 단위 테스트로 고정할 수 있다(U6 R10 구조 검증). 실제 dir 생성은 ensureConfigDir 가 한다.
33
+ export function anthropicConfigDir(tokenId: number): string {
34
+ return `${ANTHROPIC_CONFIG_DIR_BASE}/${tokenId}`;
35
+ }
36
+
37
+ // CC 가 자동 활성화하는 deferred 도구 — 토큰 최적화 위해 차단(기존 executeClaude 와 동일).
38
+ export const ANTHROPIC_DISALLOWED_TOOLS = ["Monitor", "PushNotification", "RemoteTrigger"] as const;
@@ -0,0 +1,564 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { type AnthropicCredentials } from "../../../application/token/token.types";
4
+ import { type GenerateRequest } from "../common/provider-dispatcher";
5
+ import { ANTHROPIC_DEFAULT_MODEL } from "./anthropic-constants";
6
+ import { AnthropicDispatcher } from "./anthropic-dispatcher";
7
+ import { withSessionLock } from "./claude-session";
8
+
9
+ // runClaudeSession 은 실제 claude 프로세스를 spawn 하므로 모킹.
10
+ // compatibilityKey / makeAnthropicWorkerId / withSessionLock 은 순수 함수라 실제 구현 사용.
11
+ // vi.hoisted 로 mock fn 을 호이스트해 vi.mock 팩토리에서 참조한다.
12
+ const { runClaudeSessionMock, refreshTokenMock } = vi.hoisted(() => ({
13
+ runClaudeSessionMock: vi.fn(),
14
+ refreshTokenMock: vi.fn(),
15
+ }));
16
+ vi.mock("./claude-session", async (importActual) => {
17
+ const actual = await importActual<typeof import("./claude-session")>();
18
+ return { ...actual, runClaudeSession: runClaudeSessionMock };
19
+ });
20
+ // run() 이 동적 import 하는 QgridFrame.refreshToken 을 모킹(만료 임박 토큰 preemptive refresh 경로).
21
+ vi.mock("../../../application/qgrid/qgrid.frame", () => ({
22
+ QgridFrame: { refreshToken: refreshTokenMock },
23
+ }));
24
+
25
+ function creds(expiresInMs = 3_600_000, accountUuid = "acc-1"): AnthropicCredentials {
26
+ return {
27
+ accessToken: "sk-ant-oat01-test",
28
+ refreshToken: "sk-ant-ort01-test",
29
+ expiresAt: Date.now() + expiresInMs,
30
+ accountUuid,
31
+ };
32
+ }
33
+
34
+ function sessionResult(overrides: Record<string, unknown> = {}) {
35
+ return {
36
+ text: "hello",
37
+ usage: {
38
+ totalTokens: 10,
39
+ inputTokens: 5,
40
+ cachedInputTokens: 0,
41
+ outputTokens: 5,
42
+ reasoningOutputTokens: 0,
43
+ },
44
+ durationMs: 100,
45
+ quotaExhausted: false,
46
+ isError: false,
47
+ sessionId: "sess-generated",
48
+ workerId: 1,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ function baseReq(overrides: Partial<GenerateRequest> = {}): GenerateRequest {
54
+ return {
55
+ model: "claude-sonnet-4-6",
56
+ systemPrompt: "you are helpful",
57
+ coldInput: [{ type: "text", text: "hi", text_elements: [] }],
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ describe("AnthropicDispatcher", () => {
63
+ beforeEach(() => {
64
+ runClaudeSessionMock.mockReset();
65
+ runClaudeSessionMock.mockResolvedValue(sessionResult());
66
+ refreshTokenMock.mockReset();
67
+ refreshTokenMock.mockResolvedValue("sk-ant-oat01-refreshed");
68
+ });
69
+
70
+ it("토큰 없으면 에러", async () => {
71
+ const d = new AnthropicDispatcher();
72
+ await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
73
+ });
74
+
75
+ it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {
76
+ const d = new AnthropicDispatcher();
77
+ d.onTokenAdded(1, "tok-A", creds());
78
+ const result = await d.generate(baseReq());
79
+ expect(result.text).toBe("hello");
80
+ expect(result.tokenName).toBe("tok-A");
81
+ expect(result.model).toBe("claude-sonnet-4-6");
82
+ // threadCoord: threadId=session-id, epoch=0, workerId=tokenId
83
+ expect(result.threadCoord).toEqual({ workerId: 1, threadId: "sess-generated", epoch: 0 });
84
+ });
85
+
86
+ it("cold 호출: resumeSessionId 없음, coldInput/coldHistory 전달", async () => {
87
+ const d = new AnthropicDispatcher();
88
+ d.onTokenAdded(1, "tok-A", creds());
89
+ await d.generate(
90
+ baseReq({ coldHistory: [{ type: "message", role: "assistant", content: [] }] }),
91
+ );
92
+ const call = runClaudeSessionMock.mock.calls[0]![0];
93
+ expect(call.resumeSessionId).toBeUndefined();
94
+ expect(call.coldHistory).toBeDefined();
95
+ expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
96
+ });
97
+
98
+ it("resume eligible: 같은 호환키의 reuse.threadId → resume (reuseInput, history 미전달)", async () => {
99
+ const d = new AnthropicDispatcher();
100
+ d.onTokenAdded(1, "tok-A", creds());
101
+ // 1st 호출: session "S1" 발급 + 호환키 저장
102
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
103
+ const first = await d.generate(baseReq());
104
+ expect(first.threadCoord.threadId).toBe("S1");
105
+
106
+ // 2nd 호출: 같은 system/model 로 reuse.threadId=S1 → eligible
107
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
108
+ await d.generate(
109
+ baseReq({
110
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
111
+ reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
112
+ }),
113
+ );
114
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
115
+ expect(call2.resumeSessionId).toBe("S1");
116
+ expect(call2.coldHistory).toBeUndefined(); // resume 이면 history 미전달
117
+ expect(call2.input).toEqual([{ type: "text", text: "delta", text_elements: [] }]);
118
+ });
119
+
120
+ it("resume 토큰 소유권 고정: 다른 토큰이 idle 이어도 세션을 만든 토큰으로 resume (codex P0)", async () => {
121
+ const d = new AnthropicDispatcher();
122
+ d.onTokenAdded(1, "tok-A", creds());
123
+ d.onTokenAdded(2, "tok-B", creds());
124
+
125
+ // 1st cold: RR 로 tok-A(id 1) 가 뽑히도록 — 첫 호출은 rrIndex=0 → idle[0]=id1.
126
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
127
+ const first = await d.generate(baseReq());
128
+ expect(first.threadCoord.workerId).toBe(1); // tok-A 가 S1 소유
129
+
130
+ // 이제 tok-A 는 count=1, tok-B 는 count=0 → least-used RR 이면 tok-B 를 고를 차례.
131
+ // 하지만 reuse.threadId=S1 의 소유 토큰은 tok-A 이므로 반드시 tok-A 로 resume 해야 한다.
132
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
133
+ const second = await d.generate(
134
+ baseReq({
135
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
136
+ reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
137
+ }),
138
+ );
139
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
140
+ expect(call2.tokenId).toBe(1); // RR 로 tok-B(2) 가 아니라 소유 토큰 tok-A(1)
141
+ expect(call2.resumeSessionId).toBe("S1");
142
+ expect(second.tokenName).toBe("tok-A");
143
+ });
144
+
145
+ it("resume 소유권 불일치: stored.tokenId != reuse.workerId → resume 불가, cold (codex P0)", async () => {
146
+ const d = new AnthropicDispatcher();
147
+ d.onTokenAdded(1, "tok-A", creds());
148
+ d.onTokenAdded(2, "tok-B", creds());
149
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
150
+ await d.generate(baseReq()); // tok-A(1) 가 S1 소유
151
+
152
+ // reuse.threadId=S1 은 맞지만 workerId 를 2 로 위조 → 소유권 불일치 → cold fallback.
153
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
154
+ await d.generate(
155
+ baseReq({
156
+ reuse: { workerId: 2, threadId: "S1", epoch: 0 },
157
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
158
+ }),
159
+ );
160
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
161
+ expect(call2.resumeSessionId).toBeUndefined(); // 소유권 불일치라 cold
162
+ });
163
+
164
+ it("resume ineligible: 다른 model 이면 호환키 불일치 → cold (새 session)", async () => {
165
+ const d = new AnthropicDispatcher();
166
+ d.onTokenAdded(1, "tok-A", creds());
167
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
168
+ await d.generate(baseReq()); // model sonnet 으로 S1 발급
169
+
170
+ // reuse.threadId=S1 이지만 model 이 다름 → 호환키 불일치 → cold
171
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
172
+ await d.generate(
173
+ baseReq({
174
+ model: "claude-opus-4-8",
175
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
176
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
177
+ }),
178
+ );
179
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
180
+ expect(call2.resumeSessionId).toBeUndefined(); // cold fallback (P1-5 오염 방지)
181
+ });
182
+
183
+ it("resume ineligible: schema 호환키 불일치로 cold fallback 해도 continuation input 을 전달한다", async () => {
184
+ const d = new AnthropicDispatcher();
185
+ d.onTokenAdded(1, "tok-A", creds());
186
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
187
+ await d.generate(
188
+ baseReq({ outputSchema: { type: "object", properties: { a: { type: "string" } } } }),
189
+ );
190
+
191
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
192
+ await d.generate(
193
+ baseReq({
194
+ outputSchema: { type: "object", properties: { b: { type: "string" } } },
195
+ coldHistory: [{ type: "function_call_output", call_id: "call_1", output: "ok" }],
196
+ coldInput: [
197
+ {
198
+ type: "text",
199
+ text: "Tool result for call call_1: ok\n\nNow continue answering the user's request using these results.",
200
+ text_elements: [],
201
+ },
202
+ ],
203
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
204
+ reuseInput: [
205
+ {
206
+ type: "text",
207
+ text: "Tool result for call call_1: ok\n\nNow continue answering the user's request using these results.",
208
+ text_elements: [],
209
+ },
210
+ ],
211
+ }),
212
+ );
213
+
214
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
215
+ expect(call2.resumeSessionId).toBeUndefined();
216
+ expect(call2.coldHistory).toBeDefined();
217
+ expect(call2.input[0]?.text).toContain("Now continue answering");
218
+ expect(call2.input[0]?.text).not.toBe("");
219
+ });
220
+
221
+ it("resume transcript/session failure: 죽은 compat 삭제 후 cold 로 1회 retry 한다", async () => {
222
+ const d = new AnthropicDispatcher();
223
+ d.onTokenAdded(1, "tok-A", creds());
224
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
225
+ await d.generate(baseReq());
226
+
227
+ runClaudeSessionMock
228
+ .mockRejectedValueOnce(
229
+ new Error("Claude session closed without result — stderr: failed to resume transcript"),
230
+ )
231
+ .mockResolvedValueOnce(sessionResult({ sessionId: "S2", workerId: 1 }));
232
+
233
+ const result = await d.generate(
234
+ baseReq({
235
+ coldHistory: [{ type: "function_call_output", call_id: "call_1", output: "ok" }],
236
+ coldInput: [
237
+ {
238
+ type: "text",
239
+ text: "Tool result for call call_1: ok\n\nNow continue answering the user's request using these results.",
240
+ text_elements: [],
241
+ },
242
+ ],
243
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
244
+ reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
245
+ }),
246
+ );
247
+
248
+ expect(result.threadCoord.threadId).toBe("S2");
249
+ const resumeCall = runClaudeSessionMock.mock.calls[1]![0];
250
+ expect(resumeCall.resumeSessionId).toBe("S1");
251
+ expect(resumeCall.coldHistory).toBeUndefined();
252
+ expect(resumeCall.input).toEqual([{ type: "text", text: "delta", text_elements: [] }]);
253
+
254
+ const retryCall = runClaudeSessionMock.mock.calls[2]![0];
255
+ expect(retryCall.resumeSessionId).toBeUndefined();
256
+ expect(retryCall.coldHistory).toBeDefined();
257
+ expect(retryCall.input[0]?.text).toContain("Now continue answering");
258
+
259
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S3", workerId: 1 }));
260
+ await d.generate(
261
+ baseReq({
262
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
263
+ reuseInput: [{ type: "text", text: "should not resume S1", text_elements: [] }],
264
+ }),
265
+ );
266
+ const postRetryCall = runClaudeSessionMock.mock.calls[3]![0];
267
+ expect(postRetryCall.resumeSessionId).toBeUndefined();
268
+ });
269
+
270
+ it.each([
271
+ ["timeout", "Claude session timeout after 120s"],
272
+ ["abort", "aborted"],
273
+ ["spawn", "Claude session spawn error: ENOENT"],
274
+ ["schema", "Claude session closed without result — stderr: schema validation failed"],
275
+ ["session-only", "Claude session failed before result"],
276
+ ])("resume %s failure 는 cold retry 하지 않는다", async (_label, message) => {
277
+ const d = new AnthropicDispatcher();
278
+ d.onTokenAdded(1, "tok-A", creds());
279
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
280
+ await d.generate(baseReq());
281
+
282
+ runClaudeSessionMock.mockRejectedValueOnce(new Error(message));
283
+ await expect(
284
+ d.generate(
285
+ baseReq({
286
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
287
+ reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
288
+ }),
289
+ ),
290
+ ).rejects.toThrow(message);
291
+
292
+ expect(runClaudeSessionMock).toHaveBeenCalledTimes(2);
293
+ });
294
+
295
+ it("quota 소진 → 에러", async () => {
296
+ const d = new AnthropicDispatcher();
297
+ d.onTokenAdded(1, "tok-A", creds());
298
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
299
+ await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
300
+ });
301
+
302
+ it("claude 에러 → 에러", async () => {
303
+ const d = new AnthropicDispatcher();
304
+ d.onTokenAdded(1, "tok-A", creds());
305
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ isError: true, text: "boom" }));
306
+ await expect(d.generate(baseReq())).rejects.toThrow(/claude error/);
307
+ });
308
+
309
+ it("structured(outputSchema): jsonSchema 로 직렬화되어 전달", async () => {
310
+ const d = new AnthropicDispatcher();
311
+ d.onTokenAdded(1, "tok-A", creds());
312
+ await d.generate(baseReq({ outputSchema: { type: "object", properties: {} } }));
313
+ const call = runClaudeSessionMock.mock.calls[0]![0];
314
+ expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
315
+ });
316
+
317
+ it("least-used RR: 두 토큰 번갈아 분배", async () => {
318
+ const d = new AnthropicDispatcher();
319
+ d.onTokenAdded(1, "tok-A", creds());
320
+ d.onTokenAdded(2, "tok-B", creds());
321
+ const names = new Set<string>();
322
+ runClaudeSessionMock.mockResolvedValue(sessionResult());
323
+ const r1 = await d.generate(baseReq());
324
+ const r2 = await d.generate(baseReq());
325
+ names.add(r1.tokenName);
326
+ names.add(r2.tokenName);
327
+ expect(names.size).toBe(2); // 서로 다른 토큰
328
+ });
329
+
330
+ it("onTokenRemoved 후 그 토큰 미선택", async () => {
331
+ const d = new AnthropicDispatcher();
332
+ d.onTokenAdded(1, "tok-A", creds());
333
+ d.onTokenRemoved(1);
334
+ await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
335
+ });
336
+
337
+ it("onTokenRemoved: 그 토큰으로 만든 session compat 정리 → 이후 같은 reuse.threadId 도 cold", async () => {
338
+ const d = new AnthropicDispatcher();
339
+ d.onTokenAdded(1, "tok-A", creds());
340
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
341
+ await d.generate(baseReq()); // S1 발급(token 1)
342
+
343
+ // 토큰 1 제거 → S1 compat 도 정리됨. 다시 토큰 추가 후 reuse.threadId=S1 시도 → eligible 아님(cold)
344
+ d.onTokenRemoved(1);
345
+ d.onTokenAdded(1, "tok-A", creds());
346
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
347
+ await d.generate(
348
+ baseReq({
349
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
350
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
351
+ }),
352
+ );
353
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
354
+ expect(call2.resumeSessionId).toBeUndefined(); // compat 정리돼 cold
355
+ });
356
+
357
+ it("onTokenUpdated: 같은 accountUuid 의 토큰 rotation 은 기존 session resume 유지 (codex P1)", async () => {
358
+ const d = new AnthropicDispatcher();
359
+ d.onTokenAdded(1, "tok-A", creds(3_600_000, "acc-1"));
360
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
361
+ await d.generate(baseReq()); // S1 발급(token 1, acc-1)
362
+
363
+ // access/refresh/expiresAt 만 바뀐 rotation (accountUuid 동일) → 세션 유지.
364
+ d.onTokenUpdated(1, "tok-A", creds(7_200_000, "acc-1"));
365
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
366
+ await d.generate(
367
+ baseReq({
368
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
369
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
370
+ }),
371
+ );
372
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
373
+ expect(call2.resumeSessionId).toBe("S1"); // rotation 이라 resume 유지
374
+ });
375
+
376
+ it("onTokenUpdated: accountUuid 변경(재로그인)이면 그 토큰 session compat 폐기 → cold (codex P1 격리)", async () => {
377
+ const d = new AnthropicDispatcher();
378
+ d.onTokenAdded(1, "tok-A", creds(3_600_000, "acc-1"));
379
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
380
+ await d.generate(baseReq()); // S1 발급(token 1, acc-1)
381
+
382
+ // 다른 accountUuid 로 재로그인 → S1 은 옛 계정 세션이므로 새 계정으로 resume 하면 안 됨.
383
+ d.onTokenUpdated(1, "tok-A", creds(3_600_000, "acc-2"));
384
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
385
+ await d.generate(
386
+ baseReq({
387
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
388
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
389
+ }),
390
+ );
391
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
392
+ expect(call2.resumeSessionId).toBeUndefined(); // identity 변경으로 compat 폐기 → cold
393
+ });
394
+
395
+ it("TTL 만료: 오래된 session compat 은 sweep 되어 resume 불가 → cold", async () => {
396
+ vi.useFakeTimers();
397
+ try {
398
+ const d = new AnthropicDispatcher();
399
+ d.onTokenAdded(1, "tok-A", creds());
400
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
401
+ await d.generate(baseReq()); // S1 발급 + compat 저장
402
+
403
+ // 10분 + 1초 경과 → 다음 generate 진입 시 sweep 으로 S1 폐기
404
+ vi.advanceTimersByTime(10 * 60 * 1000 + 1000);
405
+
406
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2" }));
407
+ await d.generate(
408
+ baseReq({
409
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
410
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
411
+ }),
412
+ );
413
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
414
+ expect(call2.resumeSessionId).toBeUndefined(); // TTL 만료로 cold fallback
415
+ } finally {
416
+ vi.useRealTimers();
417
+ }
418
+ });
419
+
420
+ it("model prefix 정규화: 'anthropic/claude-opus-4-8' → canonical 로 세션/result.model (codex P2)", async () => {
421
+ const d = new AnthropicDispatcher();
422
+ d.onTokenAdded(1, "tok-A", creds());
423
+ const result = await d.generate(baseReq({ model: "anthropic/claude-opus-4-8" }));
424
+ // runClaudeSession 과 GenerateResult 둘 다 prefix 없는 canonical id 를 써야 cost/compat 정합.
425
+ const call = runClaudeSessionMock.mock.calls[0]![0];
426
+ expect(call.model).toBe("claude-opus-4-8");
427
+ expect(result.model).toBe("claude-opus-4-8");
428
+ });
429
+
430
+ it("model prefix 유무가 같은 호환키 → resume 유지 (codex P2: prefix 가 compat 오염 안 함)", async () => {
431
+ const d = new AnthropicDispatcher();
432
+ d.onTokenAdded(1, "tok-A", creds());
433
+ // 1st: prefix 있는 model 로 S1 발급
434
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
435
+ await d.generate(baseReq({ model: "anthropic/claude-sonnet-4-6" }));
436
+
437
+ // 2nd: prefix 없는 동일 모델로 reuse → canonical 이 같으니 compat 일치 → resume
438
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
439
+ await d.generate(
440
+ baseReq({
441
+ model: "claude-sonnet-4-6",
442
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
443
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
444
+ }),
445
+ );
446
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
447
+ expect(call2.resumeSessionId).toBe("S1");
448
+ });
449
+
450
+ it("model 미지정 → ANTHROPIC_DEFAULT_MODEL 적용 (codex P3: sonnet 별칭 우회 차단)", async () => {
451
+ const d = new AnthropicDispatcher();
452
+ d.onTokenAdded(1, "tok-A", creds());
453
+ const result = await d.generate(baseReq({ model: undefined }));
454
+ const call = runClaudeSessionMock.mock.calls[0]![0];
455
+ expect(call.model).toBe(ANTHROPIC_DEFAULT_MODEL);
456
+ expect(result.model).toBe(ANTHROPIC_DEFAULT_MODEL);
457
+ });
458
+
459
+ it("replaceTokens: DB 기준 풀 재동기화 — 없는 토큰 제거(+compat 정리), 새 토큰 추가 (codex P1)", async () => {
460
+ const d = new AnthropicDispatcher();
461
+ d.onTokenAdded(1, "tok-A", creds());
462
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
463
+ await d.generate(baseReq()); // tok-A(1) 가 S1 소유 + compat 저장
464
+
465
+ // reconcile: DB 에는 tok-B(2)만 active. tok-A(1) 는 사라짐 → 제거 + S1 compat 정리.
466
+ d.replaceTokens([{ id: 2, name: "tok-B", credentials: creds() }]);
467
+
468
+ // 이제 tok-A(1) 미선택 + S1 resume 시도해도 compat 없어서 cold.
469
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S2", workerId: 2 }));
470
+ const r = await d.generate(
471
+ baseReq({
472
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
473
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
474
+ }),
475
+ );
476
+ expect(r.tokenName).toBe("tok-B"); // tok-A 제거됨 → tok-B 만 남음
477
+ const call2 = runClaudeSessionMock.mock.calls[1]![0];
478
+ expect(call2.resumeSessionId).toBeUndefined(); // S1 compat 정리됨 → cold
479
+ });
480
+
481
+ it("resume deadlock 회귀: runClaudeSession 이 내부 락을 잡아도 dispatcher 가 중첩 락을 안 건다 (codex U5 P0)", async () => {
482
+ const d = new AnthropicDispatcher();
483
+ d.onTokenAdded(1, "tok-A", creds());
484
+ // 1st cold → S1 발급 (mock 도 실제 runClaudeSession 처럼 sessionId 단위 락을 잡게 한다)
485
+ runClaudeSessionMock.mockImplementationOnce(async (req: { resumeSessionId?: string }) =>
486
+ withSessionLock(req.resumeSessionId ?? "S1", async () =>
487
+ sessionResult({ sessionId: "S1", workerId: 1 }),
488
+ ),
489
+ );
490
+ await d.generate(baseReq());
491
+
492
+ // 2nd resume(S1): mock 이 실제 withSessionLock("S1") 재진입. dispatcher 가 outer withSessionLock("S1")
493
+ // 으로 또 감쌌다면 같은 sessionId 비-reentrant 락이 자기 자신을 기다려 deadlock → 이 await 가 영구 정지.
494
+ // dispatcher 가 락을 안 거므로 정상 완료돼야 한다.
495
+ runClaudeSessionMock.mockImplementationOnce(async (req: { resumeSessionId?: string }) =>
496
+ withSessionLock(req.resumeSessionId ?? "S1", async () =>
497
+ sessionResult({ sessionId: "S1", workerId: 1 }),
498
+ ),
499
+ );
500
+ const second = await d.generate(
501
+ baseReq({
502
+ reuse: { workerId: 1, threadId: "S1", epoch: 0 },
503
+ reuseInput: [{ type: "text", text: "x", text_elements: [] }],
504
+ }),
505
+ );
506
+ expect(second.threadCoord.threadId).toBe("S1"); // deadlock 없이 완료
507
+ });
508
+
509
+ it("generateStream: onDelta/onComplete 호출", async () => {
510
+ const d = new AnthropicDispatcher();
511
+ d.onTokenAdded(1, "tok-A", creds());
512
+ // runClaudeSession 이 onDelta 를 호출하도록 모킹
513
+ runClaudeSessionMock.mockImplementationOnce(async (_req, onDelta: (t: string) => void) => {
514
+ onDelta("부분");
515
+ return sessionResult({ text: "부분완성" });
516
+ });
517
+ const deltas: Array<string> = [];
518
+ let completed: unknown = null;
519
+ await d.generateStream(baseReq(), {
520
+ onDelta: (t) => deltas.push(t),
521
+ onComplete: (r) => (completed = r),
522
+ onError: () => {},
523
+ });
524
+ expect(deltas).toEqual(["부분"]);
525
+ expect(completed).not.toBeNull();
526
+ });
527
+
528
+ it("refresh: 만료 임박 토큰은 provider 포함해 refreshToken 호출, 새 access token 으로 세션 진행 (codex P1)", async () => {
529
+ const d = new AnthropicDispatcher();
530
+ // 만료까지 30초 → REFRESH_SAFETY_MS(60s) 안쪽이라 preemptive refresh 발동.
531
+ d.onTokenAdded(1, "tok-A", creds(30_000));
532
+ refreshTokenMock.mockResolvedValueOnce("sk-ant-oat01-refreshed");
533
+ await d.generate(baseReq());
534
+
535
+ // refreshToken 은 provider 를 반드시 포함해 호출돼야 함(없으면 TokenModel.save 실패).
536
+ expect(refreshTokenMock).toHaveBeenCalledTimes(1);
537
+ const refreshArg = refreshTokenMock.mock.calls[0]![0];
538
+ expect(refreshArg.provider).toBe("anthropic");
539
+ expect(refreshArg.id).toBe(1);
540
+ // refresh 된 access token 이 claude 세션으로 전달돼야 함.
541
+ const sessionArg = runClaudeSessionMock.mock.calls[0]![0];
542
+ expect(sessionArg.token).toBe("sk-ant-oat01-refreshed");
543
+ });
544
+
545
+ it("refresh: 만료 여유 있으면 refresh 안 함, 기존 access token 사용", async () => {
546
+ const d = new AnthropicDispatcher();
547
+ d.onTokenAdded(1, "tok-A", creds(3_600_000)); // 1시간 여유
548
+ await d.generate(baseReq());
549
+ expect(refreshTokenMock).not.toHaveBeenCalled();
550
+ const sessionArg = runClaudeSessionMock.mock.calls[0]![0];
551
+ expect(sessionArg.token).toBe("sk-ant-oat01-test");
552
+ });
553
+
554
+ it("refresh 실패해도(throw) 기존 access token 으로 진행 — 요청을 죽이지 않음 (codex P1)", async () => {
555
+ const d = new AnthropicDispatcher();
556
+ d.onTokenAdded(1, "tok-A", creds(30_000));
557
+ refreshTokenMock.mockRejectedValueOnce(new Error("refresh boom"));
558
+ const result = await d.generate(baseReq());
559
+ // refresh 가 throw 해도 catch 후 만료 임박 access token 으로 진행.
560
+ expect(result.text).toBe("hello");
561
+ const sessionArg = runClaudeSessionMock.mock.calls[0]![0];
562
+ expect(sessionArg.token).toBe("sk-ant-oat01-test");
563
+ });
564
+ });