@cartanova/qgrid-cli 2.7.6 → 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.frame.js +5 -3
- package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
- package/bundle/dist/application/qgrid/token-window-keepalive.js +10 -4
- package/bundle/dist/application/setting/setting.constant.js +8 -4
- 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 +7 -2
- package/bundle/dist/application/token/token.types.js +3 -2
- package/bundle/dist/i18n/sd.generated.js +2 -1
- package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
- package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +9 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +2 -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 +23 -2
- package/bundle/src/application/qgrid/token-window-keepalive.ts +13 -3
- package/bundle/src/application/setting/setting.constant.test.ts +2 -2
- package/bundle/src/application/setting/setting.constant.ts +8 -3
- 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 +2 -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 +8 -1
- package/bundle/src/application/token/token.model.test.ts +29 -0
- package/bundle/src/application/token/token.model.ts +10 -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/20260820153531_alter_tokens_add1_alter1.ts +15 -0
- 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-BbOyk01G.js → index-CokxkK2G.js} +4 -4
- package/bundle/web-dist/client/assets/{logs-B7Pc88bf.js → logs-Ckqp98Y3.js} +1 -1
- package/bundle/web-dist/client/assets/{monit-CzMV4lZf.js → monit-u0pMHbyf.js} +1 -1
- package/bundle/web-dist/client/assets/{routes-COWTRxZo.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-YvdybkKd.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-CIYR7h3G.js → settings-JznSjwGQ.js} +79 -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 +10 -10
- 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-D9xE_tO7.css +0 -1
- package/bundle/web-dist/client/assets/settings-Dpaosy-o.js +0 -1
- package/bundle/web-dist/client/assets/tokens-DynVDx0c.js +0 -1
- package/bundle/web-dist/client/assets/use-oauth-login-flow-OzmHElsE.js +0 -1
- /package/bundle/web-dist/client/assets/{plus-DqBd6gHk.js → plus-p4vtg6LN.js} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLogger } from "@logtape/logtape";
|
|
2
2
|
|
|
3
|
+
import { TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY } from "../setting/setting.constant";
|
|
3
4
|
import { getSetting } from "../setting/setting.store";
|
|
4
5
|
import { TokenModel } from "../token/token.model";
|
|
5
6
|
import { type InternalQueryInput } from "./qgrid.dispatcher";
|
|
@@ -17,13 +18,14 @@ export const POST_KEEPALIVE_USAGE_DELAY_MS = 61_000;
|
|
|
17
18
|
export const KEEPALIVE_MODEL = "anthropic/claude-haiku-4-5";
|
|
18
19
|
export const KEEPALIVE_PROJECT_NAME = "qgrid-token-window-keepalive";
|
|
19
20
|
export const KEEPALIVE_SETTING_KEY = "qgrid.tokenWindowKeepaliveEnabled";
|
|
20
|
-
export const KEEPALIVE_ENV_KEY =
|
|
21
|
+
export const KEEPALIVE_ENV_KEY = TOKEN_WINDOW_KEEPALIVE_RUNNER_ENV_KEY;
|
|
21
22
|
|
|
22
23
|
type KeepaliveToken = {
|
|
23
24
|
id: number;
|
|
24
25
|
name: string;
|
|
25
26
|
provider: string;
|
|
26
27
|
active: boolean;
|
|
28
|
+
keepalive_enabled: boolean;
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
@@ -33,6 +35,7 @@ export type TokenWindowKeepaliveDeps = {
|
|
|
33
35
|
readUsage: (tokenId: number) => Promise<UsageResponse>;
|
|
34
36
|
dispatch: (input: InternalQueryInput) => Promise<unknown>;
|
|
35
37
|
readSetting: typeof getSetting;
|
|
38
|
+
isRunnerEnabled: () => boolean;
|
|
36
39
|
now: () => number;
|
|
37
40
|
setTimer: (callback: () => void, delayMs: number) => TimerHandle;
|
|
38
41
|
clearTimer: (handle: TimerHandle) => void;
|
|
@@ -43,6 +46,7 @@ const defaultDeps: TokenWindowKeepaliveDeps = {
|
|
|
43
46
|
readUsage: (tokenId) => QgridFrame.usage(tokenId),
|
|
44
47
|
dispatch: (input) => QgridFrame.query(input),
|
|
45
48
|
readSetting: getSetting,
|
|
49
|
+
isRunnerEnabled: () => process.env[KEEPALIVE_ENV_KEY] === "true",
|
|
46
50
|
now: () => Date.now(),
|
|
47
51
|
setTimer: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
48
52
|
clearTimer: (handle) => clearTimeout(handle),
|
|
@@ -191,7 +195,11 @@ export async function startTokenWindowKeepalive(
|
|
|
191
195
|
deps: TokenWindowKeepaliveDeps = defaultDeps,
|
|
192
196
|
): Promise<void> {
|
|
193
197
|
clearScheduledTimers();
|
|
194
|
-
if (deps.
|
|
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") {
|
|
195
203
|
logger.info("token window keepalive disabled");
|
|
196
204
|
return;
|
|
197
205
|
}
|
|
@@ -206,7 +214,9 @@ export async function startTokenWindowKeepalive(
|
|
|
206
214
|
}
|
|
207
215
|
if (generation !== expectedGeneration) return;
|
|
208
216
|
|
|
209
|
-
const targets = tokens.filter(
|
|
217
|
+
const targets = tokens.filter(
|
|
218
|
+
(token) => token.active && token.provider === "anthropic" && token.keepalive_enabled,
|
|
219
|
+
);
|
|
210
220
|
const targetIds = new Set(targets.map((token) => token.id));
|
|
211
221
|
for (const tokenId of fireGuards.keys()) {
|
|
212
222
|
if (!targetIds.has(tokenId)) fireGuards.delete(tokenId);
|
|
@@ -76,11 +76,11 @@ describe("SETTING_DEFS", () => {
|
|
|
76
76
|
it("token window keepalive 비상 스위치를 즉시 적용 설정으로 등록한다", () => {
|
|
77
77
|
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")).toMatchObject({
|
|
78
78
|
group: "qgrid",
|
|
79
|
-
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
80
79
|
kind: "boolean",
|
|
81
80
|
applies: "immediate",
|
|
82
81
|
fallback: "true",
|
|
83
82
|
});
|
|
83
|
+
expect(findSettingDef("qgrid.tokenWindowKeepaliveEnabled")?.envKey).toBeUndefined();
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
it("키가 중복되지 않는다", () => {
|
|
@@ -90,7 +90,7 @@ describe("SETTING_DEFS", () => {
|
|
|
90
90
|
|
|
91
91
|
it("env 키도 중복되지 않는다", () => {
|
|
92
92
|
// 겹치면 한 env 를 두 설정이 서로 덮어쓴다.
|
|
93
|
-
const envKeys = SETTING_DEFS.
|
|
93
|
+
const envKeys = SETTING_DEFS.flatMap((d) => (d.envKey ? [d.envKey] : []));
|
|
94
94
|
expect(new Set(envKeys).size).toBe(envKeys.length);
|
|
95
95
|
});
|
|
96
96
|
|
|
@@ -22,7 +22,7 @@ export type SettingGroup = "qgrid" | "slack" | "slackConnection";
|
|
|
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,16 +39,21 @@ 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[] = [
|
|
43
49
|
{
|
|
44
50
|
key: "qgrid.tokenWindowKeepaliveEnabled",
|
|
45
51
|
fallback: "true",
|
|
46
52
|
group: "qgrid",
|
|
47
|
-
envKey: "QGRID_TOKEN_WINDOW_KEEPALIVE_ENABLED",
|
|
48
53
|
label: "토큰 윈도우 keepalive",
|
|
49
54
|
kind: "boolean",
|
|
50
55
|
applies: "immediate",
|
|
51
|
-
help: "끄면
|
|
56
|
+
help: "끄면 이 서버에서 선택한 Anthropic 토큰의 5시간 윈도우 자동 유지 요청을 중단합니다",
|
|
52
57
|
},
|
|
53
58
|
{
|
|
54
59
|
key: "slack.enabled",
|
|
@@ -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>;
|
|
@@ -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,6 +26,12 @@
|
|
|
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"
|
|
29
35
|
}
|
|
30
36
|
],
|
|
31
37
|
"indexes": [
|
|
@@ -46,7 +52,8 @@
|
|
|
46
52
|
"active",
|
|
47
53
|
"ord",
|
|
48
54
|
"quota_threshold",
|
|
49
|
-
"weight"
|
|
55
|
+
"weight",
|
|
56
|
+
"keepalive_enabled"
|
|
50
57
|
]
|
|
51
58
|
},
|
|
52
59
|
"enums": {
|
|
@@ -111,6 +111,35 @@ describe("TokenModel.updateFields", () => {
|
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
describe("TokenModel.replaceByAccount", () => {
|
|
115
|
+
afterEach(() => {
|
|
116
|
+
vi.restoreAllMocks();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("re-login으로 row가 교체돼도 기존 keepalive 선택을 보존한다", async () => {
|
|
120
|
+
vi.spyOn(TokenModel, "findByAccountIdentifier").mockResolvedValue([
|
|
121
|
+
{
|
|
122
|
+
id: 1,
|
|
123
|
+
created_at: new Date(),
|
|
124
|
+
active: true,
|
|
125
|
+
ord: 0,
|
|
126
|
+
quota_threshold: 80,
|
|
127
|
+
weight: 1,
|
|
128
|
+
keepalive_enabled: true,
|
|
129
|
+
...baseToken,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
vi.spyOn(TokenModel, "del").mockResolvedValue(1);
|
|
133
|
+
const save = vi.spyOn(TokenModel, "save").mockResolvedValue([2]);
|
|
134
|
+
|
|
135
|
+
await TokenModel.replaceByAccount("anthropic", "acc-1", baseToken);
|
|
136
|
+
|
|
137
|
+
expect(save).toHaveBeenCalledWith([
|
|
138
|
+
expect.objectContaining({ keepalive_enabled: true }),
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
114
143
|
describe("TokenModel.findActiveByProviderAndName", () => {
|
|
115
144
|
afterEach(() => {
|
|
116
145
|
vi.restoreAllMocks();
|
|
@@ -23,6 +23,7 @@ export type TokenUpdateFields = {
|
|
|
23
23
|
name?: string;
|
|
24
24
|
quota_threshold?: number | null;
|
|
25
25
|
weight?: number;
|
|
26
|
+
keepalive_enabled?: boolean;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
function applyCreateDefaults(sp: TokenSaveParams): TokenSaveParams {
|
|
@@ -235,8 +236,12 @@ class TokenModelClass extends BaseModelClass<
|
|
|
235
236
|
accountId: string | undefined,
|
|
236
237
|
saveParams: TokenSaveParams,
|
|
237
238
|
): Promise<void> {
|
|
239
|
+
let keepaliveEnabled = saveParams.keepalive_enabled;
|
|
238
240
|
if (accountId) {
|
|
239
241
|
const oldEntries = await this.findByAccountIdentifier("A", provider, accountId);
|
|
242
|
+
if (keepaliveEnabled === undefined && oldEntries.length > 0) {
|
|
243
|
+
keepaliveEnabled = oldEntries.some((entry) => entry.keepalive_enabled);
|
|
244
|
+
}
|
|
240
245
|
if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
|
|
241
246
|
} else {
|
|
242
247
|
logger.warn(
|
|
@@ -244,7 +249,11 @@ class TokenModelClass extends BaseModelClass<
|
|
|
244
249
|
);
|
|
245
250
|
}
|
|
246
251
|
|
|
247
|
-
await this.save([
|
|
252
|
+
await this.save([
|
|
253
|
+
keepaliveEnabled === undefined
|
|
254
|
+
? saveParams
|
|
255
|
+
: { ...saveParams, keepalive_enabled: keepaliveEnabled },
|
|
256
|
+
]);
|
|
248
257
|
}
|
|
249
258
|
|
|
250
259
|
/**
|
|
@@ -42,4 +42,14 @@ describe("TokenSaveParams", () => {
|
|
|
42
42
|
expect(() => TokenSaveParams.parse({ ...baseToken, weight })).toThrow();
|
|
43
43
|
}
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("keeps per-token keepalive opt-in optional and accepts booleans", () => {
|
|
47
|
+
expect(TokenSaveParams.parse(baseToken)).not.toHaveProperty("keepalive_enabled");
|
|
48
|
+
expect(
|
|
49
|
+
TokenSaveParams.parse({ ...baseToken, keepalive_enabled: true }).keepalive_enabled,
|
|
50
|
+
).toBe(true);
|
|
51
|
+
expect(() =>
|
|
52
|
+
TokenSaveParams.parse({ ...baseToken, keepalive_enabled: "true" }),
|
|
53
|
+
).toThrow();
|
|
54
|
+
});
|
|
45
55
|
});
|
|
@@ -320,6 +320,7 @@ const entityLabels = {
|
|
|
320
320
|
"entity.Token.ord": "정렬 순서",
|
|
321
321
|
"entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
|
|
322
322
|
"entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
|
|
323
|
+
"entity.Token.keepalive_enabled": "Anthropic 5시간 토큰 윈도우 keepalive 사용 여부",
|
|
323
324
|
"enum.TokenOrderBy.id-desc": "ID최신순",
|
|
324
325
|
"enum.TokenOrderBy.ord-asc": "순서순",
|
|
325
326
|
"enum.TokenSearchField.id": "ID",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Knex } from "knex";
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
5
|
+
// add
|
|
6
|
+
table.boolean("keepalive_enabled").notNullable().defaultTo(knex.raw("false"));
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function down(knex: Knex): Promise<void> {
|
|
11
|
+
await knex.schema.alterTable("tokens", (table) => {
|
|
12
|
+
// rollback - add
|
|
13
|
+
table.dropColumns("keepalive_enabled");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -147,6 +147,37 @@ describe("readOpenAIQuotaUsage", () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it("converts the current WHAM limit_window_seconds duration to minutes", async () => {
|
|
151
|
+
const fetchMock = vi.fn<typeof fetch>(async () =>
|
|
152
|
+
new Response(
|
|
153
|
+
JSON.stringify({
|
|
154
|
+
rate_limit: {
|
|
155
|
+
primary_window: {
|
|
156
|
+
used_percent: 21,
|
|
157
|
+
limit_window_seconds: 604_800,
|
|
158
|
+
reset_at: 123,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
{ status: 200 },
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
readOpenAIQuotaUsage({
|
|
168
|
+
credentials: { accessToken: "access", accountId: "acct" },
|
|
169
|
+
fetch: fetchMock,
|
|
170
|
+
}),
|
|
171
|
+
).resolves.toMatchObject({
|
|
172
|
+
kind: "ok",
|
|
173
|
+
raw: {
|
|
174
|
+
rateLimits: {
|
|
175
|
+
primary: { usedPercent: 21, windowDurationMins: 10_080, resetsAt: 123 },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
150
181
|
it("fails open for direct HTTP failures", async () => {
|
|
151
182
|
await expect(
|
|
152
183
|
readOpenAIQuotaUsage({
|
|
@@ -47,11 +47,17 @@ function windowFrom(value: unknown) {
|
|
|
47
47
|
const source = value as Record<string, unknown>;
|
|
48
48
|
const usedPercent = Number(source.usedPercent ?? source.used_percent);
|
|
49
49
|
if (!Number.isFinite(usedPercent)) return null;
|
|
50
|
-
const
|
|
50
|
+
const durationMins = source.windowDurationMins ?? source.window_minutes;
|
|
51
|
+
const durationSeconds = source.limitWindowSeconds ?? source.limit_window_seconds;
|
|
51
52
|
const resets = source.resetsAt ?? source.reset_at;
|
|
52
53
|
return {
|
|
53
54
|
usedPercent,
|
|
54
|
-
windowDurationMins:
|
|
55
|
+
windowDurationMins:
|
|
56
|
+
durationMins !== null && durationMins !== undefined
|
|
57
|
+
? Number(durationMins)
|
|
58
|
+
: durationSeconds !== null && durationSeconds !== undefined
|
|
59
|
+
? Number(durationSeconds) / 60
|
|
60
|
+
: null,
|
|
55
61
|
resetsAt: resets === null || resets === undefined ? null : Number(resets),
|
|
56
62
|
};
|
|
57
63
|
}
|