@cartanova/qgrid-cli 2.7.9 → 2.9.0

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 (85) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/qgrid/expired-token-reminder.js +9 -8
  4. package/bundle/dist/application/qgrid/oauth.js +5 -2
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +8 -14
  6. package/bundle/dist/application/qgrid/qgrid.types.js +1 -1
  7. package/bundle/dist/application/qgrid/token-death.js +12 -31
  8. package/bundle/dist/application/qgrid/token-trigger-setup.js +2 -1
  9. package/bundle/dist/application/sonamu.generated.js +3 -1
  10. package/bundle/dist/application/sonamu.generated.sso.js +2 -1
  11. package/bundle/dist/application/token/token.model.js +66 -47
  12. package/bundle/dist/application/token/token.types.js +2 -1
  13. package/bundle/dist/i18n/sd.generated.js +2 -1
  14. package/bundle/dist/migrations/20260901163614_alter_tokens_add1_alter1.js +15 -0
  15. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +13 -3
  16. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +12 -7
  17. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +8 -5
  18. package/bundle/dist/utils/providers/common/model-cost.js +9 -11
  19. package/bundle/dist/utils/providers/openai/openai-refresh.js +6 -4
  20. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +8 -6
  21. package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -8
  22. package/bundle/src/application/qgrid/oauth.test.ts +28 -0
  23. package/bundle/src/application/qgrid/oauth.ts +5 -0
  24. package/bundle/src/application/qgrid/qgrid.frame.test.ts +22 -0
  25. package/bundle/src/application/qgrid/qgrid.frame.ts +8 -15
  26. package/bundle/src/application/qgrid/qgrid.types.ts +1 -1
  27. package/bundle/src/application/qgrid/token-death.test.ts +62 -58
  28. package/bundle/src/application/qgrid/token-death.ts +15 -39
  29. package/bundle/src/application/qgrid/token-trigger-setup.test.ts +6 -0
  30. package/bundle/src/application/qgrid/token-trigger-setup.ts +1 -0
  31. package/bundle/src/application/sonamu.generated.http +0 -27
  32. package/bundle/src/application/sonamu.generated.sso.ts +1 -0
  33. package/bundle/src/application/sonamu.generated.ts +3 -0
  34. package/bundle/src/application/token/token.entity.json +7 -0
  35. package/bundle/src/application/token/token.model.test.ts +112 -37
  36. package/bundle/src/application/token/token.model.ts +76 -48
  37. package/bundle/src/application/token/token.types.ts +1 -0
  38. package/bundle/src/i18n/sd.generated.ts +1 -0
  39. package/bundle/src/migrations/20260901163614_alter_tokens_add1_alter1.ts +15 -0
  40. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +25 -1
  41. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +23 -4
  42. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +36 -0
  43. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +20 -7
  44. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +28 -0
  45. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +18 -2
  46. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +14 -10
  47. package/bundle/src/utils/providers/common/model-cost.test.ts +19 -19
  48. package/bundle/src/utils/providers/common/model-cost.ts +19 -16
  49. package/bundle/src/utils/providers/openai/openai-refresh.test.ts +16 -7
  50. package/bundle/src/utils/providers/openai/openai-refresh.ts +2 -2
  51. package/bundle/web-dist/client/assets/{index-D59Q_9Z4.js → index-DDSQ_6GL.js} +4 -4
  52. package/bundle/web-dist/client/assets/{logs-BHVfI8ut.js → logs-D2OMhBlx.js} +1 -1
  53. package/bundle/web-dist/client/assets/{monit-EkLxH1oN.js → monit-CYLYHrc-.js} +1 -1
  54. package/bundle/web-dist/client/assets/{routes-Kbvuclip.js → routes-DlG0sJHR.js} +2 -2
  55. package/bundle/web-dist/client/assets/{services.generated-Da1Eq5_R.js → services.generated-C6jfEwfK.js} +1 -1
  56. package/bundle/web-dist/client/assets/{settings-B4uTFP3J.js → settings-BeCBndh_.js} +1 -1
  57. package/bundle/web-dist/client/assets/{show-BjIdSPTd.js → show-BdmzN1As.js} +1 -1
  58. package/bundle/web-dist/client/assets/{tokens-BbCSxbFu.js → tokens-KCfMOeTH.js} +1 -1
  59. package/bundle/web-dist/client/assets/{use-oauth-login-flow-4OltfVAd.js → use-oauth-login-flow-2dSjIvBk.js} +1 -1
  60. package/bundle/web-dist/client/assets/{use-update-token-mutation-CZ57EKA1.js → use-update-token-mutation-BIvO70-M.js} +1 -1
  61. package/bundle/web-dist/client/index.html +2 -2
  62. package/bundle/web-dist/server/assets/{lazyRouteComponent-V1S8gqMS.js → lazyRouteComponent-C_k4R5n6.js} +2 -2
  63. package/bundle/web-dist/server/assets/{logs-2Tkqcncq.js → logs-lBOU5eUP.js} +2 -2
  64. package/bundle/web-dist/server/assets/{monit-DrJ15xJL.js → monit-BW2X1D23.js} +2 -2
  65. package/bundle/web-dist/server/assets/{plus-DCai02Na.js → plus-DilmHifT.js} +1 -1
  66. package/bundle/web-dist/server/assets/{react-dom-BFiG5Daf.js → react-dom-CHRRsKwb.js} +1 -1
  67. package/bundle/web-dist/server/assets/{routes-Ct3I9263.js → routes-CxMHXfdH.js} +5 -5
  68. package/bundle/web-dist/server/assets/{services.generated-y7xVOTCo.js → services.generated-buX3cJzR.js} +1 -9
  69. package/bundle/web-dist/server/assets/{settings-JznSjwGQ.js → settings-CFoXQaAg.js} +3 -3
  70. package/bundle/web-dist/server/assets/{show-D8kuAFbc.js → show-CcZJO6WF.js} +2 -2
  71. package/bundle/web-dist/server/assets/{tokens-PR42jF1J.js → tokens-C32lycTe.js} +6 -5
  72. package/bundle/web-dist/server/assets/{use-oauth-login-flow-C-33Z5HA.js → use-oauth-login-flow-DCjxIa80.js} +1 -1
  73. package/bundle/web-dist/server/assets/{use-update-token-mutation-BNaRPVaX.js → use-update-token-mutation-CqK8i8Fa.js} +1 -1
  74. package/bundle/web-dist/server/entry-server.generated.js +46 -23
  75. package/package.json +1 -1
  76. package/skills/qgrid/SKILL.md +8 -3
  77. package/skills/qgrid/references/ai-sdk-provider-contract.md +8 -3
  78. package/skills/qgrid/references/anthropic-claude-code-runtime.md +17 -9
  79. package/skills/qgrid/references/cli-env-and-server-boot.md +1 -1
  80. package/skills/qgrid/references/decision-rationale.md +5 -2
  81. package/skills/qgrid/references/docs-workflow.md +3 -3
  82. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  83. package/skills/qgrid/references/sonamu-api-web-flow.md +6 -2
  84. package/skills/qgrid/references/token-auth-quota-lifecycle.md +20 -13
  85. package/skills/qgrid/references/verification-and-debugging.md +19 -16
