@cartanova/qgrid-cli 2.6.1 → 2.6.4

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 (81) hide show
  1. package/README.ko.md +2 -0
  2. package/README.md +2 -0
  3. package/bundle/dist/application/qgrid/oauth.js +21 -3
  4. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +40 -6
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +47 -19
  6. package/bundle/dist/application/qgrid/qgrid.types.js +13 -3
  7. package/bundle/dist/application/qgrid/token-death.js +77 -0
  8. package/bundle/dist/application/queries.generated.js +3 -2
  9. package/bundle/dist/application/request-log/request-log.model.js +105 -31
  10. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  11. package/bundle/dist/application/sonamu.generated.js +60 -5
  12. package/bundle/dist/application/sonamu.generated.sso.js +18 -2
  13. package/bundle/dist/application/token/token.model.js +53 -2
  14. package/bundle/dist/i18n/en.js +3 -1
  15. package/bundle/dist/i18n/ko.js +3 -1
  16. package/bundle/dist/i18n/sd.generated.js +18 -1
  17. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +49 -0
  18. package/bundle/dist/migrations/20260807145144_alter_request_logs_alter1.js +13 -0
  19. package/bundle/dist/migrations/20260807164057_alter_request_logs_alter1.js +13 -0
  20. package/bundle/dist/server-error-handler.js +5 -3
  21. package/bundle/dist/sonamu.config.js +5 -1
  22. package/bundle/dist/utils/providers/openai/openai-refresh.js +12 -1
  23. package/bundle/dist/utils/slack-notify.js +65 -0
  24. package/bundle/src/application/qgrid/oauth.ts +20 -1
  25. package/bundle/src/application/qgrid/qgrid-oauth-redirect.test.ts +15 -3
  26. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +30 -2
  27. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +44 -6
  28. package/bundle/src/application/qgrid/qgrid.frame.ts +67 -47
  29. package/bundle/src/application/qgrid/qgrid.types.ts +10 -0
  30. package/bundle/src/application/qgrid/token-death.test.ts +150 -0
  31. package/bundle/src/application/qgrid/token-death.ts +96 -0
  32. package/bundle/src/application/queries.generated.ts +5 -2
  33. package/bundle/src/application/request-log/request-log.entity.json +46 -7
  34. package/bundle/src/application/request-log/request-log.model.test.ts +153 -42
  35. package/bundle/src/application/request-log/request-log.model.ts +136 -50
  36. package/bundle/src/application/sonamu.generated.http +19 -1
  37. package/bundle/src/application/sonamu.generated.sso.ts +16 -0
  38. package/bundle/src/application/sonamu.generated.ts +58 -3
  39. package/bundle/src/application/token/token.model.test.ts +49 -0
  40. package/bundle/src/application/token/token.model.ts +75 -0
  41. package/bundle/src/i18n/en.ts +3 -0
  42. package/bundle/src/i18n/ko.ts +3 -0
  43. package/bundle/src/i18n/sd.generated.ts +17 -0
  44. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +110 -0
  45. package/bundle/src/migrations/20260807145144_alter_request_logs_alter1.ts +13 -0
  46. package/bundle/src/migrations/20260807164057_alter_request_logs_alter1.ts +13 -0
  47. package/bundle/src/server-error-handler.test.ts +58 -0
  48. package/bundle/src/server-error-handler.ts +16 -5
  49. package/bundle/src/sonamu.config.ts +6 -0
  50. package/bundle/src/utils/providers/openai/openai-refresh.ts +17 -0
  51. package/bundle/src/utils/slack-notify.test.ts +97 -0
  52. package/bundle/src/utils/slack-notify.ts +74 -0
  53. package/bundle/web-dist/client/assets/index-BGQSGMcK.css +1 -0
  54. package/bundle/web-dist/client/assets/index-CnNT0mdq.js +78 -0
  55. package/bundle/web-dist/client/assets/logs-DCY81UWb.js +1 -0
  56. package/bundle/web-dist/client/assets/{monit-CyqMmy1G.js → monit-D5OoAday.js} +1 -1
  57. package/bundle/web-dist/client/assets/routes-B4D9C86u.js +5 -0
  58. package/bundle/web-dist/client/assets/{sd.generated-CjW3rfJ8.js → sd.generated-DYdwSB2I.js} +1 -1
  59. package/bundle/web-dist/client/assets/{services.generated-BuT6Gp8V.js → services.generated-BsAvotjG.js} +1 -1
  60. package/bundle/web-dist/client/assets/{show-C5WxfoAt.js → show-BSX8ii7Z.js} +3 -3
  61. package/bundle/web-dist/client/assets/tokens-Gapk41cz.js +1 -0
  62. package/bundle/web-dist/client/assets/{use-update-token-mutation-DBkxL29Y.js → use-update-token-mutation-DqEpK7It.js} +1 -1
  63. package/bundle/web-dist/client/index.html +3 -3
  64. package/bundle/web-dist/server/assets/{lazyRouteComponent-PRxXWW3t.js → lazyRouteComponent-BaZaveNc.js} +1 -1
  65. package/bundle/web-dist/server/assets/{logs-D0z1Ghj1.js → logs-DMOw3SjD.js} +91 -42
  66. package/bundle/web-dist/server/assets/{monit-dBXxnAyI.js → monit-UmcqHroX.js} +2 -2
  67. package/bundle/web-dist/server/assets/{routes-B3p7fuwk.js → routes-Sv_4FjtH.js} +52 -46
  68. package/bundle/web-dist/server/assets/{sd.generated-ek-tz-7H.js → sd.generated-B0xqnHEo.js} +21 -0
  69. package/bundle/web-dist/server/assets/{services.generated-BNuxWs7K.js → services.generated-DqAKskAS.js} +23 -8
  70. package/bundle/web-dist/server/assets/{show-0iNioJ2I.js → show-C-oSdzHX.js} +31 -25
  71. package/bundle/web-dist/server/assets/{tokens-CvytQ9Xy.js → tokens-h-2A3wfD.js} +292 -191
  72. package/bundle/web-dist/server/assets/{use-update-token-mutation-BBCC2GKM.js → use-update-token-mutation-DHCaa7Ku.js} +1 -1
  73. package/bundle/web-dist/server/entry-server.generated.js +70 -29
  74. package/package.json +1 -1
  75. package/skills/qgrid/references/cli-env-and-server-boot.md +8 -0
  76. package/skills/qgrid/references/token-auth-quota-lifecycle.md +11 -0
  77. package/bundle/web-dist/client/assets/index-BGGBFmzE.js +0 -78
  78. package/bundle/web-dist/client/assets/index-GA9b-zy4.css +0 -1
  79. package/bundle/web-dist/client/assets/logs-Dh19u6Tk.js +0 -1
  80. package/bundle/web-dist/client/assets/routes-Cy0gCq8c.js +0 -5
  81. package/bundle/web-dist/client/assets/tokens-Ciolqw4l.js +0 -1
