@cartanova/qgrid-cli 2.7.5 → 2.7.6
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 +7 -3
- package/bundle/dist/application/qgrid/qgrid.frame.js +27 -11
- package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +19 -1
- package/bundle/dist/application/qgrid/token-window-keepalive.js +153 -0
- package/bundle/dist/application/setting/setting.constant.js +11 -1
- package/bundle/dist/application/token/token.model.js +18 -1
- package/bundle/dist/migrations/20260819102011_alter_tokens_alter1.js +13 -0
- package/bundle/dist/sonamu.config.js +9 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +7 -4
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +11 -3
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +51 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +14 -4
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +58 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +39 -12
- package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +112 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +35 -0
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +223 -0
- package/bundle/src/application/qgrid/token-window-keepalive.ts +227 -0
- package/bundle/src/application/setting/setting.constant.test.ts +10 -0
- package/bundle/src/application/setting/setting.constant.ts +11 -1
- package/bundle/src/application/sonamu.generated.http +2 -0
- package/bundle/src/application/token/token.entity.json +8 -1
- package/bundle/src/application/token/token.model.test.ts +37 -0
- package/bundle/src/application/token/token.model.ts +20 -0
- package/bundle/src/migrations/20260819102011_alter_tokens_alter1.ts +13 -0
- package/bundle/src/sonamu.config.ts +14 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +46 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +13 -4
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +30 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +17 -1
- package/bundle/web-dist/client/assets/index-BbOyk01G.js +106 -0
- package/bundle/web-dist/client/assets/{logs-BrAu0koY.js → logs-B7Pc88bf.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D6JEk7n7.js → monit-CzMV4lZf.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Dvu63qre.js → routes-COWTRxZo.js} +1 -1
- package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +1 -0
- package/bundle/web-dist/client/assets/{show-BFGjYsWb.js → show-YvdybkKd.js} +1 -1
- package/bundle/web-dist/client/assets/{tokens-D-P__jYk.js → tokens-DynVDx0c.js} +1 -1
- package/bundle/web-dist/client/assets/{use-oauth-login-flow-CJpoJD26.js → use-oauth-login-flow-OzmHElsE.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/{settings-nCv71sax.js → settings-CIYR7h3G.js} +1 -0
- package/bundle/web-dist/server/entry-server.generated.js +96 -51
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/index-CwSSXuZv.js +0 -106
- package/bundle/web-dist/client/assets/settings-mwDGFwUi.js +0 -1
|
@@ -104,6 +104,89 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
104
104
|
);
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
it("Anthropic 토큰 변경 뒤 keepalive 대상 재예약을 알린다", async () => {
|
|
108
|
+
const anthropicDispatcher = { onTokenAdded: vi.fn() };
|
|
109
|
+
const subscriber = new TokenSubscriber(
|
|
110
|
+
{} as never,
|
|
111
|
+
{
|
|
112
|
+
tokens: new Map(),
|
|
113
|
+
removeCache: vi.fn(),
|
|
114
|
+
upsertCache: vi.fn(),
|
|
115
|
+
replaceCache: vi.fn(),
|
|
116
|
+
openaiDispatcher: null,
|
|
117
|
+
anthropicDispatcher,
|
|
118
|
+
} as never,
|
|
119
|
+
);
|
|
120
|
+
const onTokensChanged = vi.fn();
|
|
121
|
+
subscriber.setTokenChangeHandler(onTokensChanged);
|
|
122
|
+
findOneMock.mockResolvedValueOnce({
|
|
123
|
+
...openaiToken(true),
|
|
124
|
+
provider: "anthropic",
|
|
125
|
+
credentials: { accessToken: "access", refreshToken: "refresh" },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await subscriber.handleNotification(JSON.stringify({ op: "INSERT", id: 1 }));
|
|
129
|
+
|
|
130
|
+
expect(onTokensChanged).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("Anthropic credentials 갱신은 keepalive 전체 재예약을 유발하지 않는다", async () => {
|
|
134
|
+
const previous = {
|
|
135
|
+
...openaiToken(true),
|
|
136
|
+
provider: "anthropic",
|
|
137
|
+
credentials: { accessToken: "old", refreshToken: "refresh" },
|
|
138
|
+
};
|
|
139
|
+
const anthropicDispatcher = { onTokenUpdated: vi.fn() };
|
|
140
|
+
const subscriber = new TokenSubscriber(
|
|
141
|
+
{} as never,
|
|
142
|
+
{
|
|
143
|
+
tokens: new Map([[previous.id, previous]]),
|
|
144
|
+
removeCache: vi.fn(),
|
|
145
|
+
upsertCache: vi.fn(),
|
|
146
|
+
replaceCache: vi.fn(),
|
|
147
|
+
openaiDispatcher: null,
|
|
148
|
+
anthropicDispatcher,
|
|
149
|
+
} as never,
|
|
150
|
+
);
|
|
151
|
+
const onTokensChanged = vi.fn();
|
|
152
|
+
subscriber.setTokenChangeHandler(onTokensChanged);
|
|
153
|
+
findOneMock.mockResolvedValueOnce({
|
|
154
|
+
...previous,
|
|
155
|
+
credentials: { accessToken: "new", refreshToken: "refresh" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: previous.id }));
|
|
159
|
+
|
|
160
|
+
expect(anthropicDispatcher.onTokenUpdated).toHaveBeenCalledOnce();
|
|
161
|
+
expect(onTokensChanged).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("Anthropic active 전환은 keepalive 대상 재예약을 알린다", async () => {
|
|
165
|
+
const previous = {
|
|
166
|
+
...openaiToken(true),
|
|
167
|
+
provider: "anthropic",
|
|
168
|
+
credentials: { accessToken: "access", refreshToken: "refresh" },
|
|
169
|
+
};
|
|
170
|
+
const subscriber = new TokenSubscriber(
|
|
171
|
+
{} as never,
|
|
172
|
+
{
|
|
173
|
+
tokens: new Map([[previous.id, previous]]),
|
|
174
|
+
removeCache: vi.fn(),
|
|
175
|
+
upsertCache: vi.fn(),
|
|
176
|
+
replaceCache: vi.fn(),
|
|
177
|
+
openaiDispatcher: null,
|
|
178
|
+
anthropicDispatcher: { onTokenRemoved: vi.fn() },
|
|
179
|
+
} as never,
|
|
180
|
+
);
|
|
181
|
+
const onTokensChanged = vi.fn();
|
|
182
|
+
subscriber.setTokenChangeHandler(onTokensChanged);
|
|
183
|
+
findOneMock.mockResolvedValueOnce({ ...previous, active: false });
|
|
184
|
+
|
|
185
|
+
await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: previous.id }));
|
|
186
|
+
|
|
187
|
+
expect(onTokensChanged).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
107
190
|
it("serializes token reloads so rapid weight updates cannot apply backward", async () => {
|
|
108
191
|
let resolveFirst!: (value: ReturnType<typeof openaiToken>) => void;
|
|
109
192
|
const firstRow = new Promise<ReturnType<typeof openaiToken>>((resolve) => {
|
|
@@ -188,4 +271,33 @@ describe("TokenSubscriber OpenAI notifications", () => {
|
|
|
188
271
|
expect.objectContaining({ id: 2, quotaThreshold: 80, weight: 4 }),
|
|
189
272
|
]);
|
|
190
273
|
});
|
|
274
|
+
|
|
275
|
+
it("reconcile 은 Anthropic 대상 집합이 바뀔 때만 keepalive 재예약을 알린다", async () => {
|
|
276
|
+
const currentAnthropic = {
|
|
277
|
+
...openaiToken(true),
|
|
278
|
+
provider: "anthropic",
|
|
279
|
+
credentials: { accessToken: "access-a", refreshToken: "refresh-a" },
|
|
280
|
+
};
|
|
281
|
+
const dispatcher = {
|
|
282
|
+
tokens: new Map([[currentAnthropic.id, currentAnthropic]]),
|
|
283
|
+
replaceCache: vi.fn(),
|
|
284
|
+
openaiDispatcher: null,
|
|
285
|
+
anthropicDispatcher: { replaceTokens: vi.fn() },
|
|
286
|
+
};
|
|
287
|
+
const subscriber = new TokenSubscriber({} as never, dispatcher as never);
|
|
288
|
+
const onTokensChanged = vi.fn();
|
|
289
|
+
subscriber.setTokenChangeHandler(onTokensChanged);
|
|
290
|
+
findActiveMock
|
|
291
|
+
.mockResolvedValueOnce([currentAnthropic])
|
|
292
|
+
.mockResolvedValueOnce([
|
|
293
|
+
currentAnthropic,
|
|
294
|
+
{ ...currentAnthropic, id: 3, name: "anthropic/tok-C" },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
await subscriber.reconcile();
|
|
298
|
+
expect(onTokensChanged).not.toHaveBeenCalled();
|
|
299
|
+
|
|
300
|
+
await subscriber.reconcile();
|
|
301
|
+
expect(onTokensChanged).toHaveBeenCalledTimes(1);
|
|
302
|
+
});
|
|
191
303
|
});
|
|
@@ -26,12 +26,25 @@ export class TokenSubscriber {
|
|
|
26
26
|
connectedAt: Date | null = null;
|
|
27
27
|
lastReconcileAt: Date | null = null;
|
|
28
28
|
private operationChain: Promise<void> = Promise.resolve();
|
|
29
|
+
private tokenChangeHandler: (() => void) | null = null;
|
|
29
30
|
|
|
30
31
|
constructor(
|
|
31
32
|
public connConfig: ClientConfig,
|
|
32
33
|
public dispatcher: QgridDispatcherClass,
|
|
33
34
|
) {}
|
|
34
35
|
|
|
36
|
+
setTokenChangeHandler(handler: (() => void) | null): void {
|
|
37
|
+
this.tokenChangeHandler = handler;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private notifyTokensChanged(): void {
|
|
41
|
+
try {
|
|
42
|
+
this.tokenChangeHandler?.();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.warn(`token change handler failed: ${(error as Error).message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
async start(): Promise<boolean> {
|
|
36
49
|
this.shutdownRequested = false;
|
|
37
50
|
this.ensureReconcileTimer();
|
|
@@ -157,6 +170,10 @@ export class TokenSubscriber {
|
|
|
157
170
|
|
|
158
171
|
private async handleNotificationNow(payloadJson: string): Promise<void> {
|
|
159
172
|
const payload = JSON.parse(payloadJson) as Payload;
|
|
173
|
+
const previousRow = this.tokenChangeHandler
|
|
174
|
+
? this.dispatcher.tokens.get(payload.id)
|
|
175
|
+
: undefined;
|
|
176
|
+
const wasKeepaliveTarget = previousRow?.active === true && previousRow.provider === "anthropic";
|
|
160
177
|
if (payload.op === "DELETE") {
|
|
161
178
|
this.dispatcher.removeCache(payload.id);
|
|
162
179
|
await this.dispatcher.openaiDispatcher
|
|
@@ -165,6 +182,7 @@ export class TokenSubscriber {
|
|
|
165
182
|
// anthropic 토큰 이벤트는 동기(void) — provider 를 모르므로 무해하게 항상 제거 시도.
|
|
166
183
|
this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
|
|
167
184
|
logger.info(`NOTIFY ${payload.op} id=${payload.id} → removed from cache`);
|
|
185
|
+
if (previousRow === undefined || wasKeepaliveTarget) this.notifyTokensChanged();
|
|
168
186
|
return;
|
|
169
187
|
}
|
|
170
188
|
const row = await TokenModel.findOne("A", { id: payload.id });
|
|
@@ -175,6 +193,7 @@ export class TokenSubscriber {
|
|
|
175
193
|
.catch((e) => logger.warn(`openai token remove failed: ${(e as Error).message}`));
|
|
176
194
|
this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
|
|
177
195
|
logger.info(`NOTIFY ${payload.op} id=${payload.id} → missing, removed from cache`);
|
|
196
|
+
if (previousRow === undefined || wasKeepaliveTarget) this.notifyTokensChanged();
|
|
178
197
|
return;
|
|
179
198
|
}
|
|
180
199
|
|
|
@@ -236,6 +255,8 @@ export class TokenSubscriber {
|
|
|
236
255
|
}
|
|
237
256
|
}
|
|
238
257
|
logger.info(`NOTIFY ${payload.op} id=${payload.id} (${row.name}) active=${row.active}`);
|
|
258
|
+
const isKeepaliveTarget = row.active && row.provider === "anthropic";
|
|
259
|
+
if (wasKeepaliveTarget !== isKeepaliveTarget) this.notifyTokensChanged();
|
|
239
260
|
}
|
|
240
261
|
|
|
241
262
|
async reconcile(): Promise<void> {
|
|
@@ -243,6 +264,13 @@ export class TokenSubscriber {
|
|
|
243
264
|
}
|
|
244
265
|
|
|
245
266
|
private async reconcileNow(): Promise<void> {
|
|
267
|
+
const previousAnthropicIds = this.tokenChangeHandler
|
|
268
|
+
? new Set(
|
|
269
|
+
[...this.dispatcher.tokens.values()]
|
|
270
|
+
.filter((row) => row.active && row.provider === "anthropic")
|
|
271
|
+
.map((row) => row.id),
|
|
272
|
+
)
|
|
273
|
+
: null;
|
|
246
274
|
const rows = await TokenModel.findActive("A");
|
|
247
275
|
this.dispatcher.replaceCache(rows);
|
|
248
276
|
// NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
|
|
@@ -270,6 +298,13 @@ export class TokenSubscriber {
|
|
|
270
298
|
?.replaceTokens(openaiRows)
|
|
271
299
|
.catch((e) => logger.warn(`openai reconcile failed: ${(e as Error).message}`));
|
|
272
300
|
this.lastReconcileAt = new Date();
|
|
301
|
+
if (
|
|
302
|
+
previousAnthropicIds &&
|
|
303
|
+
(previousAnthropicIds.size !== anthropicRows.length ||
|
|
304
|
+
anthropicRows.some((row) => !previousAnthropicIds.has(row.id)))
|
|
305
|
+
) {
|
|
306
|
+
this.notifyTokensChanged();
|
|
307
|
+
}
|
|
273
308
|
}
|
|
274
309
|
|
|
275
310
|
private enqueueOperation(operation: () => Promise<void>): Promise<void> {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
KEEPALIVE_MODEL,
|
|
5
|
+
KEEPALIVE_PROJECT_NAME,
|
|
6
|
+
POST_KEEPALIVE_USAGE_DELAY_MS,
|
|
7
|
+
rescheduleTokenWindowKeepalive,
|
|
8
|
+
startTokenWindowKeepalive,
|
|
9
|
+
stopTokenWindowKeepalive,
|
|
10
|
+
type TokenWindowKeepaliveDeps,
|
|
11
|
+
} from "./token-window-keepalive";
|
|
12
|
+
|
|
13
|
+
const NOW = Date.parse("2026-08-19T00:00:00.000Z");
|
|
14
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000;
|
|
15
|
+
|
|
16
|
+
function token(
|
|
17
|
+
id: number,
|
|
18
|
+
overrides: Partial<{ name: string; provider: string; active: boolean }> = {},
|
|
19
|
+
) {
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name: `anthropic/token-${id}`,
|
|
23
|
+
provider: "anthropic",
|
|
24
|
+
active: true,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function usage(resetsAt: string | null) {
|
|
30
|
+
return {
|
|
31
|
+
provider: "anthropic",
|
|
32
|
+
fiveHour: { utilization: 0, resetsAt },
|
|
33
|
+
sevenDay: null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function deps(overrides: Partial<TokenWindowKeepaliveDeps> = {}): TokenWindowKeepaliveDeps {
|
|
38
|
+
return {
|
|
39
|
+
findTokens: vi.fn(async () => [token(1)]),
|
|
40
|
+
readUsage: vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString())),
|
|
41
|
+
dispatch: vi.fn(async () => undefined),
|
|
42
|
+
readSetting: () => "true",
|
|
43
|
+
now: () => Date.now(),
|
|
44
|
+
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
45
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("token window keepalive", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
vi.setSystemTime(NOW);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
stopTokenWindowKeepalive();
|
|
58
|
+
vi.useRealTimers();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resetsAt 이 없으면 지정 토큰·최저가 모델·전용 project label 로 즉시 발사한다", async () => {
|
|
62
|
+
const readUsage = vi
|
|
63
|
+
.fn()
|
|
64
|
+
.mockResolvedValueOnce(usage(null))
|
|
65
|
+
.mockResolvedValueOnce(usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
66
|
+
const dispatch = vi.fn(async () => undefined);
|
|
67
|
+
|
|
68
|
+
await startTokenWindowKeepalive(deps({ readUsage, dispatch }));
|
|
69
|
+
|
|
70
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
71
|
+
prompt: "Reply OK.",
|
|
72
|
+
model: KEEPALIVE_MODEL,
|
|
73
|
+
effort: "low",
|
|
74
|
+
projectName: KEEPALIVE_PROJECT_NAME,
|
|
75
|
+
preferredTokenId: 1,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
79
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
80
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("resetsAt 이 과거면 즉시 발사한다", async () => {
|
|
84
|
+
const dispatch = vi.fn(async () => undefined);
|
|
85
|
+
await startTokenWindowKeepalive(
|
|
86
|
+
deps({
|
|
87
|
+
readUsage: vi.fn(async () => usage(new Date(NOW - 1).toISOString())),
|
|
88
|
+
dispatch,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("resetsAt 이 미래면 그 시각 직후까지 발사하지 않는다", async () => {
|
|
96
|
+
const dispatch = vi.fn(async () => undefined);
|
|
97
|
+
await startTokenWindowKeepalive(
|
|
98
|
+
deps({
|
|
99
|
+
readUsage: vi.fn(async () => usage(new Date(NOW + 10_000).toISOString())),
|
|
100
|
+
dispatch,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await vi.advanceTimersByTimeAsync(10_999);
|
|
105
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
106
|
+
|
|
107
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
108
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("비활성 토큰과 Anthropic 이 아닌 토큰은 대상에서 제외한다", async () => {
|
|
112
|
+
const readUsage = vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
113
|
+
await startTokenWindowKeepalive(
|
|
114
|
+
deps({
|
|
115
|
+
findTokens: vi.fn(async () => [
|
|
116
|
+
token(1),
|
|
117
|
+
token(2, { provider: "openai" }),
|
|
118
|
+
token(3, { active: false }),
|
|
119
|
+
]),
|
|
120
|
+
readUsage,
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(readUsage).toHaveBeenCalledTimes(1);
|
|
125
|
+
expect(readUsage).toHaveBeenCalledWith(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("한 토큰의 usage 실패가 다른 토큰 예약을 막지 않는다", async () => {
|
|
129
|
+
const readUsage = vi.fn(async (tokenId: number) => {
|
|
130
|
+
if (tokenId === 1) throw new Error("usage unavailable");
|
|
131
|
+
return usage(new Date(NOW + FIVE_HOURS_MS).toISOString());
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
startTokenWindowKeepalive(
|
|
136
|
+
deps({ findTokens: vi.fn(async () => [token(1), token(2)]), readUsage }),
|
|
137
|
+
),
|
|
138
|
+
).resolves.toBeUndefined();
|
|
139
|
+
|
|
140
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
141
|
+
expect(vi.getTimerCount()).toBe(2);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("발사 실패는 밖으로 전파하지 않고 5시간 안에 재시도하지 않는다", async () => {
|
|
145
|
+
const dispatch = vi.fn(async () => {
|
|
146
|
+
throw new Error("provider unavailable");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await expect(
|
|
150
|
+
startTokenWindowKeepalive(
|
|
151
|
+
deps({ readUsage: vi.fn(async () => usage(null)), dispatch }),
|
|
152
|
+
),
|
|
153
|
+
).resolves.toBeUndefined();
|
|
154
|
+
|
|
155
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
156
|
+
await vi.advanceTimersByTimeAsync(FIVE_HOURS_MS - 1);
|
|
157
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("발사 뒤 usage 에 아직 새 윈도우가 안 보여도 즉시 다시 발사하지 않는다", async () => {
|
|
161
|
+
const dispatch = vi.fn(async () => undefined);
|
|
162
|
+
await startTokenWindowKeepalive(
|
|
163
|
+
deps({ readUsage: vi.fn(async () => usage(null)), dispatch }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
167
|
+
|
|
168
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("발사 후 61초 대기 중 재스케줄되어도 같은 토큰을 다시 발사하지 않는다", async () => {
|
|
173
|
+
const readUsage = vi
|
|
174
|
+
.fn()
|
|
175
|
+
.mockResolvedValueOnce(usage(null))
|
|
176
|
+
.mockResolvedValueOnce(usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
177
|
+
const dispatch = vi.fn(async () => undefined);
|
|
178
|
+
const testDeps = deps({ readUsage, dispatch });
|
|
179
|
+
|
|
180
|
+
await startTokenWindowKeepalive(testDeps);
|
|
181
|
+
await rescheduleTokenWindowKeepalive(testDeps);
|
|
182
|
+
|
|
183
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(readUsage).toHaveBeenCalledTimes(1);
|
|
185
|
+
|
|
186
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
187
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
188
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("발사 실패 후 재스케줄되어도 5시간 안에 다시 발사하지 않는다", async () => {
|
|
192
|
+
const dispatch = vi.fn(async () => {
|
|
193
|
+
throw new Error("provider unavailable");
|
|
194
|
+
});
|
|
195
|
+
const testDeps = deps({ readUsage: vi.fn(async () => usage(null)), dispatch });
|
|
196
|
+
|
|
197
|
+
await startTokenWindowKeepalive(testDeps);
|
|
198
|
+
await rescheduleTokenWindowKeepalive(testDeps);
|
|
199
|
+
await vi.advanceTimersByTimeAsync(FIVE_HOURS_MS - 1);
|
|
200
|
+
|
|
201
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("스위치가 꺼져 있으면 조회나 타이머를 시작하지 않는다", async () => {
|
|
205
|
+
const testDeps = deps({ readSetting: () => "false" });
|
|
206
|
+
|
|
207
|
+
await startTokenWindowKeepalive(testDeps);
|
|
208
|
+
|
|
209
|
+
expect(testDeps.findTokens).not.toHaveBeenCalled();
|
|
210
|
+
expect(testDeps.readUsage).not.toHaveBeenCalled();
|
|
211
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("실행 중 스위치를 끄고 재예약하면 기존 타이머를 정리한다", async () => {
|
|
215
|
+
const testDeps = deps();
|
|
216
|
+
await startTokenWindowKeepalive(testDeps);
|
|
217
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
218
|
+
|
|
219
|
+
await rescheduleTokenWindowKeepalive({ ...testDeps, readSetting: () => "false" });
|
|
220
|
+
|
|
221
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
|
|
3
|
+
import { getSetting } from "../setting/setting.store";
|
|
4
|
+
import { TokenModel } from "../token/token.model";
|
|
5
|
+
import { type InternalQueryInput } from "./qgrid.dispatcher";
|
|
6
|
+
import { QgridFrame } from "./qgrid.frame";
|
|
7
|
+
import { type UsageResponse } from "./qgrid.types";
|
|
8
|
+
|
|
9
|
+
const logger = getLogger(["qgrid", "token-window-keepalive"]);
|
|
10
|
+
|
|
11
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000;
|
|
12
|
+
const RESET_GRACE_MS = 1_000;
|
|
13
|
+
|
|
14
|
+
// usage API 성공 응답은 1분 캐시된다. keepalive 직후 즉시 재조회하면 이전 윈도우를
|
|
15
|
+
// 다시 보므로 캐시가 지난 뒤 한 번 확인하고, 그때도 새 윈도우가 없으면 다음 5h까지 기다린다.
|
|
16
|
+
export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
|
|
17
|
+
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
|
|
18
|
+
export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
|
|
19
|
+
export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
|
|
20
|
+
export const KEEPALIVE_ENV_KEY = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
|
|
21
|
+
|
|
22
|
+
type KeepaliveToken = {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
provider: string;
|
|
26
|
+
active: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
30
|
+
|
|
31
|
+
export type TokenWindowKeepaliveDeps = {
|
|
32
|
+
findTokens: () => Promise<KeepaliveToken[]>;
|
|
33
|
+
readUsage: (tokenId: number) => Promise<UsageResponse>;
|
|
34
|
+
dispatch: (input: InternalQueryInput) => Promise<unknown>;
|
|
35
|
+
readSetting: typeof getSetting;
|
|
36
|
+
now: () => number;
|
|
37
|
+
setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
|
38
|
+
clearTimer: (handle: TimerHandle) => void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const defaultDeps: TokenWindowKeepaliveDeps = {
|
|
42
|
+
findTokens: () => TokenModel.findActiveByProvider("A", "anthropic"),
|
|
43
|
+
readUsage: (tokenId) => QgridFrame.usage(tokenId),
|
|
44
|
+
dispatch: (input) => QgridFrame.query(input),
|
|
45
|
+
readSetting: getSetting,
|
|
46
|
+
now: () => Date.now(),
|
|
47
|
+
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
48
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const timers = new Map<number, { handle: TimerHandle; clear: (handle: TimerHandle) => void }>();
|
|
52
|
+
const fireGuards = new Map<number, { untilMs: number; allowFireAtExpiry: boolean }>();
|
|
53
|
+
let generation = 0;
|
|
54
|
+
|
|
55
|
+
function clearTokenTimer(tokenId: number): void {
|
|
56
|
+
const timer = timers.get(tokenId);
|
|
57
|
+
if (!timer) return;
|
|
58
|
+
timer.clear(timer.handle);
|
|
59
|
+
timers.delete(tokenId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scheduleToken(
|
|
63
|
+
token: KeepaliveToken,
|
|
64
|
+
deps: TokenWindowKeepaliveDeps,
|
|
65
|
+
expectedGeneration: number,
|
|
66
|
+
delayMs: number,
|
|
67
|
+
allowFire: boolean,
|
|
68
|
+
): void {
|
|
69
|
+
if (generation !== expectedGeneration) return;
|
|
70
|
+
clearTokenTimer(token.id);
|
|
71
|
+
const handle = deps.setTimer(
|
|
72
|
+
() => {
|
|
73
|
+
timers.delete(token.id);
|
|
74
|
+
if (generation !== expectedGeneration) return;
|
|
75
|
+
void inspectToken(token, deps, expectedGeneration, allowFire).catch((error) => {
|
|
76
|
+
logger.warn(`keepalive cycle failed for ${token.name}: ${(error as Error).message}`);
|
|
77
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
Math.max(0, delayMs),
|
|
81
|
+
);
|
|
82
|
+
timers.set(token.id, { handle, clear: deps.clearTimer });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deferToken(
|
|
86
|
+
token: KeepaliveToken,
|
|
87
|
+
deps: TokenWindowKeepaliveDeps,
|
|
88
|
+
expectedGeneration: number,
|
|
89
|
+
delayMs: number,
|
|
90
|
+
allowFireAtExpiry: boolean,
|
|
91
|
+
): void {
|
|
92
|
+
const untilMs = deps.now() + delayMs;
|
|
93
|
+
fireGuards.set(token.id, { untilMs, allowFireAtExpiry });
|
|
94
|
+
scheduleToken(token, deps, expectedGeneration, delayMs, allowFireAtExpiry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scheduleFallback(
|
|
98
|
+
token: KeepaliveToken,
|
|
99
|
+
deps: TokenWindowKeepaliveDeps,
|
|
100
|
+
expectedGeneration: number,
|
|
101
|
+
): void {
|
|
102
|
+
deferToken(token, deps, expectedGeneration, FIVE_HOURS_MS, true);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function inspectToken(
|
|
106
|
+
token: KeepaliveToken,
|
|
107
|
+
deps: TokenWindowKeepaliveDeps,
|
|
108
|
+
expectedGeneration: number,
|
|
109
|
+
allowFire: boolean,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const now = deps.now();
|
|
112
|
+
const guard = fireGuards.get(token.id);
|
|
113
|
+
if (guard && guard.untilMs > now) {
|
|
114
|
+
scheduleToken(token, deps, expectedGeneration, guard.untilMs - now, guard.allowFireAtExpiry);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (guard) fireGuards.delete(token.id);
|
|
118
|
+
|
|
119
|
+
let usage: UsageResponse;
|
|
120
|
+
try {
|
|
121
|
+
usage = await deps.readUsage(token.id);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
logger.warn(`usage lookup failed for ${token.name}: ${(error as Error).message}`);
|
|
124
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (generation !== expectedGeneration) return;
|
|
128
|
+
|
|
129
|
+
if (usage.error) {
|
|
130
|
+
logger.warn(`usage lookup failed for ${token.name}: ${usage.error}`);
|
|
131
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resetsAt = usage.fiveHour?.resetsAt;
|
|
136
|
+
if (resetsAt) {
|
|
137
|
+
const resetMs = Date.parse(resetsAt);
|
|
138
|
+
if (!Number.isFinite(resetMs)) {
|
|
139
|
+
logger.warn(`invalid five-hour reset for ${token.name}: ${resetsAt}`);
|
|
140
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const inspectedAt = deps.now();
|
|
144
|
+
if (resetMs > inspectedAt) {
|
|
145
|
+
fireGuards.delete(token.id);
|
|
146
|
+
scheduleToken(token, deps, expectedGeneration, resetMs - inspectedAt + RESET_GRACE_MS, true);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 발사 직후 확인에서는 캐시된 null/과거 reset을 봐도 다시 발사하지 않는다.
|
|
152
|
+
if (!allowFire) {
|
|
153
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
deferToken(token, deps, expectedGeneration, POST_KEEPALIVE_USAGE_DELAY_MS, false);
|
|
158
|
+
try {
|
|
159
|
+
await deps.dispatch({
|
|
160
|
+
prompt: "Reply OK.",
|
|
161
|
+
model: KEEPALIVE_MODEL,
|
|
162
|
+
effort: "low",
|
|
163
|
+
projectName: KEEPALIVE_PROJECT_NAME,
|
|
164
|
+
preferredTokenId: token.id,
|
|
165
|
+
});
|
|
166
|
+
logger.info(`keepalive sent for ${token.name}`);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.warn(`keepalive failed for ${token.name}: ${(error as Error).message}`);
|
|
169
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const postFireGuard = fireGuards.get(token.id);
|
|
174
|
+
if (postFireGuard) {
|
|
175
|
+
scheduleToken(
|
|
176
|
+
token,
|
|
177
|
+
deps,
|
|
178
|
+
expectedGeneration,
|
|
179
|
+
Math.max(0, postFireGuard.untilMs - deps.now()),
|
|
180
|
+
postFireGuard.allowFireAtExpiry,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function clearScheduledTimers(): void {
|
|
186
|
+
generation++;
|
|
187
|
+
for (const tokenId of timers.keys()) clearTokenTimer(tokenId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function startTokenWindowKeepalive(
|
|
191
|
+
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
clearScheduledTimers();
|
|
194
|
+
if (deps.readSetting(KEEPALIVE_SETTING_KEY, KEEPALIVE_ENV_KEY) === "false") {
|
|
195
|
+
logger.info("token window keepalive disabled");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const expectedGeneration = generation;
|
|
199
|
+
|
|
200
|
+
let tokens: KeepaliveToken[];
|
|
201
|
+
try {
|
|
202
|
+
tokens = await deps.findTokens();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.warn(`token discovery failed: ${(error as Error).message}`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (generation !== expectedGeneration) return;
|
|
208
|
+
|
|
209
|
+
const targets = tokens.filter((token) => token.active && token.provider === "anthropic");
|
|
210
|
+
const targetIds = new Set(targets.map((token) => token.id));
|
|
211
|
+
for (const tokenId of fireGuards.keys()) {
|
|
212
|
+
if (!targetIds.has(tokenId)) fireGuards.delete(tokenId);
|
|
213
|
+
}
|
|
214
|
+
logger.info(`token window keepalive scheduling ${targets.length} token(s)`);
|
|
215
|
+
await Promise.all(targets.map((token) => inspectToken(token, deps, expectedGeneration, true)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function rescheduleTokenWindowKeepalive(
|
|
219
|
+
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
return startTokenWindowKeepalive(deps);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function stopTokenWindowKeepalive(): void {
|
|
225
|
+
clearScheduledTimers();
|
|
226
|
+
fireGuards.clear();
|
|
227
|
+
}
|
|
@@ -73,6 +73,16 @@ describe("maskSecret", () => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
describe("SETTING_DEFS", () => {
|
|
76
|
+
it("token window keepalive 비상 스위치를 즉시 적용 설정으로 등록한다", () => {
|
|
77
|
+
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")).toMatchObject({
|
|
78
|
+
group: "qgrid",
|
|
79
|
+
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
80
|
+
kind: "boolean",
|
|
81
|
+
applies: "immediate",
|
|
82
|
+
fallback: "true",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
76
86
|
it("키가 중복되지 않는다", () => {
|
|
77
87
|
const keys = SETTING_DEFS.map((d) => d.key);
|
|
78
88
|
expect(new Set(keys).size).toBe(keys.length);
|
|
@@ -17,7 +17,7 @@ export type SettingKind = "boolean" | "integer" | "number" | "string" | "secret"
|
|
|
17
17
|
* Slack 은 "언제 보낼까"(slack)와 "어디로 보낼까"(slackConnection)를 나눈다 — 앞은 운영
|
|
18
18
|
* 중 자주 만지고 뒤는 한 번 맞추면 끝이라 수명이 다르다.
|
|
19
19
|
*/
|
|
20
|
-
export type SettingGroup = "slack" | "slackConnection";
|
|
20
|
+
export type SettingGroup = "qgrid" | "slack" | "slackConnection";
|
|
21
21
|
|
|
22
22
|
export type SettingDef = {
|
|
23
23
|
key: string;
|
|
@@ -40,6 +40,16 @@ export type SettingDef = {
|
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
export const SETTING_DEFS: SettingDef[] = [
|
|
43
|
+
{
|
|
44
|
+
key: "qgrid.tokenWindowKeepaliveEnabled",
|
|
45
|
+
fallback: "true",
|
|
46
|
+
group: "qgrid",
|
|
47
|
+
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
48
|
+
label: "토큰 윈도우 keepalive",
|
|
49
|
+
kind: "boolean",
|
|
50
|
+
applies: "immediate",
|
|
51
|
+
help: "끄면 활성 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
|
|
52
|
+
},
|
|
43
53
|
{
|
|
44
54
|
key: "slack.enabled",
|
|
45
55
|
fallback: "true",
|