@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.
Files changed (91) hide show
  1. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +7 -3
  2. package/bundle/dist/application/qgrid/qgrid.frame.js +31 -13
  3. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  4. package/bundle/dist/application/qgrid/token-subscriber.js +19 -1
  5. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  6. package/bundle/dist/application/qgrid/token-window-keepalive.js +159 -0
  7. package/bundle/dist/application/setting/setting.constant.js +16 -2
  8. package/bundle/dist/application/setting/setting.model.js +4 -3
  9. package/bundle/dist/application/setting/setting.store.js +2 -2
  10. package/bundle/dist/application/setting/setting.types.js +4 -2
  11. package/bundle/dist/application/sonamu.generated.js +5 -3
  12. package/bundle/dist/application/sonamu.generated.sso.js +3 -2
  13. package/bundle/dist/application/token/token.model.js +24 -2
  14. package/bundle/dist/application/token/token.types.js +3 -2
  15. package/bundle/dist/i18n/sd.generated.js +2 -1
  16. package/bundle/dist/migrations/20260819102011_alter_tokens_alter1.js +13 -0
  17. package/bundle/dist/migrations/20260820153531_alter_tokens_add1_alter1.js +15 -0
  18. package/bundle/dist/sonamu.config.js +9 -1
  19. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +7 -4
  20. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +11 -3
  21. package/bundle/dist/utils/providers/openai/openai-quota.js +4 -3
  22. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +51 -0
  23. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +14 -4
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +67 -0
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +41 -12
  26. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  27. package/bundle/src/application/qgrid/token-subscriber.test.ts +112 -0
  28. package/bundle/src/application/qgrid/token-subscriber.ts +35 -0
  29. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  30. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  31. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +244 -0
  32. package/bundle/src/application/qgrid/token-window-keepalive.ts +237 -0
  33. package/bundle/src/application/setting/setting.constant.test.ts +11 -1
  34. package/bundle/src/application/setting/setting.constant.ts +17 -2
  35. package/bundle/src/application/setting/setting.model.ts +13 -2
  36. package/bundle/src/application/setting/setting.store.ts +2 -2
  37. package/bundle/src/application/setting/setting.types.ts +2 -0
  38. package/bundle/src/application/sonamu.generated.http +4 -1
  39. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  40. package/bundle/src/application/sonamu.generated.ts +3 -0
  41. package/bundle/src/application/token/token.entity.json +16 -2
  42. package/bundle/src/application/token/token.model.test.ts +66 -0
  43. package/bundle/src/application/token/token.model.ts +30 -1
  44. package/bundle/src/application/token/token.types.test.ts +10 -0
  45. package/bundle/src/application/token/token.types.ts +1 -0
  46. package/bundle/src/i18n/sd.generated.ts +1 -0
  47. package/bundle/src/migrations/20260819102011_alter_tokens_alter1.ts +13 -0
  48. package/bundle/src/migrations/20260820153531_alter_tokens_add1_alter1.ts +15 -0
  49. package/bundle/src/sonamu.config.ts +14 -0
  50. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +46 -0
  51. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +13 -4
  52. package/bundle/src/utils/providers/common/provider-dispatcher.ts +2 -0
  53. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +30 -0
  54. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +17 -1
  55. package/bundle/src/utils/providers/openai/openai-quota.test.ts +31 -0
  56. package/bundle/src/utils/providers/openai/openai-quota.ts +8 -2
  57. package/bundle/web-dist/client/assets/index-BshX4Gd8.css +1 -0
  58. package/bundle/web-dist/client/assets/index-CokxkK2G.js +106 -0
  59. package/bundle/web-dist/client/assets/{logs-BrAu0koY.js → logs-Ckqp98Y3.js} +1 -1
  60. package/bundle/web-dist/client/assets/{monit-D6JEk7n7.js → monit-u0pMHbyf.js} +1 -1
  61. package/bundle/web-dist/client/assets/{routes-Dvu63qre.js → routes-DobxbCAV.js} +2 -2
  62. package/bundle/web-dist/client/assets/{services.generated-CA7oRRYY.js → services.generated-Da1Eq5_R.js} +1 -1
  63. package/bundle/web-dist/client/assets/settings-C8rfyi9z.js +1 -0
  64. package/bundle/web-dist/client/assets/{show-BFGjYsWb.js → show-FbUQYs4L.js} +1 -1
  65. package/bundle/web-dist/client/assets/tokens-DFwEavFD.js +1 -0
  66. package/bundle/web-dist/client/assets/use-oauth-login-flow-D-9h3WUt.js +1 -0
  67. package/bundle/web-dist/client/assets/use-update-token-mutation-CZ57EKA1.js +1 -0
  68. package/bundle/web-dist/client/index.html +3 -3
  69. package/bundle/web-dist/server/assets/{lazyRouteComponent-tS-rTksX.js → lazyRouteComponent-V1S8gqMS.js} +2 -2
  70. package/bundle/web-dist/server/assets/{logs-ttf79emP.js → logs-2Tkqcncq.js} +2 -2
  71. package/bundle/web-dist/server/assets/{monit-CJBUH0l6.js → monit-DrJ15xJL.js} +2 -2
  72. package/bundle/web-dist/server/assets/{plus-BuSuoeon.js → plus-DCai02Na.js} +1 -1
  73. package/bundle/web-dist/server/assets/{react-dom-7_SqhNWR.js → react-dom-BFiG5Daf.js} +1 -1
  74. package/bundle/web-dist/server/assets/{routes-z79f1sOM.js → routes-B9X0lvjQ.js} +5 -4
  75. package/bundle/web-dist/server/assets/{services.generated-B5UkNmgc.js → services.generated-y7xVOTCo.js} +4 -2
  76. package/bundle/web-dist/server/assets/{settings-nCv71sax.js → settings-JznSjwGQ.js} +80 -5
  77. package/bundle/web-dist/server/assets/{show-CLrA4ZQ-.js → show-D8kuAFbc.js} +2 -2
  78. package/bundle/web-dist/server/assets/{tokens-BwlkZELV.js → tokens-CyItE-L-.js} +4 -3
  79. package/bundle/web-dist/server/assets/{use-oauth-login-flow-B2QllVMi.js → use-oauth-login-flow-CItVKngs.js} +2 -7
  80. package/bundle/web-dist/server/assets/use-update-token-mutation-BNaRPVaX.js +7 -0
  81. package/bundle/web-dist/server/entry-server.generated.js +103 -58
  82. package/package.json +1 -1
  83. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -0
  84. package/skills/qgrid/references/sonamu-api-web-flow.md +2 -0
  85. package/skills/qgrid/references/token-auth-quota-lifecycle.md +25 -1
  86. package/bundle/web-dist/client/assets/index-CwSSXuZv.js +0 -106
  87. package/bundle/web-dist/client/assets/index-D9xE_tO7.css +0 -1
  88. package/bundle/web-dist/client/assets/settings-mwDGFwUi.js +0 -1
  89. package/bundle/web-dist/client/assets/tokens-D-P__jYk.js +0 -1
  90. package/bundle/web-dist/client/assets/use-oauth-login-flow-CJpoJD26.js +0 -1
  91. /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 { buildAndValidateStrictOutputSchema, QgridDispatcher } from "./qgrid.dispatcher";
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 (args.logger === false) {
205
- return await QgridDispatcher.query(args, disconnect.signal);
230
+ if (resolvedArgs.logger === false) {
231
+ return await QgridDispatcher.query(resolvedArgs, disconnect.signal);
206
232
  }
207
233
 
208
- const { requestLogId, stepIndex } = await beforeQuery(args);
234
+ const { requestLogId, stepIndex } = await beforeQuery(resolvedArgs);
209
235
  let result: QueryOutput;
210
236
  try {
211
- result = await QgridDispatcher.query(args, disconnect.signal);
237
+ result = await QgridDispatcher.query(resolvedArgs, disconnect.signal);
212
238
  } catch (e) {
213
239
  if (disconnect.signal.aborted) {
214
- await finishRunAborted(requestLogId, args);
240
+ await finishRunAborted(requestLogId, resolvedArgs);
215
241
  } else {
216
- await finishRunWithError(requestLogId, (e as Error).message, args);
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, args, result);
248
+ const lifecycle = await afterQuery(requestLogId, stepIndex, resolvedArgs, result);
223
249
  if (disconnect.signal.aborted) {
224
- await finishRunAborted(requestLogId, args);
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, args);
262
+ await finishRunAborted(requestLogId, resolvedArgs);
237
263
  } else {
238
- await finishRunWithError(requestLogId, (e as Error).message, args);
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, args);
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
  `;