@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
|
@@ -9,6 +9,7 @@ import { QueryInput } from "./qgrid.types";
|
|
|
9
9
|
const {
|
|
10
10
|
findOneMock,
|
|
11
11
|
findManyMock,
|
|
12
|
+
findActiveByProviderAndNameMock,
|
|
12
13
|
saveMock,
|
|
13
14
|
updateFieldsMock,
|
|
14
15
|
requestLogSaveMock,
|
|
@@ -30,6 +31,7 @@ const {
|
|
|
30
31
|
return {
|
|
31
32
|
findOneMock: vi.fn(),
|
|
32
33
|
findManyMock: vi.fn(),
|
|
34
|
+
findActiveByProviderAndNameMock: vi.fn(),
|
|
33
35
|
saveMock: vi.fn(),
|
|
34
36
|
updateFieldsMock: vi.fn(),
|
|
35
37
|
requestLogSaveMock: vi.fn(),
|
|
@@ -59,6 +61,7 @@ vi.mock("../token/token.model", () => ({
|
|
|
59
61
|
TokenModel: {
|
|
60
62
|
findOne: findOneMock,
|
|
61
63
|
findMany: findManyMock,
|
|
64
|
+
findActiveByProviderAndName: findActiveByProviderAndNameMock,
|
|
62
65
|
save: saveMock,
|
|
63
66
|
updateFields: updateFieldsMock,
|
|
64
67
|
},
|
|
@@ -108,6 +111,7 @@ const tokenEntry = {
|
|
|
108
111
|
ord: 0,
|
|
109
112
|
quota_threshold: null,
|
|
110
113
|
weight: 1,
|
|
114
|
+
keepalive_enabled: false,
|
|
111
115
|
};
|
|
112
116
|
|
|
113
117
|
function deeplyNestedInputSchema(depth: number): unknown {
|
|
@@ -125,6 +129,7 @@ describe("QgridFrame.updateToken", () => {
|
|
|
125
129
|
appendStepMock.mockReset();
|
|
126
130
|
appendStepMock.mockResolvedValue(1);
|
|
127
131
|
dispatcherQueryMock.mockReset();
|
|
132
|
+
findActiveByProviderAndNameMock.mockReset();
|
|
128
133
|
});
|
|
129
134
|
|
|
130
135
|
it("rejects quota thresholds outside bounds before updating", async () => {
|
|
@@ -160,6 +165,14 @@ describe("QgridFrame.updateToken", () => {
|
|
|
160
165
|
expect(updateFieldsMock).toHaveBeenCalledWith(1, { weight: 4 });
|
|
161
166
|
});
|
|
162
167
|
|
|
168
|
+
it("updates only the supplied keepalive switch", async () => {
|
|
169
|
+
await expect(QgridFrame.updateToken(1, undefined, undefined, undefined, true)).resolves.toEqual({
|
|
170
|
+
updated: true,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(updateFieldsMock).toHaveBeenCalledWith(1, { keepalive_enabled: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
163
176
|
it("reports a missing token from the atomic update result", async () => {
|
|
164
177
|
updateFieldsMock.mockResolvedValueOnce(0);
|
|
165
178
|
|
|
@@ -180,6 +193,7 @@ describe("QgridFrame.query request logging", () => {
|
|
|
180
193
|
assertNativeRunAdmissionMock.mockReset();
|
|
181
194
|
finishRunWithErrorMock.mockReset();
|
|
182
195
|
dispatcherQueryMock.mockReset();
|
|
196
|
+
findActiveByProviderAndNameMock.mockReset();
|
|
183
197
|
});
|
|
184
198
|
|
|
185
199
|
function queryOutput() {
|
|
@@ -234,6 +248,59 @@ describe("QgridFrame.query request logging", () => {
|
|
|
234
248
|
expect(result.runContext).toEqual(queryOutput().runContext);
|
|
235
249
|
});
|
|
236
250
|
|
|
251
|
+
it("tokenName 을 활성 토큰 id 로 해석해 지정 요청으로 전달한다", async () => {
|
|
252
|
+
findActiveByProviderAndNameMock.mockResolvedValueOnce({
|
|
253
|
+
...tokenEntry,
|
|
254
|
+
id: 17,
|
|
255
|
+
name: "anthropic/tok-A",
|
|
256
|
+
});
|
|
257
|
+
dispatcherQueryMock.mockResolvedValueOnce(queryOutput());
|
|
258
|
+
|
|
259
|
+
await QgridFrame.query({
|
|
260
|
+
prompt: "hi",
|
|
261
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
262
|
+
tokenName: "anthropic/tok-A",
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(findActiveByProviderAndNameMock).toHaveBeenCalledWith(
|
|
266
|
+
"A",
|
|
267
|
+
"anthropic",
|
|
268
|
+
"anthropic/tok-A",
|
|
269
|
+
);
|
|
270
|
+
expect(dispatcherQueryMock).toHaveBeenCalledWith(
|
|
271
|
+
expect.objectContaining({ preferredTokenId: 17, requirePreferredToken: true }),
|
|
272
|
+
expect.any(AbortSignal),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("tokenName 에 해당하는 활성 토큰이 없으면 대체 없이 실패한다", async () => {
|
|
277
|
+
findActiveByProviderAndNameMock.mockResolvedValueOnce(undefined);
|
|
278
|
+
|
|
279
|
+
await expect(
|
|
280
|
+
QgridFrame.query({
|
|
281
|
+
prompt: "hi",
|
|
282
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
283
|
+
tokenName: "anthropic/missing",
|
|
284
|
+
}),
|
|
285
|
+
).rejects.toThrow("Active token not found: anthropic/missing");
|
|
286
|
+
|
|
287
|
+
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
288
|
+
expect(beforeQueryMock).not.toHaveBeenCalled();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("tokenName provider 와 model provider 가 다르면 조회 전에 실패한다", async () => {
|
|
292
|
+
await expect(
|
|
293
|
+
QgridFrame.query({
|
|
294
|
+
prompt: "hi",
|
|
295
|
+
model: "anthropic/claude-sonnet-4-6",
|
|
296
|
+
tokenName: "openai/tok-A",
|
|
297
|
+
}),
|
|
298
|
+
).rejects.toThrow("Token provider does not match model provider");
|
|
299
|
+
|
|
300
|
+
expect(findActiveByProviderAndNameMock).not.toHaveBeenCalled();
|
|
301
|
+
expect(dispatcherQueryMock).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
237
304
|
it("persists an immediate tools+schema structured answer as final JSON text", async () => {
|
|
238
305
|
const output = {
|
|
239
306
|
...queryOutput(),
|
|
@@ -39,7 +39,11 @@ import {
|
|
|
39
39
|
finishRunAborted,
|
|
40
40
|
finishRunWithError,
|
|
41
41
|
} from "./qgrid-run-lifecycle";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
buildAndValidateStrictOutputSchema,
|
|
44
|
+
type InternalQueryInput,
|
|
45
|
+
QgridDispatcher,
|
|
46
|
+
} from "./qgrid.dispatcher";
|
|
43
47
|
import {
|
|
44
48
|
type QueryInput,
|
|
45
49
|
type AppendStepInput,
|
|
@@ -147,6 +151,27 @@ function rejectInvalidCallerSchemas(args: QueryInput): void {
|
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
async function resolveTokenName(args: QueryInput): Promise<InternalQueryInput> {
|
|
155
|
+
if (!args.tokenName) return args;
|
|
156
|
+
|
|
157
|
+
const tokenProvider = args.tokenName.split("/", 1)[0];
|
|
158
|
+
const modelProvider = args.model?.split("/", 1)[0];
|
|
159
|
+
if (tokenProvider !== "anthropic" && tokenProvider !== "openai") {
|
|
160
|
+
throw new BadRequestException(`Invalid token name: ${args.tokenName}` as LocalizedString);
|
|
161
|
+
}
|
|
162
|
+
if (modelProvider !== tokenProvider) {
|
|
163
|
+
throw new BadRequestException(
|
|
164
|
+
"Token provider does not match model provider" as LocalizedString,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const token = await TokenModel.findActiveByProviderAndName("A", tokenProvider, args.tokenName);
|
|
169
|
+
if (!token) {
|
|
170
|
+
throw new BadRequestException(`Active token not found: ${args.tokenName}` as LocalizedString);
|
|
171
|
+
}
|
|
172
|
+
return { ...args, preferredTokenId: token.id, requirePreferredToken: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
function createHttpDisconnectHandle(): {
|
|
151
176
|
signal: AbortSignal;
|
|
152
177
|
dispose: () => void;
|
|
@@ -199,29 +224,30 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
199
224
|
async query(args: QueryInput): Promise<QueryOutput> {
|
|
200
225
|
assertNativeRunAdmission();
|
|
201
226
|
rejectInvalidCallerSchemas(args);
|
|
227
|
+
const resolvedArgs = await resolveTokenName(args);
|
|
202
228
|
const disconnect = createHttpDisconnectHandle();
|
|
203
229
|
try {
|
|
204
|
-
if (
|
|
205
|
-
return await QgridDispatcher.query(
|
|
230
|
+
if (resolvedArgs.logger === false) {
|
|
231
|
+
return await QgridDispatcher.query(resolvedArgs, disconnect.signal);
|
|
206
232
|
}
|
|
207
233
|
|
|
208
|
-
const { requestLogId, stepIndex } = await beforeQuery(
|
|
234
|
+
const { requestLogId, stepIndex } = await beforeQuery(resolvedArgs);
|
|
209
235
|
let result: QueryOutput;
|
|
210
236
|
try {
|
|
211
|
-
result = await QgridDispatcher.query(
|
|
237
|
+
result = await QgridDispatcher.query(resolvedArgs, disconnect.signal);
|
|
212
238
|
} catch (e) {
|
|
213
239
|
if (disconnect.signal.aborted) {
|
|
214
|
-
await finishRunAborted(requestLogId,
|
|
240
|
+
await finishRunAborted(requestLogId, resolvedArgs);
|
|
215
241
|
} else {
|
|
216
|
-
await finishRunWithError(requestLogId, (e as Error).message,
|
|
242
|
+
await finishRunWithError(requestLogId, (e as Error).message, resolvedArgs);
|
|
217
243
|
}
|
|
218
244
|
throw e;
|
|
219
245
|
}
|
|
220
246
|
|
|
221
247
|
try {
|
|
222
|
-
const lifecycle = await afterQuery(requestLogId, stepIndex,
|
|
248
|
+
const lifecycle = await afterQuery(requestLogId, stepIndex, resolvedArgs, result);
|
|
223
249
|
if (disconnect.signal.aborted) {
|
|
224
|
-
await finishRunAborted(requestLogId,
|
|
250
|
+
await finishRunAborted(requestLogId, resolvedArgs);
|
|
225
251
|
}
|
|
226
252
|
// tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
|
|
227
253
|
const threadCoord = result.runContext?.threadCoord;
|
|
@@ -233,9 +259,9 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
233
259
|
} catch (e) {
|
|
234
260
|
logger.error(`query afterQuery failed: ${(e as Error).message}`);
|
|
235
261
|
if (disconnect.signal.aborted) {
|
|
236
|
-
await finishRunAborted(requestLogId,
|
|
262
|
+
await finishRunAborted(requestLogId, resolvedArgs);
|
|
237
263
|
} else {
|
|
238
|
-
await finishRunWithError(requestLogId, (e as Error).message,
|
|
264
|
+
await finishRunWithError(requestLogId, (e as Error).message, resolvedArgs);
|
|
239
265
|
}
|
|
240
266
|
// provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
|
|
241
267
|
return result;
|
|
@@ -250,8 +276,9 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
250
276
|
assertNativeRunAdmission();
|
|
251
277
|
rejectImageGenerationStream(args);
|
|
252
278
|
rejectInvalidCallerSchemas(args);
|
|
279
|
+
const resolvedArgs = await resolveTokenName(args);
|
|
253
280
|
const streamId = crypto.randomUUID();
|
|
254
|
-
pendingStreams.set(streamId,
|
|
281
|
+
pendingStreams.set(streamId, resolvedArgs);
|
|
255
282
|
const expiry = setTimeout(() => pendingStreams.delete(streamId), 30_000);
|
|
256
283
|
expiry.unref?.();
|
|
257
284
|
return { streamId };
|
|
@@ -478,6 +505,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
478
505
|
name?: string,
|
|
479
506
|
quotaThreshold?: number | null,
|
|
480
507
|
weight?: number,
|
|
508
|
+
keepaliveEnabled?: boolean,
|
|
481
509
|
): Promise<{ updated: boolean }> {
|
|
482
510
|
if (
|
|
483
511
|
quotaThreshold !== undefined &&
|
|
@@ -498,6 +526,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
498
526
|
if (name !== undefined) patch.name = name;
|
|
499
527
|
if (quotaThreshold !== undefined) patch.quota_threshold = quotaThreshold;
|
|
500
528
|
if (weight !== undefined) patch.weight = weight;
|
|
529
|
+
if (keepaliveEnabled !== undefined) patch.keepalive_enabled = keepaliveEnabled;
|
|
501
530
|
if (Object.keys(patch).length === 0) return { updated: false };
|
|
502
531
|
|
|
503
532
|
const updated = await TokenModel.updateFields(id, patch);
|
|
@@ -114,6 +114,8 @@ export const QueryInput = z.looseObject({
|
|
|
114
114
|
serviceTier: ServiceTier.optional(),
|
|
115
115
|
history: z.string().optional(),
|
|
116
116
|
projectName: z.string().optional(),
|
|
117
|
+
// 내부·대시보드 호출용 지정 주소. 공개 AI SDK provider options 에는 노출하지 않는다.
|
|
118
|
+
tokenName: z.string().optional(),
|
|
117
119
|
// SDK-derived opaque affinity. It is intentionally distinct from the caller's sessionKey.
|
|
118
120
|
cacheAffinityKey: z
|
|
119
121
|
.string()
|
|
@@ -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> {
|
|
@@ -6,4 +6,10 @@ describe("tokens trigger setup", () => {
|
|
|
6
6
|
it("leaves weight notifications to the versioned migration trigger", () => {
|
|
7
7
|
expect(TOKENS_TRIGGER_SETUP_SQL).not.toContain("OLD.weight IS DISTINCT FROM NEW.weight");
|
|
8
8
|
});
|
|
9
|
+
|
|
10
|
+
it("notifies subscribers when per-token keepalive membership changes", () => {
|
|
11
|
+
expect(TOKENS_TRIGGER_SETUP_SQL).toContain(
|
|
12
|
+
"OLD.keepalive_enabled IS DISTINCT FROM NEW.keepalive_enabled",
|
|
13
|
+
);
|
|
14
|
+
});
|
|
9
15
|
});
|
|
@@ -40,7 +40,8 @@ export const TOKENS_TRIGGER_SETUP_SQL = `
|
|
|
40
40
|
OLD.credentials IS DISTINCT FROM NEW.credentials OR
|
|
41
41
|
OLD.provider IS DISTINCT FROM NEW.provider OR
|
|
42
42
|
OLD.name IS DISTINCT FROM NEW.name OR
|
|
43
|
-
OLD.quota_threshold IS DISTINCT FROM NEW.quota_threshold
|
|
43
|
+
OLD.quota_threshold IS DISTINCT FROM NEW.quota_threshold OR
|
|
44
|
+
OLD.keepalive_enabled IS DISTINCT FROM NEW.keepalive_enabled
|
|
44
45
|
)
|
|
45
46
|
EXECUTE FUNCTION public.tokens_notify();
|
|
46
47
|
`;
|