@@ -1,4 +1,7 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { Sonamu } from "sonamu";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  import { TokenModel } from "./token.model";
4
7
 
@@ -122,6 +125,7 @@ describe("TokenModel.replaceByAccount", () => {
122
125
  id: 1,
123
126
  created_at: new Date(),
124
127
  active: true,
128
+ reauth_required: false,
125
129
  ord: 0,
126
130
  quota_threshold: 80,
127
131
  weight: 1,
@@ -177,51 +181,122 @@ describe("TokenModel.findActiveByProviderAndName", () => {
177
181
  });
178
182
  });
179
183
 
180
- describe("TokenModel.deactivateIfActive", () => {
184
+ describe("TokenModel.findReauthRequired", () => {
181
185
  afterEach(() => {
182
186
  vi.restoreAllMocks();
183
187
  });
184
188
 
185
- function mockKnex(responses: unknown[]) {
186
- const raw = vi.fn();
187
- for (const response of responses) raw.mockResolvedValueOnce(response);
188
- vi.spyOn(TokenModel as unknown as { getPuri: (mode: "w") => unknown }, "getPuri").mockReturnValue(
189
- { knex: { raw } } as unknown as MockPuri,
190
- );
191
- return raw;
192
- }
189
+ it("queries auth-dead tokens instead of every inactive token", async () => {
190
+ const where = vi.fn();
191
+ const orderBy = vi.fn();
192
+ const qb = { where, orderBy };
193
+ vi.spyOn(
194
+ TokenModel as unknown as { getSubsetQueries: (subset: "A") => { qb: typeof qb } },
195
+ "getSubsetQueries",
196
+ ).mockReturnValue({ qb });
197
+ vi.spyOn(
198
+ TokenModel as unknown as {
199
+ executeSubsetQuery: (input: unknown) => Promise<{ rows: unknown[] }>;
200
+ },
201
+ "executeSubsetQuery",
202
+ ).mockResolvedValue({ rows: [] });
193
203
 
194
- it("deactivates in one statement that carries the last-token guard", async () => {
195
- const raw = mockKnex([{ rowCount: 1 }]);
204
+ await TokenModel.findReauthRequired("A");
196
205
 
197
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
198
- deactivated: true,
199
- keptAsLastActive: false,
200
- });
201
- expect(raw).toHaveBeenCalledTimes(1);
202
- const sql = raw.mock.calls[0]![0] as string;
203
- expect(sql).toContain("UPDATE tokens SET active = false");
204
- expect(sql).toContain("count(*)");
206
+ expect(where).toHaveBeenCalledWith("tokens.reauth_required", true);
207
+ expect(where).not.toHaveBeenCalledWith("tokens.active", false);
205
208
  });
209
+ });
206
210
 
207
- it("returns false without a diagnostic read when the token is already inactive", async () => {
208
- const raw = mockKnex([{ rowCount: 0 }, { rows: [] }]);
209
-
210
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
211
- deactivated: false,
212
- keptAsLastActive: false,
213
- });
214
- expect(raw).toHaveBeenCalledTimes(2);
211
+ describe("TokenModel.markReauthRequired", () => {
212
+ beforeAll(async () => {
213
+ await Sonamu.initForTesting();
215
214
  });
216
215
 
217
- it("refuses to deactivate the provider's last active token", async () => {
218
- const raw = mockKnex([{ rowCount: 0 }, { rows: [{ provider: "anthropic" }] }]);
219
-
220
- // 마지막 활성 토큰은 살려두되, 호출부가 systemic 실패로 알릴 수 있게 구분해 알린다.
221
- await expect(TokenModel.deactivateIfActive(7)).resolves.toEqual({
222
- deactivated: false,
223
- keptAsLastActive: true,
224
- });
225
- expect(raw).toHaveBeenCalledTimes(2);
216
+ it("serializes provider deaths, preserves one active token, and rejects stale credentials", async () => {
217
+ const suffix = randomUUID();
218
+ const tokens = [
219
+ {
220
+ ...baseToken,
221
+ name: `auth-state-a-${suffix}`,
222
+ credentials: { ...baseToken.credentials, accountUuid: `account-a-${suffix}` },
223
+ },
224
+ {
225
+ ...baseToken,
226
+ name: `auth-state-b-${suffix}`,
227
+ credentials: { ...baseToken.credentials, accountUuid: `account-b-${suffix}` },
228
+ },
229
+ ];
230
+ const ids = await TokenModel.save(tokens);
231
+
232
+ try {
233
+ await expect(
234
+ TokenModel.markReauthRequired(ids[0]!, {
235
+ ...tokens[0]!.credentials,
236
+ accessToken: "stale-access-token",
237
+ }),
238
+ ).resolves.toEqual({
239
+ marked: false,
240
+ keptAsLastActive: false,
241
+ staleCredentials: true,
242
+ });
243
+
244
+ const results = await Promise.all([
245
+ TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
246
+ TokenModel.markReauthRequired(ids[1]!, tokens[1]!.credentials),
247
+ ]);
248
+ expect(results.every((result) => result.marked)).toBe(true);
249
+
250
+ const { rows } = await TokenModel.findMany("A", { id: ids, num: 2 });
251
+ expect(rows.every((row) => row.reauth_required)).toBe(true);
252
+ expect(rows.filter((row) => row.active)).toHaveLength(1);
253
+
254
+ await expect(
255
+ TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
256
+ ).resolves.toEqual({
257
+ marked: false,
258
+ keptAsLastActive: false,
259
+ staleCredentials: false,
260
+ });
261
+
262
+ const inactive = rows.find((row) => !row.active)!;
263
+ const refreshedCredentials = {
264
+ ...inactive.credentials,
265
+ accessToken: "refreshed-access-token",
266
+ refreshToken: "refreshed-refresh-token",
267
+ };
268
+ await TokenModel.save([
269
+ {
270
+ id: inactive.id,
271
+ provider: inactive.provider,
272
+ credentials: refreshedCredentials,
273
+ name: inactive.name,
274
+ reauth_required: false,
275
+ },
276
+ ]);
277
+ await expect(
278
+ TokenModel.markReauthRequired(inactive.id, inactive.credentials),
279
+ ).resolves.toEqual({
280
+ marked: false,
281
+ keptAsLastActive: false,
282
+ staleCredentials: true,
283
+ });
284
+
285
+ await expect(TokenModel.toggleActive(inactive.id)).resolves.toEqual({
286
+ active: true,
287
+ reauthRequired: false,
288
+ });
289
+ await TokenModel.toggleActive(inactive.id);
290
+ await Promise.all([
291
+ TokenModel.toggleActive(inactive.id),
292
+ TokenModel.markReauthRequired(inactive.id, refreshedCredentials),
293
+ ]);
294
+ await expect(TokenModel.findById("A", inactive.id)).resolves.toMatchObject({
295
+ active: false,
296
+ reauth_required: true,
297
+ });
298
+ } finally {
299
+ await TokenModel.del(ids);
300
+ }
226
301
  });
227
302
  });
@@ -1,3 +1,5 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+
1
3
  import { getLogger } from "@logtape/logtape";
2
4
  import {
3
5
  api,
@@ -12,12 +14,13 @@ import {
12
14
  import { SD } from "../../i18n/sd.generated";
13
15
  import { type TokenSubsetKey, type TokenSubsetMapping } from "../sonamu.generated";
14
16
  import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso";
15
- import { type TokenListParams, type TokenSaveParams } from "./token.types";
17
+ import { type TokenCredentials, type TokenListParams, type TokenSaveParams } from "./token.types";
16
18
 
17
19
  const logger = getLogger(["qgrid", "token"]);
18
20
 
19
21
  const DEFAULT_QUOTA_THRESHOLD = 80;
20
22
  const DEFAULT_WEIGHT = 1;
23
+ const TOKEN_AUTH_DEATH_LOCK_CLASS_ID = 719;
21
24
 
22
25
  export type TokenUpdateFields = {
23
26
  name?: string;
@@ -167,13 +170,10 @@ class TokenModelClass extends BaseModelClass<
167
170
  return result.rows[0];
168
171
  }
169
172
 
170
- /**
171
- * 라우팅에서 빠진 토큰들. 세션 만료로 자동 비활성화된 것과 수동으로 끈 것이 섞여 있다 —
172
- * 둘 다 "재로그인하면 살아나는" 상태라 알림 대상으로는 같게 본다.
173
- */
174
- async findInactive<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
173
+ /** 실제 인증 만료가 확인돼 재로그인이 필요한 토큰들. 수동 비활성화는 제외한다. */
174
+ async findReauthRequired<T extends TokenSubsetKey>(subset: T): Promise<TokenSubsetMapping[T][]> {
175
175
  const { qb } = this.getSubsetQueries(subset);
176
- qb.where("tokens.active", false);
176
+ qb.where("tokens.reauth_required", true);
177
177
  qb.orderBy("tokens.name", "asc");
178
178
  const enhancers = this.createEnhancers({ A: (row) => ({ ...row }) });
179
179
  const result = await this.executeSubsetQuery({
@@ -200,7 +200,6 @@ class TokenModelClass extends BaseModelClass<
200
200
  return result.rows;
201
201
  }
202
202
 
203
- @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
204
203
  async save(spa: TokenSaveParams[]): Promise<number[]> {
205
204
  const wdb = this.getPuri("w");
206
205
  spa.forEach((sp) => {
@@ -216,15 +215,26 @@ class TokenModelClass extends BaseModelClass<
216
215
  return wdb.transaction((trx) => trx.table("tokens").where("id", id).update(fields));
217
216
  }
218
217
 
219
- /**
220
- * 세션 만료 토큰을 라우팅에서 제외한다. `active=true` 조건부 갱신이므로 공유 DB 를 쓰는
221
- * 여러 인스턴스가 동시에 같은 만료를 감지해도 true 를 받는 프로세스는 하나뿐이다
222
- * (호출부의 알림 발송 게이트).
223
- *
224
- * 단, 대상이 해당 provider 의 마지막 활성 토큰이면 갱신하지 않고 false 를 반환한다.
225
- * client_id 취소나 OAuth 계약 변경처럼 전 토큰이 동시에 실패하는 상황에서 풀이
226
- * 통째로 비는 것을 막는 안전판이다.
227
- */
218
+ async toggleActive(id: number): Promise<{ active: boolean; reauthRequired: boolean } | null> {
219
+ const wdb = this.getPuri("w");
220
+ return wdb.transaction(async (trx) => {
221
+ const token = await trx
222
+ .from("tokens")
223
+ .select({ active: "active", reauth_required: "reauth_required" })
224
+ .where("id", id)
225
+ .forUpdate()
226
+ .first();
227
+ if (!token) return null;
228
+ if (!token.active && token.reauth_required) {
229
+ return { active: false, reauthRequired: true };
230
+ }
231
+
232
+ const active = !token.active;
233
+ await trx.from("tokens").where("id", id).update({ active });
234
+ return { active, reauthRequired: false };
235
+ });
236
+ }
237
+
228
238
  /**
229
239
  * 같은 계정의 기존 토큰을 지우고 새로 저장한다(로그인·재로그인 공통 경로).
230
240
  *
@@ -257,43 +267,61 @@ class TokenModelClass extends BaseModelClass<
257
267
  }
258
268
 
259
269
  /**
260
- * @returns `deactivated` 는 이 호출이 실제로 비활성화를 수행했는지 — 호출부의 알림
261
- * 게이트다. `keptAsLastActive` 는 마지막 활성 토큰이라 거부한 경우로, 사망 자체는
262
- * 사실이지만 풀을 비우지 않으려고 살려둔 상태다(systemic 실패 신호).
270
+ * 인증 사망을 한 번만 기록하고, 다른 활성 토큰이 있으면 라우팅에서도 제외한다.
271
+ * 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
272
+ * 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
263
273
  */
264
- async deactivateIfActive(
274
+ async markReauthRequired(
265
275
  id: number,
266
- ): Promise<{ deactivated: boolean; keptAsLastActive: boolean }> {
276
+ expectedCredentials: TokenCredentials,
277
+ ): Promise<{ marked: boolean; keptAsLastActive: boolean; staleCredentials: boolean }> {
267
278
  const wdb = this.getPuri("w");
279
+ return wdb.transaction(async (trx) => {
280
+ const token = await trx
281
+ .from("tokens")
282
+ .select({
283
+ provider: "provider",
284
+ credentials: "credentials",
285
+ reauth_required: "reauth_required",
286
+ })
287
+ .where("id", id)
288
+ .forUpdate()
289
+ .first();
290
+ if (!token || token.reauth_required) {
291
+ return { marked: false, keptAsLastActive: false, staleCredentials: false };
292
+ }
293
+ if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
294
+ return { marked: false, keptAsLastActive: false, staleCredentials: true };
295
+ }
268
296
 
269
- // 활성 여부와 "마지막 남은 토큰인가"를 한 statement 안에서 판정한다. 두 조건을
270
- // 따로 읽고 나중에 갱신하면 그 사이에 다른 인스턴스가 같은 provider 의 토큰을
271
- // 비활성화해 풀이 통째로 빌 수 있다.
272
- const updated = await wdb.knex.raw<{ rowCount: number }>(
273
- `UPDATE tokens SET active = false
274
- WHERE id = ? AND active = true
275
- AND (SELECT count(*) FROM tokens peer
276
- WHERE peer.provider = tokens.provider AND peer.active) > 1`,
277
- [id],
278
- );
279
- if ((updated.rowCount ?? 0) > 0) return { deactivated: true, keptAsLastActive: false };
280
-
281
- // 갱신되지 않은 이유는 둘 중 하나다: 이미 비활성이거나(정상 경쟁 결과), 마지막
282
- // 활성 토큰이거나. 후자는 systemic 실패 신호라 남겨야 하므로 이때만 한 번 더 읽는다.
283
- const survivor = (
284
- await wdb.knex.raw<{ rows: { provider: string }[] }>(
285
- "SELECT provider FROM tokens WHERE id = ? AND active = true",
297
+ await trx.knex.raw("SELECT pg_advisory_xact_lock(?, hashtext(?))", [
298
+ TOKEN_AUTH_DEATH_LOCK_CLASS_ID,
299
+ token.provider,
300
+ ]);
301
+ const updated = await trx.knex.raw<{ rows: { active: boolean }[] }>(
302
+ `UPDATE tokens
303
+ SET reauth_required = true,
304
+ active = CASE
305
+ WHEN active AND (SELECT count(*) FROM tokens peer
306
+ WHERE peer.provider = tokens.provider AND peer.active) > 1
307
+ THEN false
308
+ ELSE active
309
+ END
310
+ WHERE id = ? AND reauth_required = false
311
+ RETURNING active`,
286
312
  [id],
287
- )
288
- ).rows[0];
289
- if (survivor) {
290
- logger.error(
291
- `refusing to auto-deactivate token ${id}: last active ${survivor.provider} token — ` +
292
- `systemic refresh failure suspected, keeping the pool non-empty`,
293
313
  );
294
- return { deactivated: false, keptAsLastActive: true };
295
- }
296
- return { deactivated: false, keptAsLastActive: false };
314
+ const marked = updated.rows[0];
315
+ if (!marked) return { marked: false, keptAsLastActive: false, staleCredentials: false };
316
+
317
+ if (marked.active) {
318
+ logger.error(
319
+ `refusing to auto-deactivate token ${id}: last active provider token — ` +
320
+ `systemic refresh failure suspected, keeping the pool non-empty`,
321
+ );
322
+ }
323
+ return { marked: true, keptAsLastActive: marked.active, staleCredentials: false };
324
+ });
297
325
  }
298
326
 
299
327
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
@@ -38,6 +38,7 @@ export const TokenSaveParams = TokenBaseSchema.partial({
38
38
  id: true,
39
39
  created_at: true,
40
40
  active: true,
41
+ reauth_required: true,
41
42
  ord: true,
42
43
  quota_threshold: true,
43
44
  weight: true,
@@ -317,6 +317,7 @@ const entityLabels = {
317
317
  "entity.Token.credentials": "인증 정보 (JSONB)",
318
318
  "entity.Token.name": "토큰 이름",
319
319
  "entity.Token.active": "활성 여부",
320
+ "entity.Token.reauth_required": "인증 만료로 재로그인이 필요한지 여부",
320
321
  "entity.Token.ord": "정렬 순서",
321
322
  "entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
322
323
  "entity.Token.weight": "가중 라운드로빈 라우팅 가중치 (1..100)",
@@ -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("reauth_required").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("reauth_required");
14
+ });
15
+ }
@@ -5,6 +5,7 @@ import {
5
5
  assertSupportedOneMillionSuffix,
6
6
  canonicalAnthropicModel,
7
7
  hasOneMillionSuffix,
8
+ isFableFamilyModel,
8
9
  needsCli1mSuffix,
9
10
  supports1MContext,
10
11
  usesAdaptiveThinking,
@@ -18,6 +19,7 @@ describe("canonicalAnthropicModel", () => {
18
19
  it("provider prefix 를 제거한다", () => {
19
20
  expect(canonicalAnthropicModel("anthropic/claude-opus-4-8")).toBe("claude-opus-4-8");
20
21
  expect(canonicalAnthropicModel("anthropic/claude-fable-5")).toBe("claude-fable-5");
22
+ expect(canonicalAnthropicModel("anthropic/claude-fable-5-1")).toBe("claude-fable-5-1");
21
23
  });
22
24
 
23
25
  it("[1m] suffix 를 base canonical 에서 제거한다", () => {
@@ -28,6 +30,7 @@ describe("canonicalAnthropicModel", () => {
28
30
 
29
31
  describe("Anthropic 1M context policy", () => {
30
32
  it("지원 모델은 실측 확인된 exact set 만 true", () => {
33
+ expect(supports1MContext("claude-fable-5-1")).toBe(true);
31
34
  expect(supports1MContext("claude-fable-5")).toBe(true);
32
35
  expect(supports1MContext("claude-opus-5")).toBe(true);
33
36
  expect(supports1MContext("claude-sonnet-5")).toBe(true);
@@ -42,6 +45,7 @@ describe("Anthropic 1M context policy", () => {
42
45
  });
43
46
 
44
47
  it("CLI suffix 필요 모델과 기본 1M 모델을 분리한다", () => {
48
+ expect(needsCli1mSuffix("claude-fable-5-1")).toBe(false);
45
49
  expect(needsCli1mSuffix("claude-fable-5")).toBe(false);
46
50
  expect(needsCli1mSuffix("claude-opus-5")).toBe(false);
47
51
  expect(needsCli1mSuffix("claude-sonnet-5")).toBe(false);
@@ -65,10 +69,18 @@ describe("Anthropic 1M context policy", () => {
65
69
  );
66
70
  expect(() => assertSupportedOneMillionSuffix("anthropic/claude-sonnet-4-6[1m]")).not.toThrow();
67
71
  });
72
+
73
+ it("native 1M 모델의 [1m] suffix 는 허용하되 CLI 에는 suffix 없이 전달한다", () => {
74
+ expect(() => assertSupportedOneMillionSuffix("anthropic/claude-fable-5-1[1m]")).not.toThrow();
75
+ expect(supports1MContext("anthropic/claude-fable-5-1[1m]")).toBe(true);
76
+ expect(needsCli1mSuffix("anthropic/claude-fable-5-1[1m]")).toBe(false);
77
+ });
68
78
  });
69
79
 
70
80
  describe("Anthropic thinking policy", () => {
71
- it("Fable 5 와 Opus 5 는 adaptive thinking 을 보존한다", () => {
81
+ it("Fable 5/5.1 과 Opus 5 는 adaptive thinking 을 보존한다", () => {
82
+ expect(usesAdaptiveThinking("claude-fable-5-1")).toBe(true);
83
+ expect(usesAdaptiveThinking("anthropic/claude-fable-5-1")).toBe(true);
72
84
  expect(usesAdaptiveThinking("claude-fable-5")).toBe(true);
73
85
  expect(usesAdaptiveThinking("anthropic/claude-fable-5")).toBe(true);
74
86
  expect(usesAdaptiveThinking("claude-opus-5")).toBe(true);
@@ -77,3 +89,15 @@ describe("Anthropic thinking policy", () => {
77
89
  expect(usesAdaptiveThinking("claude-opus-4-8")).toBe(false);
78
90
  });
79
91
  });
92
+
93
+ describe("Anthropic Fable family", () => {
94
+ it("Fable 5/5.1 만 Fable 계열로 판정하고 prefix/[1m] 은 무시한다", () => {
95
+ expect(isFableFamilyModel("claude-fable-5-1")).toBe(true);
96
+ expect(isFableFamilyModel("claude-fable-5")).toBe(true);
97
+ expect(isFableFamilyModel("anthropic/claude-fable-5-1[1m]")).toBe(true);
98
+
99
+ expect(isFableFamilyModel("claude-opus-5")).toBe(false);
100
+ expect(isFableFamilyModel("claude-sonnet-4-6")).toBe(false);
101
+ expect(isFableFamilyModel(undefined)).toBe(false);
102
+ });
103
+ });
@@ -45,6 +45,7 @@ export function hasOneMillionSuffix(model?: string): boolean {
45
45
  // 1M 지원 여부는 Claude Code 내부의 광범위한 prefix 판정이 아니라 qgrid에서 확인한 exact set 으로
46
46
  // 고정한다. opus-4-7 은 실측 전까지 미포함. 새 모델 추가 시 공식 사양과 실제 CLI 동작을 확인한다.
47
47
  const ONE_MILLION_CONTEXT_MODELS = new Set([
48
+ "claude-fable-5-1",
48
49
  "claude-fable-5",
49
50
  "claude-opus-5",
50
51
  "claude-sonnet-5",
@@ -54,10 +55,16 @@ const ONE_MILLION_CONTEXT_MODELS = new Set([
54
55
  ]);
55
56
 
56
57
  const CLI_ONE_MILLION_SUFFIX_MODELS = new Set(["claude-sonnet-4-6", "claude-opus-4-6"]);
57
- // Fable 5 는 adaptive thinking 이 유일한 모드다. Opus 5 는 disabled 를 high 이하에서 지원하지만,
58
- // 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid 기본 effort=low 가 비용/지연 제어를
59
- // 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시 tool/XML 출력 오염도 피한다.
60
- const ADAPTIVE_THINKING_MODELS = new Set(["claude-fable-5", "claude-opus-5"]);
58
+ // Fable 5/5.1 은 adaptive thinking 이 유일한 모드다(CLI 카탈로그 `rejects_disabled_thinking`). Opus 5 는
59
+ // disabled 를 high 이하에서 지원하지만, 공식 기본값·권장 동작인 adaptive thinking 을 유지한다. qgrid
60
+ // 기본 effort=low 가 비용/지연 제어를 맡으며, 이 정책은 disabled+xhigh/max 400 오류와 disabled 시
61
+ // tool/XML 출력 오염도 피한다.
62
+ const ADAPTIVE_THINKING_MODELS = new Set(["claude-fable-5-1", "claude-fable-5", "claude-opus-5"]);
63
+
64
+ // Fable 계열: 구독 플랜에서 usage credits 가 필요한 최상위 티어. 구독 usage API 의
65
+ // `seven_day_overage_included` 버킷은 이 계열에만 적용되므로 quota threshold 판정이 참조한다.
66
+ // Mythos 는 Project Glasswing 조직 전용이라 구독 토큰 경로에서 도달할 수 없어 포함하지 않는다.
67
+ const FABLE_FAMILY_MODELS = new Set(["claude-fable-5-1", "claude-fable-5"]);
61
68
 
62
69
  // 불변 계약: suffix 가 필요한 모델은 반드시 1M 지원 모델의 부분집합이어야 한다. 역방향 불일치(suffix
63
70
  // 대상인데 지원 집합엔 없음)면 needsCli1mSuffix=true·supports1MContext=false 가 동시에 나서
@@ -68,6 +75,13 @@ for (const model of CLI_ONE_MILLION_SUFFIX_MODELS) {
68
75
  throw new Error(`[invariant] CLI 1M suffix model not in context set: ${model}`);
69
76
  }
70
77
  }
78
+ // Fable 계열은 공식 사양상 1M 기본 컨텍스트 + adaptive thinking 필수다. 새 Fable 버전을 계열 Set
79
+ // 에만 넣고 나머지를 빠뜨리는 실수를 같은 방식으로 모듈 로드 시점에 잡는다.
80
+ for (const model of FABLE_FAMILY_MODELS) {
81
+ if (!ONE_MILLION_CONTEXT_MODELS.has(model) || !ADAPTIVE_THINKING_MODELS.has(model)) {
82
+ throw new Error(`[invariant] Fable family model missing from 1M/adaptive set: ${model}`);
83
+ }
84
+ }
71
85
 
72
86
  export function supports1MContext(model?: string): boolean {
73
87
  return ONE_MILLION_CONTEXT_MODELS.has(canonicalAnthropicModel(model));
@@ -83,6 +97,11 @@ export function usesAdaptiveThinking(model?: string): boolean {
83
97
  return ADAPTIVE_THINKING_MODELS.has(canonicalAnthropicModel(model));
84
98
  }
85
99
 
100
+ // 구독 usage 의 Fable 전용 7일 버킷을 quota 판정에 포함할지 결정한다.
101
+ export function isFableFamilyModel(model?: string): boolean {
102
+ return FABLE_FAMILY_MODELS.has(canonicalAnthropicModel(model));
103
+ }
104
+
86
105
  export function assertSupportedOneMillionSuffix(model?: string): void {
87
106
  if (!hasOneMillionSuffix(model)) return;
88
107
  const canonical = canonicalAnthropicModel(model);
@@ -12,12 +12,14 @@ const {
12
12
  runClaudeSessionMock,
13
13
  refreshTokenMock,
14
14
  readAnthropicQuotaUsageMock,
15
+ invalidateAnthropicQuotaUsageMock,
15
16
  loggerInfoMock,
16
17
  loggerWarnMock,
17
18
  } = vi.hoisted(() => ({
18
19
  runClaudeSessionMock: vi.fn(),
19
20
  refreshTokenMock: vi.fn(),
20
21
  readAnthropicQuotaUsageMock: vi.fn(),
22
+ invalidateAnthropicQuotaUsageMock: vi.fn(),
21
23
  loggerInfoMock: vi.fn(),
22
24
  loggerWarnMock: vi.fn(),
23
25
  }));
@@ -33,6 +35,7 @@ vi.mock("./claude-session", async (importActual) => {
33
35
 
34
36
  vi.mock("./anthropic-quota", () => ({
35
37
  readAnthropicQuotaUsage: readAnthropicQuotaUsageMock,
38
+ invalidateAnthropicQuotaUsage: invalidateAnthropicQuotaUsageMock,
36
39
  }));
37
40
 
38
41
  vi.mock("../../../application/qgrid/qgrid.frame", () => ({
@@ -112,6 +115,7 @@ describe("AnthropicDispatcher", () => {
112
115
  refreshTokenMock.mockReset();
113
116
  refreshTokenMock.mockResolvedValue("sk-ant-oat01-refreshed");
114
117
  readAnthropicQuotaUsageMock.mockReset();
118
+ invalidateAnthropicQuotaUsageMock.mockReset();
115
119
  loggerInfoMock.mockReset();
116
120
  loggerWarnMock.mockReset();
117
121
  });
@@ -211,6 +215,21 @@ describe("AnthropicDispatcher", () => {
211
215
  runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
212
216
 
213
217
  await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
218
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenCalledWith("sk-ant-oat01-test");
219
+ });
220
+
221
+ it("refresh 후 quota 소진은 선택 전후 access token cache를 모두 무효화한다", async () => {
222
+ const d = new AnthropicDispatcher();
223
+ d.onTokenAdded(1, "tok-A", creds(10_000), null, 1);
224
+ runClaudeSessionMock.mockResolvedValueOnce(sessionResult({ quotaExhausted: true }));
225
+
226
+ await expect(d.generate(baseReq())).rejects.toThrow(/quota exhausted/);
227
+
228
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(1, "sk-ant-oat01-test");
229
+ expect(invalidateAnthropicQuotaUsageMock).toHaveBeenNthCalledWith(
230
+ 2,
231
+ "sk-ant-oat01-refreshed",
232
+ );
214
233
  });
215
234
 
216
235
  it("claude 에러 → 에러", async () => {
@@ -306,6 +325,23 @@ describe("AnthropicDispatcher", () => {
306
325
  expect((await d.generate(baseReq())).tokenName).toBe("tok-B");
307
326
  });
308
327
 
328
+ it("Fable 요청은 Fable 전용 quota가 소진된 토큰을 건너뛴다", async () => {
329
+ const d = new AnthropicDispatcher();
330
+ d.onTokenAdded(1, "tok-A", creds(), 80, 1);
331
+ d.onTokenAdded(2, "tok-B", creds(), 80, 1);
332
+ readAnthropicQuotaUsageMock
333
+ .mockResolvedValueOnce(quotaOk(100))
334
+ .mockResolvedValueOnce(quotaOk(10));
335
+
336
+ const result = await d.generate(baseReq({ model: "claude-fable-5" }));
337
+
338
+ expect(result.tokenName).toBe("tok-B");
339
+ expect(readAnthropicQuotaUsageMock).toHaveBeenCalledWith(
340
+ expect.any(String),
341
+ "claude-fable-5",
342
+ );
343
+ });
344
+
309
345
  it("resets the schedule when a token weight changes", async () => {
310
346
  const d = new AnthropicDispatcher();
311
347
  d.onTokenAdded(1, "tok-A", creds(), null, 1);
@@ -26,7 +26,11 @@ import {
26
26
  import { serializeAndValidateDispatchSchema } from "../common/schema-validation";
27
27
  import { SmoothWeightedRoundRobin } from "../common/smooth-weighted-round-robin";
28
28
  import { assertSupportedOneMillionSuffix, canonicalAnthropicModel } from "./anthropic-constants";
29
- import { readAnthropicQuotaUsage, type AnthropicQuotaUsageResult } from "./anthropic-quota";
29
+ import {
30
+ invalidateAnthropicQuotaUsage,
31
+ readAnthropicQuotaUsage,
32
+ type AnthropicQuotaUsageResult,
33
+ } from "./anthropic-quota";
30
34
  import { makeAnthropicWorkerId, runClaudeSession } from "./claude-session";
31
35
 
32
36
  const logger = getLogger(["qgrid", "anthropic-dispatcher"]);
@@ -163,7 +167,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
163
167
 
164
168
  // 지정 요청은 해당 토큰만 quota 판정하고 weighted 상태를 건드리지 않는다.
165
169
  // 미지정 요청만 quota 통과 후보로 smooth weighted round-robin 을 진행한다.
166
- private async selectToken(preferredTokenId?: number): Promise<PooledToken | null> {
170
+ private async selectToken(model: string, preferredTokenId?: number): Promise<PooledToken | null> {
167
171
  const preferred =
168
172
  preferredTokenId === undefined ? undefined : this.tokenPool.get(preferredTokenId);
169
173
  if (preferredTokenId !== undefined && !preferred) {
@@ -172,7 +176,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
172
176
 
173
177
  const rows = preferred ? [preferred] : [...this.tokenPool.values()];
174
178
  if (rows.length === 0) return null;
175
- const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows);
179
+ const { eligible, overThresholdTokens } = await this.filterEligibleTokens(rows, model);
176
180
  if (eligible.length === 0) {
177
181
  logger.warn("quota_threshold gate: all_exceeded", {
178
182
  provider: "anthropic",
@@ -193,7 +197,10 @@ export class AnthropicDispatcher implements ProviderDispatcher {
193
197
  return this.tokenPool.get(selectedId) ?? null;
194
198
  }
195
199
 
196
- private async filterEligibleTokens(rows: PooledToken[]): Promise<{
200
+ private async filterEligibleTokens(
201
+ rows: PooledToken[],
202
+ model: string,
203
+ ): Promise<{
197
204
  eligible: PooledToken[];
198
205
  overThresholdTokens: Array<{ tokenName: string; threshold: number }>;
199
206
  }> {
@@ -207,7 +214,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
207
214
  const checks = await Promise.allSettled(
208
215
  thresholded.map(async (token) => ({
209
216
  token,
210
- result: await readAnthropicQuotaUsage(token.credentials.accessToken),
217
+ result: await readAnthropicQuotaUsage(token.credentials.accessToken, model),
211
218
  })),
212
219
  );
213
220
 
@@ -314,7 +321,7 @@ export class AnthropicDispatcher implements ProviderDispatcher {
314
321
  const model = canonicalAnthropicModel(req.model);
315
322
  const jsonSchema = serializeAndValidateDispatchSchema(req.outputSchema, "anthropic");
316
323
 
317
- const token = await this.selectToken(req.preferredTokenId);
324
+ const token = await this.selectToken(model, req.preferredTokenId);
318
325
  if (!token) throw new Error("No anthropic tokens available");
319
326
 
320
327
  const exec = async (): Promise<GenerateResult> => {
@@ -359,7 +366,13 @@ export class AnthropicDispatcher implements ProviderDispatcher {
359
366
  onDelta,
360
367
  );
361
368
 
362
- if (session.quotaExhausted) throw new Error(`quota exhausted (${token.name})`);
369
+ if (session.quotaExhausted) {
370
+ invalidateAnthropicQuotaUsage(token.credentials.accessToken);
371
+ if (accessToken !== token.credentials.accessToken) {
372
+ invalidateAnthropicQuotaUsage(accessToken);
373
+ }
374
+ throw new Error(`quota exhausted (${token.name})`);
375
+ }
363
376
  if (session.isError) {
364
377
  // 진단(SON-495): isError 판정 사유(subtype/terminal_reason)를 메시지 앞에 드러낸다.
365
378
  // 그동안 detail 이 session.text(완전한 JSON 본문)뿐이라 "schema 위반인지 / max retries 인지 /