@cartanova/qgrid-cli 2.3.2 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -278
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +15 -5
  3. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +33 -12
  4. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +42 -98
  5. package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
  6. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
  7. package/bundle/dist/utils/providers/common/model-cost.js +3 -2
  8. package/bundle/dist/utils/providers/openai/codex-worker.js +1 -2
  9. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +19 -12
  10. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +235 -7
  11. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -364
  12. package/bundle/src/application/qgrid/qgrid.frame.ts +38 -23
  13. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +60 -0
  14. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +57 -16
  15. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +92 -340
  16. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +61 -188
  17. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
  18. package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
  19. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
  20. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
  21. package/bundle/src/utils/providers/common/model-cost.test.ts +19 -1
  22. package/bundle/src/utils/providers/common/model-cost.ts +2 -1
  23. package/bundle/src/utils/providers/common/provider-dispatcher.ts +7 -4
  24. package/bundle/src/utils/providers/openai/codex-worker.ts +2 -8
  25. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +18 -11
  26. package/package.json +1 -1
@@ -4,20 +4,17 @@ import { type AnthropicCredentials } from "../../../application/token/token.type
4
4
  import { type GenerateRequest } from "../common/provider-dispatcher";
5
5
  import { ANTHROPIC_DEFAULT_MODEL } from "./anthropic-constants";
6
6
  import { AnthropicDispatcher } from "./anthropic-dispatcher";
7
- import { withSessionLock } from "./claude-session";
8
7
 
9
- // runClaudeSession 은 실제 claude 프로세스를 spawn 하므로 모킹.
10
- // compatibilityKey / makeAnthropicWorkerId / withSessionLock 은 순수 함수라 실제 구현 사용.
11
- // vi.hoisted 로 mock fn 을 호이스트해 vi.mock 팩토리에서 참조한다.
12
8
  const { runClaudeSessionMock, refreshTokenMock } = vi.hoisted(() => ({
13
9
  runClaudeSessionMock: vi.fn(),
14
10
  refreshTokenMock: vi.fn(),
15
11
  }));
12
+
16
13
  vi.mock("./claude-session", async (importActual) => {
17
14
  const actual = await importActual<typeof import("./claude-session")>();
18
15
  return { ...actual, runClaudeSession: runClaudeSessionMock };
19
16
  });
20
- // run() 이 동적 import 하는 QgridFrame.refreshToken 을 모킹(만료 임박 토큰 preemptive refresh 경로).
17
+
21
18
  vi.mock("../../../application/qgrid/qgrid.frame", () => ({
22
19
  QgridFrame: { refreshToken: refreshTokenMock },
23
20
  }));
