@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.
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +94 -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 +42 -98
- package/bundle/dist/utils/providers/anthropic/claude-session.js +54 -32
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +7 -37
- 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 +235 -7
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +152 -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 +61 -188
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +89 -62
- package/bundle/src/utils/providers/anthropic/claude-session.ts +71 -46
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +74 -30
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +40 -102
- 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
|
@@ -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
|
-
|
|
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 호출:
|
|
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
|
|
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("
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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("
|
|
116
|
+
it("structured(outputSchema): jsonSchema 로 직렬화되어 전달", async () => {
|
|
165
117
|
const d = new AnthropicDispatcher();
|
|
166
118
|
d.onTokenAdded(1, "tok-A", creds());
|
|
167
|
-
|
|
168
|
-
await d.generate(baseReq()); // model sonnet 으로 S1 발급
|
|
119
|
+
await d.generate(baseReq({ outputSchema: { type: "object", properties: {} } }));
|
|
169
120
|
|
|
170
|
-
|
|
171
|
-
|
|
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("
|
|
125
|
+
it("quota 소진 → 에러", async () => {
|
|
184
126
|
const d = new AnthropicDispatcher();
|
|
185
127
|
d.onTokenAdded(1, "tok-A", creds());
|
|
186
|
-
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({
|
|
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
|
-
|
|
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("
|
|
133
|
+
it("claude 에러 → 에러", async () => {
|
|
222
134
|
const d = new AnthropicDispatcher();
|
|
223
135
|
d.onTokenAdded(1, "tok-A", creds());
|
|
224
|
-
runClaudeSessionMock.mockResolvedValueOnce(sessionResult({
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
383
|
-
|
|
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("
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
404
|
-
vi.advanceTimersByTime(10 * 60 * 1000 + 1000);
|
|
194
|
+
const result = await d.generate(baseReq({ model: "anthropic/claude-opus-4-8" }));
|
|
405
195
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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("
|
|
201
|
+
it("[1m] suffix 정규화: result/runClaudeSession 에는 base canonical 만 전달", async () => {
|
|
421
202
|
const d = new AnthropicDispatcher();
|
|
422
203
|
d.onTokenAdded(1, "tok-A", creds());
|
|
423
|
-
|
|
424
|
-
|
|
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-
|
|
427
|
-
expect(result.model).toBe("claude-
|
|
208
|
+
expect(call.model).toBe("claude-sonnet-4-6");
|
|
209
|
+
expect(result.model).toBe("claude-sonnet-4-6");
|
|
428
210
|
});
|
|
429
211
|
|
|
430
|
-
it("
|
|
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
|
-
|
|
438
|
-
|
|
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 적용
|
|
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 기준 풀 재동기화 — 없는 토큰
|
|
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
|
-
|
|
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 으로 세션 진행
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
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 실패해도
|
|
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
|
-
|
|
312
|
+
|
|
560
313
|
expect(result.text).toBe("hello");
|
|
561
|
-
|
|
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
|
});
|