@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
@@ -111,6 +111,72 @@ 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
+
143
+ describe("TokenModel.findActiveByProviderAndName", () => {
144
+ afterEach(() => {
145
+ vi.restoreAllMocks();
146
+ });
147
+
148
+ it("active·provider·name 세 조건으로 단일 토큰을 조회한다", async () => {
149
+ const where = vi.fn();
150
+ const qb = { where };
151
+ const row = { id: 7, active: true, ...baseToken };
152
+ vi.spyOn(
153
+ TokenModel as unknown as { getSubsetQueries: (subset: "A") => { qb: typeof qb } },
154
+ "getSubsetQueries",
155
+ ).mockReturnValue({ qb });
156
+ const executeSubsetQuery = vi
157
+ .spyOn(
158
+ TokenModel as unknown as {
159
+ executeSubsetQuery: (input: unknown) => Promise<{ rows: typeof row[] }>;
160
+ },
161
+ "executeSubsetQuery",
162
+ )
163
+ .mockResolvedValue({ rows: [row] });
164
+
165
+ await expect(
166
+ TokenModel.findActiveByProviderAndName("A", "anthropic", "anthropic/tok-A"),
167
+ ).resolves.toBe(row);
168
+
169
+ expect(where.mock.calls).toEqual([
170
+ ["tokens.active", true],
171
+ ["tokens.provider", "anthropic"],
172
+ ["tokens.name", "anthropic/tok-A"],
173
+ ]);
174
+ expect(executeSubsetQuery).toHaveBeenCalledWith(
175
+ expect.objectContaining({ subset: "A", qb, params: { num: 1, page: 1 } }),
176
+ );
177
+ });
178
+ });
179
+
114
180
  describe("TokenModel.deactivateIfActive", () => {
115
181
  afterEach(() => {
116
182
  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 {
@@ -146,6 +147,26 @@ class TokenModelClass extends BaseModelClass<
146
147
  return result.rows;
147
148
  }
148
149
 
150
+ async findActiveByProviderAndName<T extends TokenSubsetKey>(
151
+ subset: T,
152
+ provider: string,
153
+ name: string,
154
+ ): Promise<TokenSubsetMapping[T] | undefined> {
155
+ const { qb } = this.getSubsetQueries(subset);
156
+ qb.where("tokens.active", true);
157
+ qb.where("tokens.provider", provider);
158
+ qb.where("tokens.name", name);
159
+ const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
160
+ const result = await this.executeSubsetQuery({
161
+ subset,
162
+ qb,
163
+ params: { num: 1, page: 1 },
164
+ enhancers,
165
+ debug: false,
166
+ });
167
+ return result.rows[0];
168
+ }
169
+
149
170
  /**
150
171
  * 라우팅에서 빠진 토큰들. 세션 만료로 자동 비활성화된 것과 수동으로 끈 것이 섞여 있다 —
151
172
  * 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
@@ -215,8 +236,12 @@ class TokenModelClass extends BaseModelClass<
215
236
  accountId: string | undefined,
216
237
  saveParams: TokenSaveParams,
217
238
  ): Promise<void> {
239
+ let keepaliveEnabled = saveParams.keepalive_enabled;
218
240
  if (accountId) {
219
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
+ }
220
245
  if (oldEntries.length > 0) await this.del(oldEntries.map((o) => o.id));
221
246
  } else {
222
247
  logger.warn(
@@ -224,7 +249,11 @@ class TokenModelClass extends BaseModelClass<
224
249
  );
225
250
  }
226
251
 
227
- await this.save([saveParams]);
252
+ await this.save([
253
+ keepaliveEnabled === undefined
254
+ ? saveParams
255
+ : { ...saveParams, keepalive_enabled: keepaliveEnabled },
256
+ ]);
228
257
  }
229
258
 
230
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
  });
@@ -41,6 +41,7 @@ export const TokenSaveParams = TokenBaseSchema.partial({
41
41
  ord: true,
42
42
  quota_threshold: true,
43
43
  weight: true,
44
+ keepalive_enabled: true,
44
45
  }).extend({
45
46
  quota_threshold: TokenQuotaThreshold.optional(),
46
47
  weight: TokenWeight.optional(),
@@ -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,13 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.raw(
5
+ `CREATE UNIQUE INDEX tokens_provider_name_unique ON tokens USING btree(provider ASC NULLS LAST, name ASC NULLS LAST) NULLS DISTINCT;`,
6
+ );
7
+ }
8
+
9
+ export async function down(knex: Knex): Promise<void> {
10
+ await knex.schema.alterTable("tokens", (table) => {
11
+ table.dropIndex(["provider", "name"], "tokens_provider_name_unique");
12
+ });
13
+ }
@@ -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
+ }
@@ -17,6 +17,11 @@ import { QgridDispatcher } from "./application/qgrid/qgrid.dispatcher";
17
17
  import { QgridFrame } from "./application/qgrid/qgrid.frame";
18
18
  import { TokenSubscriber } from "./application/qgrid/token-subscriber";
19
19
  import { ensureTokensTrigger } from "./application/qgrid/token-trigger-setup";
20
+ import {
21
+ rescheduleTokenWindowKeepalive,
22
+ startTokenWindowKeepalive,
23
+ stopTokenWindowKeepalive,
24
+ } from "./application/qgrid/token-window-keepalive";
20
25
  import { loadSettings, setSettingChangeHandler } from "./application/setting/setting.store";
21
26
  import { handleServerError } from "./server-error-handler";
22
27
  import { AnthropicDispatcher } from "./utils/providers/anthropic/anthropic-dispatcher";
@@ -176,6 +181,9 @@ export default defineConfig({
176
181
  if (key === "slack.expiryReminderMinutes" || key === "slack.remindersEnabled") {
177
182
  rescheduleExpiredTokenReminder();
178
183
  }
184
+ if (key === "qgrid.tokenWindowKeepaliveEnabled") {
185
+ void rescheduleTokenWindowKeepalive();
186
+ }
179
187
  });
180
188
 
181
189
  let triggerReady = true;
@@ -213,7 +221,11 @@ export default defineConfig({
213
221
  log.warn(`anthropic dispatcher failed: ${(e as Error).message}`);
214
222
  }
215
223
 
224
+ subscriber.setTokenChangeHandler(() => {
225
+ void rescheduleTokenWindowKeepalive();
226
+ });
216
227
  startExpiredTokenReminder();
228
+ void startTokenWindowKeepalive();
217
229
 
218
230
  const anthropicCount = QgridDispatcher.anthropicDispatcher?.tokenCount ?? 0;
219
231
  const openaiCount = QgridDispatcher.openaiDispatcher?.tokenCount ?? 0;
@@ -228,7 +240,9 @@ export default defineConfig({
228
240
  onShutdown: async () => {
229
241
  const log = getLogger(["qgrid", "startup"]);
230
242
  stopExpiredTokenReminder();
243
+ stopTokenWindowKeepalive();
231
244
  setSettingChangeHandler(null);
245
+ QgridDispatcher.subscriber?.setTokenChangeHandler(null);
232
246
  if (QgridDispatcher.openaiDispatcher) {
233
247
  await QgridDispatcher.openaiDispatcher.stop();
234
248
  }
@@ -249,6 +249,52 @@ describe("AnthropicDispatcher", () => {
249
249
  expect(readAnthropicQuotaUsageMock).not.toHaveBeenCalled();
250
250
  });
251
251
 
252
+ it("preferredTokenId 가 있으면 가중 선택과 무관하게 그 토큰을 고른다", async () => {
253
+ const d = new AnthropicDispatcher();
254
+ d.onTokenAdded(1, "tok-A", creds(), null, 10);
255
+ d.onTokenAdded(2, "tok-B", creds(), null, 1);
256
+
257
+ const result = await d.generate(baseReq({ preferredTokenId: 2 }));
258
+
259
+ expect(result.tokenName).toBe("tok-B");
260
+ });
261
+
262
+ it("preferredTokenId 가 풀에 없으면 다른 토큰으로 대체하지 않는다", async () => {
263
+ const d = new AnthropicDispatcher();
264
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
265
+
266
+ await expect(d.generate(baseReq({ preferredTokenId: 99 }))).rejects.toThrow(
267
+ "Preferred anthropic token 99 is not available",
268
+ );
269
+ expect(runClaudeSessionMock).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("지정 토큰이 threshold 를 넘으면 eligible 토큰으로 대체하지 않는다", async () => {
273
+ const d = new AnthropicDispatcher();
274
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
275
+ d.onTokenAdded(2, "tok-B", creds(), 80, 1);
276
+ readAnthropicQuotaUsageMock.mockResolvedValueOnce(quotaOk(80));
277
+
278
+ const error = await d.generate(baseReq({ preferredTokenId: 2 })).catch((e) => e);
279
+
280
+ expect(error).toBeInstanceOf(QuotaThresholdExceededError);
281
+ expect(error.message).toBe(
282
+ "All anthropic tokens exceeded quota threshold: tok-B (threshold 80%)",
283
+ );
284
+ expect(readAnthropicQuotaUsageMock).toHaveBeenCalledTimes(1);
285
+ expect(runClaudeSessionMock).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it("지정 선택은 다음 비지정 요청의 가중 선택 상태를 소비하지 않는다", async () => {
289
+ const d = new AnthropicDispatcher();
290
+ d.onTokenAdded(1, "tok-A", creds(), null, 1);
291
+ d.onTokenAdded(2, "tok-B", creds(), null, 2);
292
+
293
+ expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
294
+ expect((await d.generate(baseReq({ preferredTokenId: 2 }))).tokenName).toBe("tok-B");
295
+ expect((await d.generate(baseReq())).tokenName).toBe("tok-A");
296
+ });
297
+
252
298
  it("recomputes weighted selection from quota-eligible tokens", async () => {
253
299
  const d = new AnthropicDispatcher();
254
300
  d.onTokenAdded(1, "tok-A", creds(), 80, 10);
@@ -161,9 +161,16 @@ export class AnthropicDispatcher implements ProviderDispatcher {
161
161
  this.weightedSelector.removeToken(id);
162
162
  }
163
163
 
164
- // quota 통과 후보만으로 요청마다 smooth weighted round-robin 선택한다.
165
- private async selectToken(): Promise<PooledToken | null> {
166
- const rows = [...this.tokenPool.values()];
164
+ // 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
165
+ // 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
166
+ private async selectToken(preferredTokenId?: number): Promise<PooledToken | null> {
167
+ const preferred =
168
+ preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
169
+ if (preferredTokenId !== undefined && !preferred) {
170
+ throw new Error(`Preferred anthropic token ${preferredTokenId} is not available`);
171
+ }
172
+
173
+ const rows = preferred ? [preferred] : [...this.tokenPool.values()];
167
174
  if (rows.length === 0) return null;
168
175
  const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
169
176
  if (eligible.length === 0) {
@@ -179,6 +186,8 @@ export class AnthropicDispatcher implements ProviderDispatcher {
179
186
  );
180
187
  }
181
188
 
189
+ if (preferred) return preferred;
190
+
182
191
  const selectedId = this.weightedSelector.select(new Set(eligible.map((token) => token.id)));
183
192
  if (selectedId === null) return null;
184
193
  return this.tokenPool.get(selectedId) ?? null;
@@ -305,7 +314,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
305
314
  const model = canonicalAnthropicModel(req.model);
306
315
  const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
307
316
 
308
- const token = await this.selectToken();
317
+ const token = await this.selectToken(req.preferredTokenId);
309
318
  if (!token) throw new Error("No anthropic tokens available");
310
319
 
311
320
  const exec = async (): Promise<GenerateResult> => {
@@ -64,6 +64,8 @@ export interface GenerateRequest {
64
64
  // and prompt-cache routing without relying on a process-local worker/thread.
65
65
  promptCacheKey?: string;
66
66
  preferredTokenId?: number;
67
+ // 사람이 지정한 exact target. Cache affinity 선호와 달리 부적격 시 다른 토큰으로 대체하지 않는다.
68
+ requirePreferredToken?: boolean;
67
69
  // 재사용 좌표 + delta input. 둘은 한 쌍 — 검증 통과 시에만 설정한다.
68
70
  // dispatcher 가 worker/thread 생존을 재검증해 성공하면 reuseInput(delta)을, 실패하면
69
71
  // coldInput + coldHistory 로 폴백한다(전체 history 로 문맥 복구).
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
+ import { QuotaThresholdExceededError } from "../../../application/qgrid/qgrid.types";
3
4
  import { type GenerateRequest } from "../common/provider-dispatcher";
4
5
  import {
5
6
  type OpenAINormalizedEvent,
@@ -178,6 +179,35 @@ describe("OpenAIDispatcher direct runtime", () => {
178
179
  expect((await d.generate(request())).tokenName).toBe("two");
179
180
  });
180
181
 
182
+ it("required preferred token 이 없으면 다른 토큰으로 대체하지 않는다", async () => {
183
+ const d = dispatcher(() => events({ type: "completed", responseId: "r" }));
184
+ await d.onTokenAdded(1, "one", credentials);
185
+
186
+ await expect(
187
+ d.generate(request({ preferredTokenId: 99, requirePreferredToken: true })),
188
+ ).rejects.toThrow("Preferred openai token 99 is not available");
189
+ });
190
+
191
+ it("required preferred token 이 threshold 를 넘으면 eligible 토큰으로 대체하지 않는다", async () => {
192
+ const d = new OpenAIDispatcher(config(), {
193
+ clientFactory: () => ({ responses: () => events({ type: "completed", responseId: "r" }) }),
194
+ fetch: async () =>
195
+ new Response(
196
+ JSON.stringify({ rate_limits: { primary_window: { used_percent: 90 } } }),
197
+ { status: 200, headers: { "content-type": "application/json" } },
198
+ ),
199
+ });
200
+ await d.onTokenAdded(1, "one", credentials, null);
201
+ await d.onTokenAdded(2, "two", { ...credentials, accountId: "acct2" }, 80);
202
+
203
+ const error = await d
204
+ .generate(request({ preferredTokenId: 2, requirePreferredToken: true }))
205
+ .catch((caught) => caught);
206
+
207
+ expect(error).toBeInstanceOf(QuotaThresholdExceededError);
208
+ expect(error.message).toContain("two (threshold 80%)");
209
+ });
210
+
181
211
  it("runs concurrent requests without queueing and tracks in-flight counts", async () => {
182
212
  const releases: Array<() => void> = [];
183
213
  const d = dispatcher(() => ({
@@ -272,7 +272,11 @@ export class OpenAIDispatcher implements ProviderDispatcher {
272
272
  ): Promise<GenerateResult> {
273
273
  const signal = activeSignal(req.abortSignal, req.timeoutMs);
274
274
  if (signal.aborted) throw abortError(signal);
275
- const selection = await this.selectToken(req.preferredTokenId, signal);
275
+ const selection = await this.selectToken(
276
+ req.preferredTokenId,
277
+ req.requirePreferredToken ?? false,
278
+ signal,
279
+ );
276
280
  this.inFlightCount++;
277
281
  try {
278
282
  if (signal.aborted) throw abortError(signal);
@@ -420,6 +424,7 @@ export class OpenAIDispatcher implements ProviderDispatcher {
420
424
  */
421
425
  private async selectToken(
422
426
  preferredTokenId: number | undefined,
427
+ requirePreferredToken: boolean,
423
428
  signal: AbortSignal,
424
429
  ): Promise<TokenSelection> {
425
430
  if (preferredTokenId !== undefined) {
@@ -427,6 +432,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
427
432
  if (preferred?.active && (await this.isQuotaEligible(preferredTokenId, preferred, signal))) {
428
433
  return { tokenId: preferredTokenId, metadata: preferred };
429
434
  }
435
+ if (requirePreferredToken) {
436
+ if (!preferred?.active) {
437
+ throw new Error(`Preferred openai token ${preferredTokenId} is not available`);
438
+ }
439
+ const threshold = preferred.quotaThreshold;
440
+ if (threshold !== null && threshold !== undefined) {
441
+ throw new QuotaThresholdExceededError(
442
+ quotaMessage([{ name: preferred.name, threshold }]),
443
+ );
444
+ }
445
+ }
430
446
  }
431
447
 
432
448
  // 토큰별 quota 판정은 서로 독립이라 병렬로 — 직렬 대기는 콜드 캐시에서
@@ -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 duration = source.windowDurationMins ?? source.window_minutes;
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: duration === null || duration === undefined ? null : Number(duration),
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
  }