@@ -117,6 +117,25 @@ export async function exchangeCodeForTokens(
117
117
  };
118
118
  }
119
119
 
120
+ /**
121
+ * refresh 실패를 status 와 함께 전달한다. 호출부가 문자열 메시지를 파싱하지 않고
122
+ * 영구 실패(400/401)를 판별할 수 있어야 하기 때문이다.
123
+ */
124
+ export class RefreshFailedError extends Error {
125
+ override readonly name = "RefreshFailedError";
126
+ constructor(
127
+ readonly status: number,
128
+ readonly body: string,
129
+ ) {
130
+ super(`Token refresh failed (${status}): ${body}`);
131
+ }
132
+
133
+ /** 재로그인 외 복구 불가한 인증 실패인지. 일시 오류(5xx·네트워크)는 제외된다. */
134
+ get isAuthDead(): boolean {
135
+ return this.status === 400 || this.status === 401;
136
+ }
137
+ }
138
+
120
139
  export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
121
140
  const res = await fetch(TOKEN_URL, {
122
141
  method: "POST",
@@ -130,7 +149,7 @@ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTok
130
149
  });
131
150
  if (!res.ok) {
132
151
  const text = await res.text();
133
- throw new Error(`Token refresh failed (${res.status}): ${text}`);
152
+ throw new RefreshFailedError(res.status, text);
134
153
  }
