@cartanova/qgrid-cli 2.7.5 → 2.7.7
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 +31 -13
- 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-trigger-setup.js +3 -2
- package/bundle/dist/application/qgrid/token-window-keepalive.js +159 -0
- package/bundle/dist/application/setting/setting.constant.js +16 -2
- package/bundle/dist/application/setting/setting.model.js +4 -3
- package/bundle/dist/application/setting/setting.store.js +2 -2
- package/bundle/dist/application/setting/setting.types.js +4 -2
- package/bundle/dist/application/sonamu.generated.js +5 -3
- package/bundle/dist/application/sonamu.generated.sso.js +3 -2
- package/bundle/dist/application/token/token.model.js +24 -2
- package/bundle/dist/application/token/token.types.js +3 -2
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260819102011_alter_tokens_alter1.js +13 -0
- package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -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/dist/utils/providers/openai/openai-quota.js +4 -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 +67 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +41 -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-trigger-setup.test.ts +6 -0
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
- package/bundle/src/application/qgrid/token-window-keepalive.test.ts +244 -0
- package/bundle/src/application/qgrid/token-window-keepalive.ts +237 -0
- package/bundle/src/application/setting/setting.constant.test.ts +11 -1
- package/bundle/src/application/setting/setting.constant.ts +17 -2
- package/bundle/src/application/setting/setting.model.ts +13 -2
- package/bundle/src/application/setting/setting.store.ts +2 -2
- package/bundle/src/application/setting/setting.types.ts +2 -0
- package/bundle/src/application/sonamu.generated.http +4 -1
- package/bundle/src/application/sonamu.generated.sso.ts +1 -0
- package/bundle/src/application/sonamu.generated.ts +3 -0
- package/bundle/src/application/token/token.entity.json +16 -2
- package/bundle/src/application/token/token.model.test.ts +66 -0
- package/bundle/src/application/token/token.model.ts +30 -1
- package/bundle/src/application/token/token.types.test.ts +10 -0
- package/bundle/src/application/token/token.types.ts +1 -0
- package/bundle/src/i18n/sd.generated.ts +1 -0
- package/bundle/src/migrations/20260819102011_alter_tokens_alter1.ts +13 -0
- package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -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/src/utils/providers/openai/openai-quota.test.ts +31 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
- package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
- package/bundle/web-dist/client/assets/index-CokxkK2G.js +106 -0
- package/bundle/web-dist/client/assets/{logs-BrAu0koY.js → logs-Ckqp98Y3.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-D6JEk7n7.js → monit-u0pMHbyf.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-Dvu63qre.js → routes-DobxbCAV.js} +2 -2
- package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
- package/bundle/web-dist/client/assets/settings-C8rfyi9z.js +1 -0
- package/bundle/web-dist/client/assets/{show-BFGjYsWb.js → show-FbUQYs4L.js} +1 -1
- package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +1 -0
- package/bundle/web-dist/client/assets/use-oauth-login-flow-D-9h3WUt.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
- package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
- package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
- package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
- package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
- package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-B9X0lvjQ.js} +5 -4
- package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
- package/bundle/web-dist/server/assets/{settings-nCv71sax.js → settings-JznSjwGQ.js} +80 -5
- package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
- package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-CyItE-L-.js} +4 -3
- package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-CItVKngs.js} +2 -7
- package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
- package/bundle/web-dist/server/entry-server.generated.js +103 -58
- package/package.json +1 -1
- package/skills/qgrid/references/cli-env-and-server-boot.md +1 -0
- package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +25 -1
- package/bundle/web-dist/client/assets/index-CwSSXuZv.js +0 -106
- package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
- package/bundle/web-dist/client/assets/settings-mwDGFwUi.js +0 -1
- package/bundle/web-dist/client/assets/tokens-D-P__jYk.js +0 -1
- package/bundle/web-dist/client/assets/use-oauth-login-flow-CJpoJD26.js +0 -1
- /package/bundle/web-dist/client/assets/{plus-DqBd6gHk.js → plus-p4vtg6LN.js} +0 -0
|
@@ -0,0 +1,244 @@
|
|
|
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<{
|
|
19
|
+
name: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
active: boolean;
|
|
22
|
+
keepalive_enabled: boolean;
|
|
23
|
+
}> = {},
|
|
24
|
+
) {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
name: `anthropic/token-${id}`,
|
|
28
|
+
provider: "anthropic",
|
|
29
|
+
active: true,
|
|
30
|
+
keepalive_enabled: true,
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function usage(resetsAt: string | null) {
|
|
36
|
+
return {
|
|
37
|
+
provider: "anthropic",
|
|
38
|
+
fiveHour: { utilization: 0, resetsAt },
|
|
39
|
+
sevenDay: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function deps(overrides: Partial<TokenWindowKeepaliveDeps> = {}): TokenWindowKeepaliveDeps {
|
|
44
|
+
return {
|
|
45
|
+
findTokens: vi.fn(async () => [token(1)]),
|
|
46
|
+
readUsage: vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString())),
|
|
47
|
+
dispatch: vi.fn(async () => undefined),
|
|
48
|
+
readSetting: () => "true",
|
|
49
|
+
isRunnerEnabled: () => true,
|
|
50
|
+
now: () => Date.now(),
|
|
51
|
+
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
52
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("token window keepalive", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
vi.setSystemTime(NOW);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
stopTokenWindowKeepalive();
|
|
65
|
+
vi.useRealTimers();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("resetsAt 이 없으면 지정 토큰·최저가 모델·전용 project label 로 즉시 발사한다", async () => {
|
|
69
|
+
const readUsage = vi
|
|
70
|
+
.fn()
|
|
71
|
+
.mockResolvedValueOnce(usage(null))
|
|
72
|
+
.mockResolvedValueOnce(usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
73
|
+
const dispatch = vi.fn(async () => undefined);
|
|
74
|
+
|
|
75
|
+
await startTokenWindowKeepalive(deps({ readUsage, dispatch }));
|
|
76
|
+
|
|
77
|
+
expect(dispatch).toHaveBeenCalledWith({
|
|
78
|
+
prompt: "Reply OK.",
|
|
79
|
+
model: KEEPALIVE_MODEL,
|
|
80
|
+
effort: "low",
|
|
81
|
+
projectName: KEEPALIVE_PROJECT_NAME,
|
|
82
|
+
preferredTokenId: 1,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
86
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
87
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("resetsAt 이 과거면 즉시 발사한다", async () => {
|
|
91
|
+
const dispatch = vi.fn(async () => undefined);
|
|
92
|
+
await startTokenWindowKeepalive(
|
|
93
|
+
deps({
|
|
94
|
+
readUsage: vi.fn(async () => usage(new Date(NOW - 1).toISOString())),
|
|
95
|
+
dispatch,
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resetsAt 이 미래면 그 시각 직후까지 발사하지 않는다", async () => {
|
|
103
|
+
const dispatch = vi.fn(async () => undefined);
|
|
104
|
+
await startTokenWindowKeepalive(
|
|
105
|
+
deps({
|
|
106
|
+
readUsage: vi.fn(async () => usage(new Date(NOW + 10_000).toISOString())),
|
|
107
|
+
dispatch,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await vi.advanceTimersByTimeAsync(10_999);
|
|
112
|
+
expect(dispatch).not.toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
115
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("비활성·비Anthropic·keepalive off 토큰은 대상에서 제외한다", async () => {
|
|
119
|
+
const readUsage = vi.fn(async () => usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
120
|
+
await startTokenWindowKeepalive(
|
|
121
|
+
deps({
|
|
122
|
+
findTokens: vi.fn(async () => [
|
|
123
|
+
token(1),
|
|
124
|
+
token(2, { provider: "openai" }),
|
|
125
|
+
token(3, { active: false }),
|
|
126
|
+
token(4, { keepalive_enabled: false }),
|
|
127
|
+
]),
|
|
128
|
+
readUsage,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(readUsage).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(readUsage).toHaveBeenCalledWith(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("한 토큰의 usage 실패가 다른 토큰 예약을 막지 않는다", async () => {
|
|
137
|
+
const readUsage = vi.fn(async (tokenId: number) => {
|
|
138
|
+
if (tokenId === 1) throw new Error("usage unavailable");
|
|
139
|
+
return usage(new Date(NOW + FIVE_HOURS_MS).toISOString());
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await expect(
|
|
143
|
+
startTokenWindowKeepalive(
|
|
144
|
+
deps({ findTokens: vi.fn(async () => [token(1), token(2)]), readUsage }),
|
|
145
|
+
),
|
|
146
|
+
).resolves.toBeUndefined();
|
|
147
|
+
|
|
148
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
149
|
+
expect(vi.getTimerCount()).toBe(2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("발사 실패는 밖으로 전파하지 않고 5시간 안에 재시도하지 않는다", async () => {
|
|
153
|
+
const dispatch = vi.fn(async () => {
|
|
154
|
+
throw new Error("provider unavailable");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
startTokenWindowKeepalive(
|
|
159
|
+
deps({ readUsage: vi.fn(async () => usage(null)), dispatch }),
|
|
160
|
+
),
|
|
161
|
+
).resolves.toBeUndefined();
|
|
162
|
+
|
|
163
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
164
|
+
await vi.advanceTimersByTimeAsync(FIVE_HOURS_MS - 1);
|
|
165
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("발사 뒤 usage 에 아직 새 윈도우가 안 보여도 즉시 다시 발사하지 않는다", async () => {
|
|
169
|
+
const dispatch = vi.fn(async () => undefined);
|
|
170
|
+
await startTokenWindowKeepalive(
|
|
171
|
+
deps({ readUsage: vi.fn(async () => usage(null)), dispatch }),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
175
|
+
|
|
176
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("발사 후 61초 대기 중 재스케줄되어도 같은 토큰을 다시 발사하지 않는다", async () => {
|
|
181
|
+
const readUsage = vi
|
|
182
|
+
.fn()
|
|
183
|
+
.mockResolvedValueOnce(usage(null))
|
|
184
|
+
.mockResolvedValueOnce(usage(new Date(NOW + FIVE_HOURS_MS).toISOString()));
|
|
185
|
+
const dispatch = vi.fn(async () => undefined);
|
|
186
|
+
const testDeps = deps({ readUsage, dispatch });
|
|
187
|
+
|
|
188
|
+
await startTokenWindowKeepalive(testDeps);
|
|
189
|
+
await rescheduleTokenWindowKeepalive(testDeps);
|
|
190
|
+
|
|
191
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
192
|
+
expect(readUsage).toHaveBeenCalledTimes(1);
|
|
193
|
+
|
|
194
|
+
await vi.advanceTimersByTimeAsync(POST_KEEPALIVE_USAGE_DELAY_MS);
|
|
195
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(readUsage).toHaveBeenCalledTimes(2);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("발사 실패 후 재스케줄되어도 5시간 안에 다시 발사하지 않는다", async () => {
|
|
200
|
+
const dispatch = vi.fn(async () => {
|
|
201
|
+
throw new Error("provider unavailable");
|
|
202
|
+
});
|
|
203
|
+
const testDeps = deps({ readUsage: vi.fn(async () => usage(null)), dispatch });
|
|
204
|
+
|
|
205
|
+
await startTokenWindowKeepalive(testDeps);
|
|
206
|
+
await rescheduleTokenWindowKeepalive(testDeps);
|
|
207
|
+
await vi.advanceTimersByTimeAsync(FIVE_HOURS_MS - 1);
|
|
208
|
+
|
|
209
|
+
expect(dispatch).toHaveBeenCalledTimes(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("스위치가 꺼져 있으면 조회나 타이머를 시작하지 않는다", async () => {
|
|
213
|
+
const testDeps = deps({ readSetting: () => "false" });
|
|
214
|
+
|
|
215
|
+
await startTokenWindowKeepalive(testDeps);
|
|
216
|
+
|
|
217
|
+
expect(testDeps.findTokens).not.toHaveBeenCalled();
|
|
218
|
+
expect(testDeps.readUsage).not.toHaveBeenCalled();
|
|
219
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("이 인스턴스가 runner로 지정되지 않으면 전역 스위치와 무관하게 시작하지 않는다", async () => {
|
|
223
|
+
const testDeps = deps({
|
|
224
|
+
isRunnerEnabled: () => false,
|
|
225
|
+
readSetting: vi.fn(() => "true"),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await startTokenWindowKeepalive(testDeps);
|
|
229
|
+
|
|
230
|
+
expect(testDeps.findTokens).not.toHaveBeenCalled();
|
|
231
|
+
expect(testDeps.readSetting).not.toHaveBeenCalled();
|
|
232
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("실행 중 스위치를 끄고 재예약하면 기존 타이머를 정리한다", async () => {
|
|
236
|
+
const testDeps = deps();
|
|
237
|
+
await startTokenWindowKeepalive(testDeps);
|
|
238
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
239
|
+
|
|
240
|
+
await rescheduleTokenWindowKeepalive({ ...testDeps, readSetting: () => "false" });
|
|
241
|
+
|
|
242
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { getLogger } from "@logtape/logtape";
|
|
2
|
+
|
|
3
|
+
import { TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY } from "../setting/setting.constant";
|
|
4
|
+
import { getSetting } from "../setting/setting.store";
|
|
5
|
+
import { TokenModel } from "../token/token.model";
|
|
6
|
+
import { type InternalQueryInput } from "./qgrid.dispatcher";
|
|
7
|
+
import { QgridFrame } from "./qgrid.frame";
|
|
8
|
+
import { type UsageResponse } from "./qgrid.types";
|
|
9
|
+
|
|
10
|
+
const logger = getLogger(["qgrid", "token-window-keepalive"]);
|
|
11
|
+
|
|
12
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1_000;
|
|
13
|
+
const RESET_GRACE_MS = 1_000;
|
|
14
|
+
|
|
15
|
+
// usage API 성공 응답은 1분 캐시된다. keepalive 직후 즉시 재조회하면 이전 윈도우를
|
|
16
|
+
// 다시 보므로 캐시가 지난 뒤 한 번 확인하고, 그때도 새 윈도우가 없으면 다음 5h까지 기다린다.
|
|
17
|
+
export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
|
|
18
|
+
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
|
|
19
|
+
export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
|
|
20
|
+
export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
|
|
21
|
+
export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
|
|
22
|
+
|
|
23
|
+
type KeepaliveToken = {
|
|
24
|
+
id: number;
|
|
25
|
+
name: string;
|
|
26
|
+
provider: string;
|
|
27
|
+
active: boolean;
|
|
28
|
+
keepalive_enabled: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
32
|
+
|
|
33
|
+
export type TokenWindowKeepaliveDeps = {
|
|
34
|
+
findTokens: () => Promise<KeepaliveToken[]>;
|
|
35
|
+
readUsage: (tokenId: number) => Promise<UsageResponse>;
|
|
36
|
+
dispatch: (input: InternalQueryInput) => Promise<unknown>;
|
|
37
|
+
readSetting: typeof getSetting;
|
|
38
|
+
isRunnerEnabled: () => boolean;
|
|
39
|
+
now: () => number;
|
|
40
|
+
setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
|
41
|
+
clearTimer: (handle: TimerHandle) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const defaultDeps: TokenWindowKeepaliveDeps = {
|
|
45
|
+
findTokens: () => TokenModel.findActiveByProvider("A", "anthropic"),
|
|
46
|
+
readUsage: (tokenId) => QgridFrame.usage(tokenId),
|
|
47
|
+
dispatch: (input) => QgridFrame.query(input),
|
|
48
|
+
readSetting: getSetting,
|
|
49
|
+
isRunnerEnabled: () => process.env[KEEPALIVE_ENV_KEY] === "true",
|
|
50
|
+
now: () => Date.now(),
|
|
51
|
+
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
52
|
+
clearTimer: (handle) => clearTimeout(handle),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const timers = new Map<number, { handle: TimerHandle; clear: (handle: TimerHandle) => void }>();
|
|
56
|
+
const fireGuards = new Map<number, { untilMs: number; allowFireAtExpiry: boolean }>();
|
|
57
|
+
let generation = 0;
|
|
58
|
+
|
|
59
|
+
function clearTokenTimer(tokenId: number): void {
|
|
60
|
+
const timer = timers.get(tokenId);
|
|
61
|
+
if (!timer) return;
|
|
62
|
+
timer.clear(timer.handle);
|
|
63
|
+
timers.delete(tokenId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scheduleToken(
|
|
67
|
+
token: KeepaliveToken,
|
|
68
|
+
deps: TokenWindowKeepaliveDeps,
|
|
69
|
+
expectedGeneration: number,
|
|
70
|
+
delayMs: number,
|
|
71
|
+
allowFire: boolean,
|
|
72
|
+
): void {
|
|
73
|
+
if (generation !== expectedGeneration) return;
|
|
74
|
+
clearTokenTimer(token.id);
|
|
75
|
+
const handle = deps.setTimer(
|
|
76
|
+
() => {
|
|
77
|
+
timers.delete(token.id);
|
|
78
|
+
if (generation !== expectedGeneration) return;
|
|
79
|
+
void inspectToken(token, deps, expectedGeneration, allowFire).catch((error) => {
|
|
80
|
+
logger.warn(`keepalive cycle failed for ${token.name}: ${(error as Error).message}`);
|
|
81
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
Math.max(0, delayMs),
|
|
85
|
+
);
|
|
86
|
+
timers.set(token.id, { handle, clear: deps.clearTimer });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deferToken(
|
|
90
|
+
token: KeepaliveToken,
|
|
91
|
+
deps: TokenWindowKeepaliveDeps,
|
|
92
|
+
expectedGeneration: number,
|
|
93
|
+
delayMs: number,
|
|
94
|
+
allowFireAtExpiry: boolean,
|
|
95
|
+
): void {
|
|
96
|
+
const untilMs = deps.now() + delayMs;
|
|
97
|
+
fireGuards.set(token.id, { untilMs, allowFireAtExpiry });
|
|
98
|
+
scheduleToken(token, deps, expectedGeneration, delayMs, allowFireAtExpiry);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function scheduleFallback(
|
|
102
|
+
token: KeepaliveToken,
|
|
103
|
+
deps: TokenWindowKeepaliveDeps,
|
|
104
|
+
expectedGeneration: number,
|
|
105
|
+
): void {
|
|
106
|
+
deferToken(token, deps, expectedGeneration, FIVE_HOURS_MS, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function inspectToken(
|
|
110
|
+
token: KeepaliveToken,
|
|
111
|
+
deps: TokenWindowKeepaliveDeps,
|
|
112
|
+
expectedGeneration: number,
|
|
113
|
+
allowFire: boolean,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
const now = deps.now();
|
|
116
|
+
const guard = fireGuards.get(token.id);
|
|
117
|
+
if (guard && guard.untilMs > now) {
|
|
118
|
+
scheduleToken(token, deps, expectedGeneration, guard.untilMs - now, guard.allowFireAtExpiry);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (guard) fireGuards.delete(token.id);
|
|
122
|
+
|
|
123
|
+
let usage: UsageResponse;
|
|
124
|
+
try {
|
|
125
|
+
usage = await deps.readUsage(token.id);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger.warn(`usage lookup failed for ${token.name}: ${(error as Error).message}`);
|
|
128
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (generation !== expectedGeneration) return;
|
|
132
|
+
|
|
133
|
+
if (usage.error) {
|
|
134
|
+
logger.warn(`usage lookup failed for ${token.name}: ${usage.error}`);
|
|
135
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const resetsAt = usage.fiveHour?.resetsAt;
|
|
140
|
+
if (resetsAt) {
|
|
141
|
+
const resetMs = Date.parse(resetsAt);
|
|
142
|
+
if (!Number.isFinite(resetMs)) {
|
|
143
|
+
logger.warn(`invalid five-hour reset for ${token.name}: ${resetsAt}`);
|
|
144
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const inspectedAt = deps.now();
|
|
148
|
+
if (resetMs > inspectedAt) {
|
|
149
|
+
fireGuards.delete(token.id);
|
|
150
|
+
scheduleToken(token, deps, expectedGeneration, resetMs - inspectedAt + RESET_GRACE_MS, true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 발사 직후 확인에서는 캐시된 null/과거 reset을 봐도 다시 발사하지 않는다.
|
|
156
|
+
if (!allowFire) {
|
|
157
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
deferToken(token, deps, expectedGeneration, POST_KEEPALIVE_USAGE_DELAY_MS, false);
|
|
162
|
+
try {
|
|
163
|
+
await deps.dispatch({
|
|
164
|
+
prompt: "Reply OK.",
|
|
165
|
+
model: KEEPALIVE_MODEL,
|
|
166
|
+
effort: "low",
|
|
167
|
+
projectName: KEEPALIVE_PROJECT_NAME,
|
|
168
|
+
preferredTokenId: token.id,
|
|
169
|
+
});
|
|
170
|
+
logger.info(`keepalive sent for ${token.name}`);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
logger.warn(`keepalive failed for ${token.name}: ${(error as Error).message}`);
|
|
173
|
+
scheduleFallback(token, deps, expectedGeneration);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const postFireGuard = fireGuards.get(token.id);
|
|
178
|
+
if (postFireGuard) {
|
|
179
|
+
scheduleToken(
|
|
180
|
+
token,
|
|
181
|
+
deps,
|
|
182
|
+
expectedGeneration,
|
|
183
|
+
Math.max(0, postFireGuard.untilMs - deps.now()),
|
|
184
|
+
postFireGuard.allowFireAtExpiry,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clearScheduledTimers(): void {
|
|
190
|
+
generation++;
|
|
191
|
+
for (const tokenId of timers.keys()) clearTokenTimer(tokenId);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function startTokenWindowKeepalive(
|
|
195
|
+
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
clearScheduledTimers();
|
|
198
|
+
if (!deps.isRunnerEnabled()) {
|
|
199
|
+
logger.info("token window keepalive runner disabled for this instance");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (deps.readSetting(KEEPALIVE_SETTING_KEY) === "false") {
|
|
203
|
+
logger.info("token window keepalive disabled");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const expectedGeneration = generation;
|
|
207
|
+
|
|
208
|
+
let tokens: KeepaliveToken[];
|
|
209
|
+
try {
|
|
210
|
+
tokens = await deps.findTokens();
|
|
211
|
+
} catch (error) {
|
|
212
|
+
logger.warn(`token discovery failed: ${(error as Error).message}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (generation !== expectedGeneration) return;
|
|
216
|
+
|
|
217
|
+
const targets = tokens.filter(
|
|
218
|
+
(token) => token.active && token.provider === "anthropic" && token.keepalive_enabled,
|
|
219
|
+
);
|
|
220
|
+
const targetIds = new Set(targets.map((token) => token.id));
|
|
221
|
+
for (const tokenId of fireGuards.keys()) {
|
|
222
|
+
if (!targetIds.has(tokenId)) fireGuards.delete(tokenId);
|
|
223
|
+
}
|
|
224
|
+
logger.info(`token window keepalive scheduling ${targets.length} token(s)`);
|
|
225
|
+
await Promise.all(targets.map((token) => inspectToken(token, deps, expectedGeneration, true)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function rescheduleTokenWindowKeepalive(
|
|
229
|
+
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
return startTokenWindowKeepalive(deps);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function stopTokenWindowKeepalive(): void {
|
|
235
|
+
clearScheduledTimers();
|
|
236
|
+
fireGuards.clear();
|
|
237
|
+
}
|
|
@@ -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
|
+
kind: "boolean",
|
|
80
|
+
applies: "immediate",
|
|
81
|
+
fallback: "true",
|
|
82
|
+
});
|
|
83
|
+
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")?.envKey).toBeUndefined();
|
|
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);
|
|
@@ -80,7 +90,7 @@ describe("SETTING_DEFS", () => {
|
|
|
80
90
|
|
|
81
91
|
it("env 키도 중복되지 않는다", () => {
|
|
82
92
|
// 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
|
|
83
|
-
const envKeys = SETTING_DEFS.
|
|
93
|
+
const envKeys = SETTING_DEFS.flatMap((d) => (d.envKey ? [d.envKey] : []));
|
|
84
94
|
expect(new Set(envKeys).size).toBe(envKeys.length);
|
|
85
95
|
});
|
|
86
96
|
|
|
@@ -17,12 +17,12 @@ 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;
|
|
24
24
|
group: SettingGroup;
|
|
25
|
-
envKey
|
|
25
|
+
envKey?: string;
|
|
26
26
|
label: string;
|
|
27
27
|
kind: SettingKind;
|
|
28
28
|
/** 적용 시점. 부팅 시 한 번만 읽히는 값은 재시작이 필요하다. */
|
|
@@ -39,7 +39,22 @@ export type SettingDef = {
|
|
|
39
39
|
help?: string;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* keepalive 스케줄러를 실제로 소유할 인스턴스만 켠다. Settings DB 값과 일부러 분리한다 —
|
|
44
|
+
* 같은 DB를 보는 개발자 로컬 인스턴스가 전역 설정을 읽고 함께 발사하면 중복 요청이 된다.
|
|
45
|
+
*/
|
|
46
|
+
export const TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY = "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED";
|
|
47
|
+
|
|
42
48
|
export const SETTING_DEFS: SettingDef[] = [
|
|
49
|
+
{
|
|
50
|
+
key: "qgrid.tokenWindowKeepaliveEnabled",
|
|
51
|
+
fallback: "true",
|
|
52
|
+
group: "qgrid",
|
|
53
|
+
label: "토큰 윈도우 keepalive",
|
|
54
|
+
kind: "boolean",
|
|
55
|
+
applies: "immediate",
|
|
56
|
+
help: "끄면 이 서버에서 선택한 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
|
|
57
|
+
},
|
|
43
58
|
{
|
|
44
59
|
key: "slack.enabled",
|
|
45
60
|
fallback: "true",
|
|
@@ -16,7 +16,13 @@ import { detectSupervisor } from "../../utils/process-supervisor";
|
|
|
16
16
|
import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
|
|
17
17
|
import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
|
|
18
18
|
import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
findSettingDef,
|
|
21
|
+
maskSecret,
|
|
22
|
+
SETTING_DEFS,
|
|
23
|
+
TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY,
|
|
24
|
+
validateSettingValue,
|
|
25
|
+
} from "./setting.constant";
|
|
20
26
|
import {
|
|
21
27
|
getSetting,
|
|
22
28
|
isStored,
|
|
@@ -252,7 +258,12 @@ class SettingModelClass extends BaseModelClass<
|
|
|
252
258
|
};
|
|
253
259
|
});
|
|
254
260
|
|
|
255
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
settings,
|
|
263
|
+
runtime: this.runtimeInfo(),
|
|
264
|
+
supervisor: detectSupervisor(),
|
|
265
|
+
keepaliveRunnerEnabled: process.env[TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY] === "true",
|
|
266
|
+
};
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
private runtimeInfo(): RuntimeInfoItem[] {
|
|
@@ -54,8 +54,8 @@ export async function loadSettings(persistence: SettingPersistence = SettingMode
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/** DB → env 순. 둘 다 없으면 undefined 라 호출부의 기본값으로 떨어진다. */
|
|
57
|
-
export function getSetting(key: string, envKey
|
|
58
|
-
return cache?.get(key) ?? process.env[envKey];
|
|
57
|
+
export function getSetting(key: string, envKey?: string): string | undefined {
|
|
58
|
+
return cache?.get(key) ?? (envKey ? process.env[envKey] : undefined);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** 저장된 값인지 — 화면에서 출처를 구분해 보여준다. */
|
|
@@ -61,5 +61,7 @@ export const SettingsResponse = z.object({
|
|
|
61
61
|
runtime: RuntimeInfoItem.array(),
|
|
62
62
|
/** null 이면 재시작 버튼을 쓸 수 없다 — 종료해도 되살릴 주체가 없다. */
|
|
63
63
|
supervisor: SupervisorKind.nullable(),
|
|
64
|
+
/** 이 인스턴스가 keepalive 스케줄러를 소유하도록 명시적으로 허용됐는지. */
|
|
65
|
+
keepaliveRunnerEnabled: z.boolean(),
|
|
64
66
|
});
|
|
65
67
|
export type SettingsResponse = z.infer<typeof SettingsResponse>;
|
|
@@ -329,6 +329,7 @@ Content-Type: application/json
|
|
|
329
329
|
"serviceTier": "fast",
|
|
330
330
|
"history": "HISTORY",
|
|
331
331
|
"projectName": "PROJECTNAME",
|
|
332
|
+
"tokenName": "TOKENNAME",
|
|
332
333
|
"cacheAffinityKey": "CACHEAFFINITYKEY",
|
|
333
334
|
"logger": false,
|
|
334
335
|
"runContext": {
|
|
@@ -390,6 +391,7 @@ Content-Type: application/json
|
|
|
390
391
|
"serviceTier": "fast",
|
|
391
392
|
"history": "HISTORY",
|
|
392
393
|
"projectName": "PROJECTNAME",
|
|
394
|
+
"tokenName": "TOKENNAME",
|
|
393
395
|
"cacheAffinityKey": "CACHEAFFINITYKEY",
|
|
394
396
|
"logger": false,
|
|
395
397
|
"runContext": {
|
|
@@ -564,7 +566,8 @@ Content-Type: application/json
|
|
|
564
566
|
"id": 0,
|
|
565
567
|
"name": "NAME",
|
|
566
568
|
"quotaThreshold": null,
|
|
567
|
-
"weight": 0
|
|
569
|
+
"weight": 0,
|
|
570
|
+
"keepaliveEnabled": false
|
|
568
571
|
}
|
|
569
572
|
|
|
570
573
|
###
|
|
@@ -299,6 +299,7 @@ export const TokenBaseSchema = z.object({
|
|
|
299
299
|
ord: z.int(),
|
|
300
300
|
quota_threshold: z.int().nullable(),
|
|
301
301
|
weight: z.int(),
|
|
302
|
+
keepalive_enabled: z.boolean(),
|
|
302
303
|
});
|
|
303
304
|
export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
304
305
|
readonly __hasDefault__: readonly [
|
|
@@ -307,6 +308,7 @@ export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
|
|
|
307
308
|
"ord",
|
|
308
309
|
"quota_threshold",
|
|
309
310
|
"weight",
|
|
311
|
+
"keepalive_enabled",
|
|
310
312
|
"id",
|
|
311
313
|
];
|
|
312
314
|
};
|
|
@@ -558,6 +560,7 @@ export const TokenSubsetA = z.object({
|
|
|
558
560
|
ord: z.int(),
|
|
559
561
|
quota_threshold: z.int().nullable(),
|
|
560
562
|
weight: z.int(),
|
|
563
|
+
keepalive_enabled: z.boolean(),
|
|
561
564
|
});
|
|
562
565
|
export type TokenSubsetA = z.infer<typeof TokenSubsetA>;
|
|
563
566
|
export type TokenSubsetMapping = {
|
|
@@ -26,9 +26,22 @@
|
|
|
26
26
|
"type": "integer",
|
|
27
27
|
"desc": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
28
28
|
"dbDefault": "1"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "keepalive_enabled",
|
|
32
|
+
"type": "boolean",
|
|
33
|
+
"desc": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
|
|
34
|
+
"dbDefault": "false"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"indexes": [
|
|
38
|
+
{
|
|
39
|
+
"type": "unique",
|
|
40
|
+
"name": "tokens_provider_name_unique",
|
|
41
|
+
"columns": [{ "name": "provider" }, { "name": "name" }],
|
|
42
|
+
"using": "btree"
|
|
29
43
|
}
|
|
30
44
|
],
|
|
31
|
-
"indexes": [],
|
|
32
45
|
"subsets": {
|
|
33
46
|
"A": [
|
|
34
47
|
"id",
|
|
@@ -39,7 +52,8 @@
|
|
|
39
52
|
"active",
|
|
40
53
|
"ord",
|
|
41
54
|
"quota_threshold",
|
|
42
|
-
"weight"
|
|
55
|
+
"weight",
|
|
56
|
+
"keepalive_enabled"
|
|
43
57
|
]
|
|
44
58
|
},
|
|
45
59
|
"enums": {
|