@cartanova/qgrid-cli 2.2.1 → 2.3.1

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 +64 -37
  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 +37 -0
  17. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +99 -44
  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
@@ -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
+ });