135
154
  const data = (await res.json()) as {
136
155
  access_token: string;
@@ -37,6 +37,18 @@ vi.mock("../token/token.model", () => ({
37
37
  save: tokenSaveMock,
38
38
  del: tokenDelMock,
39
39
  findByAccountIdentifier: findByAccountMock,
40
+ // 실제 구현과 같은 순서(dedup → save)로 동작시켜 저장 payload 검증이 유지되게 한다.
41
+ replaceByAccount: async (
42
+ _provider: string,
43
+ accountId: string | undefined,
44
+ saveParams: unknown,
45
+ ) => {
46
+ if (accountId) {
47
+ const olds = (await findByAccountMock()) as unknown as { id: number; active: boolean }[];
48
+ if (olds.length > 0) await tokenDelMock();
49
+ }
50
+ await (tokenSaveMock as unknown as (rows: unknown[]) => Promise<number[]>)([saveParams]);
51
+ },
40
52
  },
41
53
  }));
42
54
 
@@ -106,7 +118,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
106
118
 
107
119
  it("exchanges a pasted code#state against the stored pending state and saves the token", async () => {
108
120
  mockHttpContext({ origin: "https://qgrid.example.com" });
109
- const { authUrl } = await QgridFrame.oauthStart("haze-2");
121
+ const { authUrl } = await QgridFrame.oauthStart("test-token-2");
110
122
  const state = authUrlParam(authUrl, "state")!;
111
123
 
112
124
  exchangeMock.mockResolvedValueOnce({
@@ -119,7 +131,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
119
131
 
120
132
  await expect(QgridFrame.oauthComplete(` the-code#${state} `)).resolves.toEqual({
121
133
  added: true,
122
- name: "haze-2",
134
+ name: "test-token-2",
123
135
  });
124
136
 
125
137
  expect(exchangeMock).toHaveBeenCalledWith(
@@ -131,7 +143,7 @@ describe("QgridFrame.oauthComplete code flow", () => {
131
143
  expect(tokenSaveMock).toHaveBeenCalledWith([
132
144
  expect.objectContaining({
133
145
  provider: "anthropic",
134
- name: "haze-2",
146
+ name: "test-token-2",
135
147
  credentials: expect.objectContaining({ accessToken: "at", accountUuid: "acc-1" }),
136
148
  }),
137
149
  ]);
@@ -56,7 +56,7 @@ describe("QgridDispatcherClass", () => {
56
56
 
57
57
  await expect(
58
58
  dispatcher.query({ prompt: "hi", model: "anthropic/claude-sonnet-4-6" }),
59
- ).rejects.toThrow(/Anthropic dispatcher not initialized/);
59
+ ).rejects.toMatchObject({ statusCode: 503 });
60
60
  });
61
61
 
62
62
  it("AnthropicDispatcher 미초기화 시 queryStream 은 delta 없는 query 폴백 없이 실패한다", async () => {
@@ -71,7 +71,35 @@ describe("QgridDispatcherClass", () => {
71
71
  onError: vi.fn(),
72
72
  },
73
73
  ),
74
- ).rejects.toThrow(/Anthropic dispatcher not initialized/);
74
+ ).rejects.toMatchObject({ statusCode: 503 });
75
+ });
76
+
77
+ // 기동 중과 초기화 실패는 클라이언트의 재시도 판단이 갈리는 지점이라 상태코드로 구분한다.
78
+ it("기동 중 미준비는 503 으로 재시도 가능함을 알린다", async () => {
79
+ const dispatcher = new QgridDispatcherClass();
80
+
81
+ await expect(
82
+ dispatcher.query({ prompt: "hi", model: "openai/gpt-5.4" }),
83
+ ).rejects.toMatchObject({ statusCode: 503 });
84
+ });
85
+
86
+ it("초기화가 실패로 끝났으면 500 으로 재시도가 무의미함을 알린다", async () => {
87
+ const dispatcher = new QgridDispatcherClass();
88
+ dispatcher.startupState.openai = "failed";
89
+
90
+ await expect(
91
+ dispatcher.query({ prompt: "hi", model: "openai/gpt-5.4" }),
92
+ ).rejects.toMatchObject({ statusCode: 500 });
93
+ });
94
+
95
+ it("provider 별로 기동 상태를 따로 본다", async () => {
96
+ const dispatcher = new QgridDispatcherClass();
97
+ dispatcher.startupState.openai = "failed";
98
+
99
+ // openai 가 실패해도 anthropic 은 여전히 기동 중(재시도 가능)이다.
100
+ await expect(
101
+ dispatcher.query({ prompt: "hi", model: "anthropic/claude-sonnet-4-6" }),
102
+ ).rejects.toMatchObject({ statusCode: 503 });
75
103
  });
76
104
 
77
105
  it("provider prefix 없는 모델은 AnthropicDispatcher 로 암묵 라우팅하지 않는다", async () => {
@@ -4,9 +4,13 @@
4
4
  * - 메모리 캐시 (Map<id, TokenSubsetA>) 는 TokenSubscriber 가 pg LISTEN/NOTIFY 로 갱신
5
5
  * - OpenAI/Anthropic 요청은 각 provider dispatcher 로만 실행
6
6
  * - QuotaError 는 그대로 상위 전파
7
+ * - dispatcher 미준비는 기동 중(503)/초기화 실패(500)로 구분해 던진다
7
8
  */
8
9
 
10
+ import { InternalServerErrorException, ServiceUnavailableException } from "sonamu";
11
+
9
12
  import { type JsonValue } from "../../codex-protocol/serde_json/JsonValue";
13
+ import { SD } from "../../i18n/sd.generated";
10
14
  import { type AnthropicDispatcher } from "../../utils/providers/anthropic/anthropic-dispatcher";
11
15
  import { getAccessToken } from "../../utils/providers/common/credentials";
12
16
  import { calculateCostUsd } from "../../utils/providers/common/model-cost";
@@ -22,8 +26,14 @@ import { strictify } from "../../utils/providers/common/strictifier";
22
26
  import { type OpenAIDispatcher } from "../../utils/providers/openai/openai-dispatcher";
23
27
  import { type TokenSubsetA } from "../sonamu.generated";
24
28
  import { decideConvRouting, issueConvContext } from "./conv-routing";
25
- import { type QueryInput, type QueryOutput, type TokenStats } from "./qgrid.types";
26
- import { maskToken, ProcessError, QuotaError } from "./qgrid.types";
29
+ import {
30
+ maskToken,
31
+ ProcessError,
32
+ type ProviderStartupState,
33
+ type QueryInput,
34
+ type QueryOutput,
35
+ type TokenStats,
36
+ } from "./qgrid.types";
27
37
  import { type TokenSubscriber } from "./token-subscriber";
28
38
  import { applyToolCallEmulation } from "./tool-emulation";
29
39
  import { buildToolCallSchema } from "./tool-emulation-schema";
@@ -39,6 +49,34 @@ export class QgridDispatcherClass {
39
49
  openaiDispatcher: OpenAIDispatcher | null = null;
40
50
  anthropicDispatcher: AnthropicDispatcher | null = null;
41
51
 
52
+ /**
53
+ * provider 별 기동 상태. dispatcher 가 없는 이유를 구분하기 위해 필요하다 —
54
+ * HTTP 리스닝은 dispatcher 준비보다 먼저 열리므로(sonamu.config onStart),
55
+ * 그 사이 들어온 요청은 "잠시 후 되는" 상태와 "재시도해도 안 되는" 상태가 다르다.
56
+ *
57
+ * - `starting`: 워커 spawn 중. dev0 기준 25 워커 × 500ms 간격이라 1~2분 걸린다 → 재시도 가능
58
+ * - `ready`: 정상
59
+ * - `failed`: start() 가 예외로 끝남 → 재시도해도 같은 결과
60
+ */
61
+ startupState: Record<"openai" | "anthropic", ProviderStartupState> = {
62
+ openai: "starting",
63
+ anthropic: "starting",
64
+ };
65
+
66
+ /**
67
+ * dispatcher 가 준비되지 않았을 때 던질 예외를 만든다.
68
+ *
69
+ * 기동 중이면 503(+Retry-After) 으로 재시도 가능함을 알리고, 초기화가 실패로 끝났으면
70
+ * 500 으로 재시도가 무의미함을 알린다. 이전에는 둘 다 QuotaError 였는데, 쿼터 소진이
71
+ * 아닌 상태를 그렇게 표현하면 호출자가 토큰 문제로 오해하고 재시도 판단도 못 한다.
72
+ */
73
+ private notReadyError(provider: "openai" | "anthropic"): Error {
74
+ const label = provider === "openai" ? "OpenAI" : "Anthropic";
75
+ return this.startupState[provider] === "failed"
76
+ ? new InternalServerErrorException(SD("qgrid.dispatcherFailed")(label))
77
+ : new ServiceUnavailableException(SD("qgrid.dispatcherStarting")(label));
78
+ }
79
+
42
80
  countOf(name: string): number {
43
81
  return this.requestCounts.get(name) ?? 0;
44
82
  }
@@ -73,7 +111,7 @@ export class QgridDispatcherClass {
73
111
 
74
112
  // provider prefix routing: 'openai/gpt-5.4' → OpenAIDispatcher
75
113
  if (route.provider === "openai") {
76
- if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
114
+ if (!this.openaiDispatcher) throw this.notReadyError("openai");
77
115
 
78
116
  const decision = decideConvRouting(input);
79
117
  const result = await this.openaiDispatcher.generate({
@@ -105,7 +143,7 @@ export class QgridDispatcherClass {
105
143
  answerKind,
106
144
  });
107
145
  } else if (route.provider === "anthropic") {
108
- if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
146
+ if (!this.anthropicDispatcher) throw this.notReadyError("anthropic");
109
147
 
110
148
  const decision = decideConvRouting(input);
111
149
  const result = await this.anthropicDispatcher.generate({
@@ -142,7 +180,7 @@ export class QgridDispatcherClass {
142
180
  const answerKind = input.jsonSchema ? ("json" as const) : ("text" as const);
143
181
 
144
182
  if (route.provider === "openai") {
145
- if (!this.openaiDispatcher) throw new QuotaError("OpenAI dispatcher not initialized");
183
+ if (!this.openaiDispatcher) throw this.notReadyError("openai");
146
184
 
147
185
  const decision = decideConvRouting(input);
148
186
  await this.openaiDispatcher.generateStream(
@@ -180,7 +218,7 @@ export class QgridDispatcherClass {
180
218
  );
181
219
  return;
182
220
  } else if (route.provider === "anthropic") {
183
- if (!this.anthropicDispatcher) throw new QuotaError("Anthropic dispatcher not initialized");
221
+ if (!this.anthropicDispatcher) throw this.notReadyError("anthropic");
184
222
 
185
223
  const decision = decideConvRouting(input);
186
224
  await this.anthropicDispatcher.generateStream(
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../utils/providers/common/credentials";
14
14
  import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
15
15
  import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
16
+ import { type RequestLogListParams } from "../request-log/request-log.types";
16
17
  import { type TokenSubsetA } from "../sonamu.generated";
17
18
  import { TokenModel, type TokenUpdateFields } from "../token/token.model";
18
19
  import { TokenCredentials } from "../token/token.types";
@@ -24,6 +25,7 @@ import {
24
25
  fetchUsage,
25
26
  generatePKCE,
26
27
  refreshAccessToken,
28
+ RefreshFailedError,
27
29
  } from "./oauth";
28
30
  import {
29
31
  afterQuery,
@@ -45,6 +47,7 @@ import {
45
47
  type TokenStats,
46
48
  type UsageResponse,
47
49
  } from "./qgrid.types";
50
+ import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
48
51
  import { ToolSchemaCompositionError } from "./tool-emulation-schema";
49
52
 
50
53
  const pendingStreams = new Map<string, QueryInput>();
@@ -197,6 +200,9 @@ function createHttpDisconnectHandle(): {
197
200
  const logger = getLogger(["qgrid"]);
198
201
  const oauthLogger = getLogger(["qgrid", "oauth"]);
199
202
 
203
+ // per-token refresh dedup — 회전 겹침으로 인한 사망 오판을 막는다.
204
+ const inflightAnthropicRefresh = new Map<number, Promise<string>>();
205
+
200
206
  class QgridFrameClass extends BaseFrameClass {
201
207
  constructor() {
202
208
  super("Qgrid");
@@ -464,9 +470,11 @@ class QgridFrameClass extends BaseFrameClass {
464
470
  return QgridDispatcher.getStats();
465
471
  }
466
472
 
473
+ // 화면의 목록 필터를 그대로 받아 같은 조건의 합계를 돌려준다 — 목록과 비용이
474
+ // 서로 다른 모수를 보면 사용자에게는 숫자가 어긋난 것으로 읽힌다.
467
475
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
468
- async totalCost(tokenName?: string): Promise<{ usd: number }> {
469
- return { usd: await RequestLogModel.totalCost({ token_name: tokenName }) };
476
+ async totalCost(params?: RequestLogListParams): Promise<{ usd: number }> {
477
+ return { usd: await RequestLogModel.totalCost(params) };
470
478
  }
471
479
 
472
480
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
@@ -474,6 +482,11 @@ class QgridFrameClass extends BaseFrameClass {
474
482
  return { names: await RequestLogModel.distinctProjectNames() };
475
483
  }
476
484
 
485
+ @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
486
+ async modelNames(): Promise<{ names: string[] }> {
487
+ return { names: await RequestLogModel.distinctModelNames() };
488
+ }
489
+
477
490
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
478
491
  async addToken(
479
492
  provider: string,
@@ -601,29 +614,18 @@ class QgridFrameClass extends BaseFrameClass {
601
614
  pending.redirectUri,
602
615
  );
603
616
 
604
- if (tokens.accountUuid) {
605
- const oldEntries = await TokenModel.findByAccountIdentifier(
606
- "A",
607
- "anthropic",
608
- tokens.accountUuid,
609
- );
610
- if (oldEntries.length > 0) {
611
- await TokenModel.del(oldEntries.map((o) => o.id));
612
- }
613
- }
614
-
615
- await TokenModel.save([
616
- {
617
- provider: "anthropic",
618
- credentials: {
619
- accessToken: tokens.accessToken,
620
- refreshToken: tokens.refreshToken,
621
- expiresAt: tokens.expiresAt ?? 0,
622
- accountUuid: tokens.accountUuid ?? "",
623
- },
624
- name: pending.name,
617
+ await TokenModel.replaceByAccount("anthropic", tokens.accountUuid, {
618
+ provider: "anthropic",
619
+ credentials: {
620
+ accessToken: tokens.accessToken,
621
+ refreshToken: tokens.refreshToken,
622
+ expiresAt: tokens.expiresAt ?? 0,
623
+ accountUuid: tokens.accountUuid ?? "",
625
624
  },
626
- ]);
625
+ name: pending.name,
626
+ });
627
+
628
+ notifyTokenAdded(pending.name, "anthropic");
627
629
  }
628
630
 
629
631
  async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
@@ -651,30 +653,19 @@ class QgridFrameClass extends BaseFrameClass {
651
653
  QgridDispatcher.openaiDispatcher
652
654
  .completeBrowserLogin()
653
655
  .then(async (creds) => {
654
- if (creds.accountId) {
655
- const oldEntries = await TokenModel.findByAccountIdentifier(
656
- "A",
657
- "openai",
658
- creds.accountId,
659
- );
660
- if (oldEntries.length > 0) {
661
- await TokenModel.del(oldEntries.map((o) => o.id));
662
- }
663
- }
664
- await TokenModel.save([
665
- {
666
- provider: "openai",
667
- credentials: {
668
- accessToken: creds.accessToken,
669
- refreshToken: creds.refreshToken,
670
- idToken: creds.idToken,
671
- accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
672
- accountId: creds.accountId,
673
- },
674
- name,
656
+ await TokenModel.replaceByAccount("openai", creds.accountId, {
657
+ provider: "openai",
658
+ credentials: {
659
+ accessToken: creds.accessToken,
660
+ refreshToken: creds.refreshToken,
661
+ idToken: creds.idToken,
662
+ accessTokenExpiresAt: Date.now() + 10 * 24 * 3600 * 1000,
663
+ accountId: creds.accountId,
675
664
  },
676
- ]);
665
+ name,
666
+ });
677
667
  logger.info(`OpenAI token saved for ${name}`);
668
+ notifyTokenAdded(name, "openai");
678
669
  })
679
670
  .catch((e) => {
680
671
  logger.warn(`OpenAI browser login failed: ${(e as Error).message}`);
@@ -754,11 +745,36 @@ class QgridFrameClass extends BaseFrameClass {
754
745
  return convertAnthropicUsage(raw);
755
746
  }
756
747
 
748
+ // 동시 refresh 가 회전을 겹치면 뒤늦은 호출이 이미 폐기된 refresh token 을 쓰게 되어
749
+ // 멀쩡한 토큰이 사망으로 오판된다. openai-refresh.ts 와 같은 per-token dedup 을 둔다.
757
750
  async refreshToken(token: TokenSubsetA): Promise<string> {
751
+ const inflight = inflightAnthropicRefresh.get(token.id);
752
+ if (inflight) return inflight;
753
+
754
+ const promise = this.doRefreshToken(token);
755
+ inflightAnthropicRefresh.set(token.id, promise);
756
+ try {
757
+ return await promise;
758
+ } finally {
759
+ inflightAnthropicRefresh.delete(token.id);
760
+ }
761
+ }
762
+
763
+ private async doRefreshToken(token: TokenSubsetA): Promise<string> {
758
764
  const creds = token.credentials;
759
765
  const rt = getRefreshToken(creds);
760
766
  if (!rt) throw new Error("No refresh token");
761
- const refreshed = await refreshAccessToken(rt);
767
+
768
+ let refreshed;
769
+ try {
770
+ refreshed = await refreshAccessToken(rt);
771
+ } catch (e) {
772
+ if (e instanceof RefreshFailedError && e.isAuthDead) {
773
+ await deactivateAuthDeadToken(token, rt, `anthropic:${e.status}`);
774
+ }
775
+ throw e;
776
+ }
777
+
762
778
  const updated = TokenCredentials.parse({
763
779
  ...creds,
764
780
  accessToken: refreshed.accessToken,
@@ -778,10 +794,14 @@ class QgridFrameClass extends BaseFrameClass {
778
794
 
779
795
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
780
796
  async health(): Promise<HealthResponse> {
797
+ const providers = { ...QgridDispatcher.startupState };
781
798
  return {
782
799
  status: "ok",
783
800
  activeTokens: QgridDispatcher.tokens.size,
784
801
  subscriber: QgridDispatcher.subscriber?.status() ?? null,
802
+ // provider 별 라우팅이라 하나만 준비돼도 그 provider 요청은 처리된다.
803
+ ready: Object.values(providers).includes("ready"),
804
+ providers,
785
805
  };
786
806
  }
787
807
  }
@@ -306,10 +306,20 @@ export const SubscriberStatus = z.object({
306
306
  });
307
307
  export type SubscriberStatus = z.infer<typeof SubscriberStatus>;
308
308
 
309
+ export const ProviderStartupState = z.enum(["starting", "ready", "failed"]);
310
+ export type ProviderStartupState = z.infer<typeof ProviderStartupState>;
311
+
309
312
  export const HealthResponse = z.object({
310
313
  status: z.string(),
311
314
  activeTokens: z.number(),
312
315
  subscriber: SubscriberStatus.nullable(),
316
+ // HTTP 리스닝은 dispatcher 기동보다 먼저 열린다. 200 응답만으로는 요청을 처리할 수
317
+ // 있는지 알 수 없어 provider 별 준비 상태를 함께 노출한다.
318
+ ready: z.boolean(),
319
+ providers: z.object({
320
+ openai: ProviderStartupState,
321
+ anthropic: ProviderStartupState,
322
+ }),
313
323
  });
314
324
  export type HealthResponse = z.infer<typeof HealthResponse>;
315
325
 
@@ -0,0 +1,150 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { SLACK_COLOR } from "../../utils/slack-notify";
4
+ import { deactivateAuthDeadToken, notifyTokenAdded } from "./token-death";
5
+
6
+ const { findOneMock, deactivateMock, notifySlackMock } = vi.hoisted(() => ({
7
+ findOneMock: vi.fn(),
8
+ deactivateMock: vi.fn(),
9
+ notifySlackMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../token/token.model", () => ({
13
+ TokenModel: { findOne: findOneMock, deactivateIfActive: deactivateMock },
14
+ }));
15
+
16
+ // SLACK_COLOR 는 상수라 가릴 이유가 없다 — 전송 함수만 스텁한다.
17
+ vi.mock("../../utils/slack-notify", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../utils/slack-notify")>()),
19
+ notifySlack: notifySlackMock,
20
+ }));
21
+
22
+ const token = { id: 7, name: "anthropic/test-token", provider: "anthropic" };
23
+
24
+ function storedRefreshToken(refreshToken: string) {
25
+ return {
26
+ id: 7,
27
+ provider: "anthropic",
28
+ credentials: { accessToken: "at", refreshToken, expiresAt: 0, accountUuid: "acc-1" },
29
+ };
30
+ }
31
+
32
+ describe("deactivateAuthDeadToken", () => {
33
+ afterEach(() => {
34
+ findOneMock.mockReset();
35
+ deactivateMock.mockReset();
36
+ notifySlackMock.mockReset();
37
+ });
38
+
39
+ it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
40
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
41
+ deactivateMock.mockResolvedValue({ deactivated: true, keptAsLastActive: false });
42
+
43
+ await expect(
44
+ deactivateAuthDeadToken(token, "rt-current", "anthropic:400"),
45
+ ).resolves.toBe(true);
46
+
47
+ expect(deactivateMock).toHaveBeenCalledWith(7);
48
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
49
+ const notification = notifySlackMock.mock.calls[0]![0] as {
50
+ subject: string;
51
+ context: string;
52
+ color: string;
53
+ };
54
+ expect(notification.subject).toBe("anthropic/test-token");
55
+ expect(notification.context).toContain("anthropic:400");
56
+ expect(notification.color).toBe(SLACK_COLOR.bad);
57
+ });
58
+
59
+ it("skips deactivation when the refresh token rotated since the failed attempt", async () => {
60
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-rotated"));
61
+
62
+ await expect(deactivateAuthDeadToken(token, "rt-stale", "anthropic:400")).resolves.toBe(false);
63
+
64
+ expect(deactivateMock).not.toHaveBeenCalled();
65
+ expect(notifySlackMock).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("does not notify when another process won the deactivation race", async () => {
69
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
70
+ deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: false });
71
+
72
+ await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
73
+ false,
74
+ );
75
+
76
+ expect(notifySlackMock).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("alerts instead of deactivating when the dying token is the last active one", async () => {
80
+ // 알림 쿨다운이 모듈 수준 상태라 다른 테스트의 발송 기록에 가려질 수 있다.
81
+ vi.resetModules();
82
+ const { deactivateAuthDeadToken: freshDeactivate } = await import("./token-death");
83
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
84
+ deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: true });
85
+
86
+ await expect(freshDeactivate(token, "rt-current", "anthropic:400")).resolves.toBe(false);
87
+
88
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
89
+ const notification = notifySlackMock.mock.calls[0]![0] as { title: string; context: string };
90
+ expect(notification.title).toContain("마지막");
91
+ expect(notification.context).toContain("anthropic:400");
92
+ });
93
+
94
+ it("suppresses repeat last-active alerts for the same provider within the cooldown", async () => {
95
+ // 이 경로는 토큰을 비활성화하지 않아 상태 변화가 없다 — 억제가 없으면 요청마다 알림이 간다.
96
+ // 쿨다운은 모듈 수준 상태라 앞 테스트의 발송 기록이 남는다. 새로 불러 격리한다.
97
+ vi.resetModules();
98
+ const { deactivateAuthDeadToken: freshDeactivate } = await import("./token-death");
99
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
100
+ deactivateMock.mockResolvedValue({ deactivated: false, keptAsLastActive: true });
101
+
102
+ await freshDeactivate(token, "rt-current", "anthropic:400");
103
+ await freshDeactivate(token, "rt-current", "anthropic:400");
104
+ await freshDeactivate(token, "rt-current", "anthropic:401");
105
+
106
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("does nothing when the token no longer exists", async () => {
110
+ findOneMock.mockResolvedValue(undefined);
111
+
112
+ await expect(deactivateAuthDeadToken(token, "rt-current", "anthropic:400")).resolves.toBe(
113
+ false,
114
+ );
115
+
116
+ expect(deactivateMock).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("never puts the raw provider error body in the notification", async () => {
120
+ findOneMock.mockResolvedValue(storedRefreshToken("rt-current"));
121
+ deactivateMock.mockResolvedValue({ deactivated: true, keptAsLastActive: false });
122
+
123
+ await deactivateAuthDeadToken(token, "rt-current", "anthropic:400");
124
+
125
+ // 필드가 늘어도 새는 곳이 생기지 않도록 페이로드 전체를 훑는다.
126
+ const payload = JSON.stringify(notifySlackMock.mock.calls[0]![0]);
127
+ expect(payload).not.toContain("sk-ant");
128
+ expect(payload).not.toContain("@");
129
+ });
130
+ });
131
+
132
+ describe("notifyTokenAdded", () => {
133
+ afterEach(() => {
134
+ notifySlackMock.mockReset();
135
+ });
136
+
137
+ it("sends one added notification naming the token and provider", () => {
138
+ notifyTokenAdded("anthropic/test-token", "anthropic");
139
+
140
+ expect(notifySlackMock).toHaveBeenCalledTimes(1);
141
+ const notification = notifySlackMock.mock.calls[0]![0] as {
142
+ subject: string;
143
+ context: string;
144
+ color: string;
145
+ };
146
+ expect(notification.subject).toBe("anthropic/test-token");
147
+ expect(notification.context).toContain("anthropic");
148
+ expect(notification.color).toBe(SLACK_COLOR.good);
149
+ });
150
+ });