@@ -75,211 +72,77 @@ describe("AnthropicDispatcher", () => {
75
72
  it("happy: generate → GenerateResult (threadCoord 조립, systemHash 없음)", async () => {
76
73
  const d = new AnthropicDispatcher();
77
74
  d.onTokenAdded(1, "tok-A", creds());
75
+
78
76
  const result = await d.generate(baseReq());
77
+
79
78
  expect(result.text).toBe("hello");
80
79
  expect(result.tokenName).toBe("tok-A");
81
80
  expect(result.model).toBe("claude-sonnet-4-6");
82
- // threadCoord: threadId=session-id, epoch=0, workerId=tokenId
83
81
  expect(result.threadCoord).toEqual({ workerId: 1, threadId: "sess-generated", epoch: 0 });
84
82
  });
85
83
 
86
- it("cold 호출: resumeSessionId 없음, coldInput/coldHistory 전달", async () => {
84
+ it("cold 호출: coldInput/coldHistory 를 그대로 전달하고 continuation session id 는 없다", async () => {
87
85
  const d = new AnthropicDispatcher();
88
86
  d.onTokenAdded(1, "tok-A", creds());
87
+
89
88
  await d.generate(
90
89
  baseReq({ coldHistory: [{ type: "message", role: "assistant", content: [] }] }),
91
90
  );
91
+
92
92
  const call = runClaudeSessionMock.mock.calls[0]![0];
93
- expect(call.resumeSessionId).toBeUndefined();
93
+ expect(call).not.toHaveProperty("resumeSessionId");
94
94
  expect(call.coldHistory).toBeDefined();
95
95
  expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
96
96
  });
97
97
 
98
- it("resume eligible: 같은 호환키의 reuse.threadId resume (reuseInput, history 미전달)", async () => {
98
+ it("reuse/reuseInput 실려 와도 무시하고 coldInput/coldHistory 실행한다", async () => {
99
99
  const d = new AnthropicDispatcher();
100
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
101
 
106
- // 2nd 호출: 같은 system/model 로 reuse.threadId=S1 → eligible
107
- runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
108
102
  await d.generate(
109
103
  baseReq({
104
+ coldHistory: [{ type: "message", role: "assistant", content: [] }],
110
105
  reuse: { workerId: 1, threadId: "S1", epoch: 0 },
111
106
  reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
112
107
  }),
113
108
  );
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
109
 
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
110
+ const call = runClaudeSessionMock.mock.calls[0]![0];
111
+ expect(call).not.toHaveProperty("resumeSessionId");
112
+ expect(call.coldHistory).toBeDefined();
113
+ expect(call.input).toEqual([{ type: "text", text: "hi", text_elements: [] }]);
162
114
  });
163
115
 
164
- it("resume ineligible: 다른 model 이면 호환키 불일치 → cold (새 session)", async () => {
116
+ it("structured(outputSchema): jsonSchema 직렬화되어 전달", async () => {
165
117
  const d = new AnthropicDispatcher();
166
118
  d.onTokenAdded(1, "tok-A", creds());
167
- runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1" }));
168
- await d.generate(baseReq()); // model sonnet 으로 S1 발급
119
+ await d.generate(baseReq({ outputSchema: { type: "object", properties: {} } }));
169
120
 
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 오염 방지)
121
+ const call = runClaudeSessionMock.mock.calls[0]![0];
122
+ expect(call.jsonSchema).toBe(JSON.stringify({ type: "object", properties: {} }));
181
123
  });
182
124
 
183
- it("resume ineligible: schema 호환키 불일치로 cold fallback 해도 continuation input 을 전달한다", async () => {
125
+ it("quota 소진 에러", async () => {
184
126
  const d = new AnthropicDispatcher();
185
127
  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
- );
128
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
213
129
 
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("");
130
+ await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
219
131
  });
220
132
 
221
- it("resume transcript/session failure: 죽은 compat 삭제 후 cold 로 1회 retry 한다", async () => {
133
+ it("claude 에러 에러", async () => {
222
134
  const d = new AnthropicDispatcher();
223
135
  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");
136
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ isError: true, text: "boom" }));
258
137
 
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();
138
+ await expect(d.generate(baseReq())).rejects.toThrow(/claude error/);
268
139
  });
269
140
 
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) => {
141
+ it("cold 실패는 resume retry 없이 그대로 전파한다", async () => {
277
142
  const d = new AnthropicDispatcher();
278
143
  d.onTokenAdded(1, "tok-A", creds());
279
- runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ sessionId: "S1", workerId: 1 }));
280
- await d.generate(baseReq());
144
+ runClaudeSessionMock.mockRejectedValueOnce(new Error("closed without result"));
281
145
 
282
- runClaudeSessionMock.mockRejectedValueOnce(new Error(message));
283
146
  await expect(
284
147
  d.generate(
285
148
  baseReq({
@@ -287,233 +150,103 @@ describe("AnthropicDispatcher", () => {
287
150
  reuseInput: [{ type: "text", text: "delta", text_elements: [] }],
288
151
  }),
289
152
  ),
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: {} }));
153
+ ).rejects.toThrow("closed without result");
154
+ expect(runClaudeSessionMock).toHaveBeenCalledTimes(1);
315
155
  });
316
156
 
317
157
  it("least-used RR: 두 토큰 번갈아 분배", async () => {
318
158
  const d = new AnthropicDispatcher();
319
159
  d.onTokenAdded(1, "tok-A", creds());
320
160
  d.onTokenAdded(2, "tok-B", creds());
161
+
321
162
  const names = new Set<string>();
322
- runClaudeSessionMock.mockResolvedValue(sessionResult());
323
163
  const r1 = await d.generate(baseReq());
324
164
  const r2 = await d.generate(baseReq());
325
165
  names.add(r1.tokenName);
326
166
  names.add(r2.tokenName);
327
- expect(names.size).toBe(2); // 서로 다른 토큰
167
+
168
+ expect(names.size).toBe(2);
328
169
  });
329
170
 
330
171
  it("onTokenRemoved 후 그 토큰 미선택", async () => {
331
172
  const d = new AnthropicDispatcher();
332
173
  d.onTokenAdded(1, "tok-A", creds());
333
174
  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
175
 
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
176
+ await expect(d.generate(baseReq())).rejects.toThrow(/No anthropic tokens/);
355
177
  });
356
178
 
357
- it("onTokenUpdated: 같은 accountUuid 의 토큰 rotation 기존 session resume 유지 (codex P1)", async () => {
179
+ it("onTokenUpdated 토큰 credentials in-place 갱신한다", async () => {
358
180
  const d = new AnthropicDispatcher();
359
181
  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)
182
+ d.onTokenUpdated(1, "tok-A", creds(7_200_000, "acc-2"));
362
183
 
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)
184
+ await d.generate(baseReq());
381
185
 
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
186
+ const call = runClaudeSessionMock.mock.calls[0]![0];
187
+ expect(call.tokenId).toBe(1);
393
188
  });
394
189
 
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 저장
190
+ it("model prefix 정규화: 'anthropic/claude-opus-4-8' canonical 세션/result.model", async () => {
191
+ const d = new AnthropicDispatcher();
192
+ d.onTokenAdded(1, "tok-A", creds());
402
193
 
403
- // 10분 + 1초 경과 → 다음 generate 진입 sweep 으로 S1 폐기
404
- vi.advanceTimersByTime(10 * 60 * 1000 + 1000);
194
+ const result = await d.generate(baseReq({ model: "anthropic/claude-opus-4-8" }));
405
195
 
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
- }
196
+ const call = runClaudeSessionMock.mock.calls[0]![0];
197
+ expect(call.model).toBe("claude-opus-4-8");
198
+ expect(result.model).toBe("claude-opus-4-8");
418
199
  });
419
200
 
420
- it("model prefix 정규화: 'anthropic/claude-opus-4-8' canonical 세션/result.model (codex P2)", async () => {
201
+ it("[1m] suffix 정규화: result/runClaudeSession 에는 base canonical 전달", async () => {
421
202
  const d = new AnthropicDispatcher();
422
203
  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 정합.
204
+
205
+ const result = await d.generate(baseReq({ model: "anthropic/claude-sonnet-4-6[1m]" }));
206
+
425
207
  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");
208
+ expect(call.model).toBe("claude-sonnet-4-6");
209
+ expect(result.model).toBe("claude-sonnet-4-6");
428
210
  });
429
211
 
430
- it("model prefix 유무가 같은 호환키 resume 유지 (codex P2: prefix 가 compat 오염 안 함)", async () => {
212
+ it("unsupported alias + [1m] 조용히 다운그레이드하지 않는다", async () => {
431
213
  const d = new AnthropicDispatcher();
432
214
  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
215
 
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
- }),
216
+ await expect(d.generate(baseReq({ model: "sonnet[1m]" }))).rejects.toThrow(
217
+ /Unsupported Anthropic 1M model suffix/,
445
218
  );
446
- const call2 = runClaudeSessionMock.mock.calls[1]![0];
447
- expect(call2.resumeSessionId).toBe("S1");
448
219
  });
449
220
 
450
- it("model 미지정 → ANTHROPIC_DEFAULT_MODEL 적용 (codex P3: sonnet 별칭 우회 차단)", async () => {
221
+ it("model 미지정 → ANTHROPIC_DEFAULT_MODEL 적용", async () => {
451
222
  const d = new AnthropicDispatcher();
452
223
  d.onTokenAdded(1, "tok-A", creds());
224
+
453
225
  const result = await d.generate(baseReq({ model: undefined }));
226
+
454
227
  const call = runClaudeSessionMock.mock.calls[0]![0];
455
228
  expect(call.model).toBe(ANTHROPIC_DEFAULT_MODEL);
456
229
  expect(result.model).toBe(ANTHROPIC_DEFAULT_MODEL);
457
230
  });
458
231
 
459
- it("replaceTokens: DB 기준 풀 재동기화 — 없는 토큰 제거(+compat 정리), 새 토큰 추가 (codex P1)", async () => {
232
+ it("replaceTokens: DB 기준 풀 재동기화 — 없는 토큰 제거, 새 토큰 추가", async () => {
460
233
  const d = new AnthropicDispatcher();
461
234
  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
235
 
465
- // reconcile: DB 에는 tok-B(2)만 active. tok-A(1) 는 사라짐 → 제거 + S1 compat 정리.
466
236
  d.replaceTokens([{ id: 2, name: "tok-B", credentials: creds() }]);
237
+ const result = await d.generate(baseReq());
467
238
 
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 없이 완료
239
+ expect(result.tokenName).toBe("tok-B");
507
240
  });
508
241
 
509
242
  it("generateStream: onDelta/onComplete 호출", async () => {
510
243
  const d = new AnthropicDispatcher();
511
244
  d.onTokenAdded(1, "tok-A", creds());
512
- // runClaudeSession 이 onDelta 를 호출하도록 모킹
513
245
  runClaudeSessionMock.mockImplementationOnce(async (_req, onDelta: (t: string) => void) => {
514
246
  onDelta("부분");
515
247
  return sessionResult({ text: "부분완성" });
516
248
  });
249
+
517
250
  const deltas: Array<string> = [];
518
251
  let completed: unknown = null;
519
252
  await d.generateStream(baseReq(), {
@@ -521,44 +254,63 @@ describe("AnthropicDispatcher", () => {
521
254
  onComplete: (r) => (completed = r),
522
255
  onError: () => {},
523
256
  });
257
+
524
258
  expect(deltas).toEqual(["부분"]);
525
259
  expect(completed).not.toBeNull();
260
+ expect(runClaudeSessionMock.mock.calls[0]![0].includePartialMessages).toBe(true);
261
+ });
262
+
263
+ it("generateStream: Claude server error 는 onError 로 전달하고 complete 하지 않는다", async () => {
264
+ const d = new AnthropicDispatcher();
265
+ d.onTokenAdded(1, "tok-A", creds());
266
+ const serverError = new Error(
267
+ "claude error (anthropic/yds): API Error: 529 Overloaded. This is a server-side issue",
268
+ );
269
+ runClaudeSessionMock.mockRejectedValueOnce(serverError);
270
+
271
+ const onDelta = vi.fn();
272
+ const onComplete = vi.fn();
273
+ const onError = vi.fn();
274
+ await d.generateStream(baseReq(), { onDelta, onComplete, onError });
275
+
276
+ expect(onDelta).not.toHaveBeenCalled();
277
+ expect(onComplete).not.toHaveBeenCalled();
278
+ expect(onError).toHaveBeenCalledWith(serverError);
279
+ expect(runClaudeSessionMock.mock.calls[0]![0].includePartialMessages).toBe(true);
526
280
  });
527
281
 
528
- it("refresh: 만료 임박 토큰은 provider 포함해 refreshToken 호출, 새 access token 으로 세션 진행 (codex P1)", async () => {
282
+ it("refresh: 만료 임박 토큰은 provider 포함해 refreshToken 호출, 새 access token 으로 세션 진행", async () => {
529
283
  const d = new AnthropicDispatcher();
530
- // 만료까지 30초 → REFRESH_SAFETY_MS(60s) 안쪽이라 preemptive refresh 발동.
531
284
  d.onTokenAdded(1, "tok-A", creds(30_000));
532
285
  refreshTokenMock.mockResolvedValueOnce("sk-ant-oat01-refreshed");
286
+
533
287
  await d.generate(baseReq());
534
288
 
535
- // refreshToken 은 provider 를 반드시 포함해 호출돼야 함(없으면 TokenModel.save 실패).
536
289
  expect(refreshTokenMock).toHaveBeenCalledTimes(1);
537
290
  const refreshArg = refreshTokenMock.mock.calls[0]![0];
538
291
  expect(refreshArg.provider).toBe("anthropic");
539
292
  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");
293
+ expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-refreshed");
543
294
  });
544
295
 
545
296
  it("refresh: 만료 여유 있으면 refresh 안 함, 기존 access token 사용", async () => {
546
297
  const d = new AnthropicDispatcher();
547
- d.onTokenAdded(1, "tok-A", creds(3_600_000)); // 1시간 여유
298
+ d.onTokenAdded(1, "tok-A", creds(3_600_000));
299
+
548
300
  await d.generate(baseReq());
301
+
549
302
  expect(refreshTokenMock).not.toHaveBeenCalled();
550
- const sessionArg = runClaudeSessionMock.mock.calls[0]![0];
551
- expect(sessionArg.token).toBe("sk-ant-oat01-test");
303
+ expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-test");
552
304
  });
553
305
 
554
- it("refresh 실패해도(throw) 기존 access token 으로 진행 — 요청을 죽이지 않음 (codex P1)", async () => {
306
+ it("refresh 실패해도 기존 access token 으로 진행 — 요청을 죽이지 않음", async () => {
555
307
  const d = new AnthropicDispatcher();
556
308
  d.onTokenAdded(1, "tok-A", creds(30_000));
557
309
  refreshTokenMock.mockRejectedValueOnce(new Error("refresh boom"));
310
+
558
311
  const result = await d.generate(baseReq());
559
- // refresh 가 throw 해도 catch 후 만료 임박 access token 으로 진행.
312
+
560
313
  expect(result.text).toBe("hello");
561
- const sessionArg = runClaudeSessionMock.mock.calls[0]![0];
562
- expect(sessionArg.token).toBe("sk-ant-oat01-test");
314
+ expect(runClaudeSessionMock.mock.calls[0]![0].token).toBe("sk-ant-oat01-test");
563
315
  });
564
316
  });