@cartanova/qgrid-cli 2.9.2 → 2.9.5

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 (77) hide show
  1. package/bundle/dist/application/qgrid/expired-token-reminder.js +6 -5
  2. package/bundle/dist/application/qgrid/qgrid-image-generation.js +12 -3
  3. package/bundle/dist/application/qgrid/qgrid-response-format.js +8 -6
  4. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  5. package/bundle/dist/application/qgrid/qgrid.frame.js +4 -3
  6. package/bundle/dist/application/qgrid/qgrid.types.js +31 -4
  7. package/bundle/dist/application/qgrid/token-death.js +6 -22
  8. package/bundle/dist/application/qgrid/token-subscriber.js +2 -1
  9. package/bundle/dist/application/qgrid/tool-emulation.js +3 -2
  10. package/bundle/dist/application/setting/setting.model.js +8 -2
  11. package/bundle/dist/application/token/token.model.js +22 -17
  12. package/bundle/dist/utils/providers/common/model-cost.js +21 -13
  13. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +1 -1
  14. package/bundle/dist/utils/providers/openai/openai-direct-client.js +39 -1
  15. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +41 -20
  16. package/bundle/dist/utils/providers/openai/openai-image-generation.js +145 -0
  17. package/bundle/dist/utils/providers/openai/openai-quota.js +11 -3
  18. package/bundle/dist/utils/providers/openai/openai-refresh.js +7 -5
  19. package/bundle/dist/utils/slack-notify.js +8 -4
  20. package/bundle/src/application/qgrid/expired-token-reminder.test.ts +6 -1
  21. package/bundle/src/application/qgrid/expired-token-reminder.ts +10 -4
  22. package/bundle/src/application/qgrid/qgrid-image-generation.test.ts +53 -0
  23. package/bundle/src/application/qgrid/qgrid-image-generation.ts +32 -2
  24. package/bundle/src/application/qgrid/qgrid-response-format.test.ts +26 -0
  25. package/bundle/src/application/qgrid/qgrid-response-format.ts +21 -13
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +32 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  28. package/bundle/src/application/qgrid/qgrid.frame.test.ts +20 -0
  29. package/bundle/src/application/qgrid/qgrid.frame.ts +3 -2
  30. package/bundle/src/application/qgrid/qgrid.types.ts +32 -0
  31. package/bundle/src/application/qgrid/token-death.test.ts +11 -10
  32. package/bundle/src/application/qgrid/token-death.ts +9 -21
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +5 -1
  34. package/bundle/src/application/qgrid/token-subscriber.ts +1 -0
  35. package/bundle/src/application/qgrid/tool-emulation.test.ts +18 -0
  36. package/bundle/src/application/qgrid/tool-emulation.ts +3 -0
  37. package/bundle/src/application/setting/setting.model.test.ts +20 -0
  38. package/bundle/src/application/setting/setting.model.ts +10 -2
  39. package/bundle/src/application/sonamu.generated.http +4 -2
  40. package/bundle/src/application/token/token.model.test.ts +24 -5
  41. package/bundle/src/application/token/token.model.ts +30 -23
  42. package/bundle/src/utils/providers/common/effort.test.ts +8 -0
  43. package/bundle/src/utils/providers/common/model-cost.test.ts +25 -12
  44. package/bundle/src/utils/providers/common/model-cost.ts +28 -14
  45. package/bundle/src/utils/providers/common/provider-dispatcher.ts +5 -1
  46. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +9 -1
  47. package/bundle/src/utils/providers/openai/openai-direct-client.ts +48 -1
  48. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +137 -2
  49. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +44 -18
  50. package/bundle/src/utils/providers/openai/openai-image-generation.test.ts +135 -0
  51. package/bundle/src/utils/providers/openai/openai-image-generation.ts +177 -0
  52. package/bundle/src/utils/providers/openai/openai-quota.test.ts +32 -0
  53. package/bundle/src/utils/providers/openai/openai-quota.ts +15 -8
  54. package/bundle/src/utils/providers/openai/openai-refresh.test.ts +18 -0
  55. package/bundle/src/utils/providers/openai/openai-refresh.ts +11 -4
  56. package/bundle/src/utils/slack-notify.test.ts +14 -0
  57. package/bundle/src/utils/slack-notify.ts +19 -3
  58. package/bundle/web-dist/client/assets/{index-DH1n8kgG.js → index-BcCytvFf.js} +3 -3
  59. package/bundle/web-dist/client/assets/{logs-D36O-NET.js → logs-BkOilk5x.js} +1 -1
  60. package/bundle/web-dist/client/assets/{monit-DhZAaz9K.js → monit-8oCSLCdL.js} +1 -1
  61. package/bundle/web-dist/client/assets/{routes-C7q1CsSv.js → routes-DFIF4IoW.js} +2 -2
  62. package/bundle/web-dist/client/assets/{settings-M_jfoIAe.js → settings-nUI-DR3U.js} +1 -1
  63. package/bundle/web-dist/client/assets/{show-CORjMGQs.js → show-Bzx6Xkoc.js} +1 -1
  64. package/bundle/web-dist/client/assets/{tokens-C7-j4VXD.js → tokens-DFWCL7Ts.js} +1 -1
  65. package/bundle/web-dist/client/assets/{use-oauth-login-flow-Fi62xbBc.js → use-oauth-login-flow-B5MAjpVc.js} +1 -1
  66. package/bundle/web-dist/client/index.html +1 -1
  67. package/bundle/web-dist/server/assets/{routes-CxMHXfdH.js → routes-B408iqh1.js} +29 -14
  68. package/bundle/web-dist/server/entry-server.generated.js +3 -2
  69. package/package.json +2 -1
  70. package/skills/qgrid/SKILL.md +2 -1
  71. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -2
  72. package/skills/qgrid/references/decision-rationale.md +2 -2
  73. package/skills/qgrid/references/openai-codex-runtime.md +79 -0
  74. package/skills/qgrid/references/provider-runtime-differences.md +1 -1
  75. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  76. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
  77. package/skills/qgrid/references/token-auth-quota-lifecycle.md +4 -3
@@ -186,4 +186,36 @@ describe("readOpenAIQuotaUsage", () => {
186
186
  }),
187
187
  ).resolves.toEqual({ kind: "lookup_failed", reason: "OpenAI quota lookup failed: HTTP 503" });
188
188
  });
189
+
190
+ it("does not refresh a cancelled 401 lookup", async () => {
191
+ const controller = new AbortController();
192
+ const refreshCredentials = vi.fn();
193
+ const result = await readOpenAIQuotaUsage({
194
+ credentials: { accessToken: "old", accountId: "acct" },
195
+ signal: controller.signal,
196
+ refreshCredentials,
197
+ fetch: async () => {
198
+ controller.abort(new Error("cancelled"));
199
+ return new Response("", { status: 401 });
200
+ },
201
+ });
202
+ expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
203
+ expect(refreshCredentials).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it("does not retry when cancelled while refreshing", async () => {
207
+ const controller = new AbortController();
208
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
209
+ const result = await readOpenAIQuotaUsage({
210
+ credentials: { accessToken: "old", accountId: "acct" },
211
+ signal: controller.signal,
212
+ fetch: fetchMock,
213
+ refreshCredentials: async () => {
214
+ controller.abort(new Error("cancelled"));
215
+ return { accessToken: "new", accountId: "acct" };
216
+ },
217
+ });
218
+ expect(result).toEqual({ kind: "lookup_failed", reason: "cancelled" });
219
+ expect(fetchMock).toHaveBeenCalledTimes(1);
220
+ });
189
221
  });
@@ -23,19 +23,26 @@ export type OpenAIQuotaUsageResult =
23
23
 
24
24
  export interface OpenAIQuotaHttpOptions {
25
25
  credentials: Pick<OpenAICredentials, "accessToken" | "accountId">;
26
+ refreshCredentials?: () => Promise<Pick<OpenAICredentials, "accessToken" | "accountId">>;
26
27
  fetch?: typeof fetch;
27
28
  signal?: AbortSignal;
28
29
  }
29
30
 
30
31
  async function readDirect(options: OpenAIQuotaHttpOptions): Promise<OpenAIRateLimitsWithMeta> {
31
- const response = await (options.fetch ?? fetch)(CHATGPT_WHAM_USAGE_URL, {
32
- method: "GET",
33
- headers: buildCodexIdentityHeaders(
34
- options.credentials.accessToken,
35
- options.credentials.accountId,
36
- ),
37
- ...(options.signal ? { signal: options.signal } : {}),
38
- });
32
+ const read = (credentials: OpenAIQuotaHttpOptions["credentials"]) =>
33
+ (options.fetch ?? fetch)(CHATGPT_WHAM_USAGE_URL, {
34
+ method: "GET",
35
+ headers: buildCodexIdentityHeaders(credentials.accessToken, credentials.accountId),
36
+ ...(options.signal ? { signal: options.signal } : {}),
37
+ });
38
+ let response = await read(options.credentials);
39
+ if (response.status === 401 && options.refreshCredentials) {
40
+ await response.body?.cancel();
41
+ options.signal?.throwIfAborted();
42
+ const credentials = await options.refreshCredentials();
43
+ options.signal?.throwIfAborted();
44
+ response = await read(credentials);
45
+ }
39
46
  if (!response.ok) throw new Error(`OpenAI quota lookup failed: HTTP ${response.status}`);
40
47
  const body = (await response.json()) as Record<string, unknown>;
41
48
  const data = normalizeWhamUsage(body);
@@ -126,4 +126,22 @@ describe("OpenAI refresh", () => {
126
126
  "openai:refresh_token_reused",
127
127
  );
128
128
  });
129
+
130
+ it("recognizes nested permanent error codes without exposing the provider body", async () => {
131
+ findOneMock.mockResolvedValue(token(12));
132
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({
133
+ error: { code: "refresh_token_invalidated", message: "private provider detail" },
134
+ }), { status: 401 })));
135
+ await expect(handleChatgptAuthTokensRefresh(12)).rejects.toThrow(
136
+ "OpenAI refresh failed: 401 refresh_token_invalidated",
137
+ );
138
+ expect(deactivateMock).toHaveBeenCalledWith(expect.objectContaining({ id: 12 }), "openai:refresh_token_invalidated");
139
+ });
140
+
141
+ it.each([429, 500, 503])("does not deactivate a token on transient refresh HTTP %s", async (status) => {
142
+ findOneMock.mockResolvedValue(token(status));
143
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("upstream failure", { status })));
144
+ await expect(handleChatgptAuthTokensRefresh(status)).rejects.toThrow(`OpenAI refresh failed: ${status} unknown`);
145
+ expect(deactivateMock).not.toHaveBeenCalled();
146
+ });
129
147
  });
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenAI chatgptAuthTokens refresh handler.
3
3
  *
4
- * - direct HTTPS/WS transport가 401 refresh를 소유하고 저장된 refresh token으로 갱신함
4
+ * - direct transport와 quota 조회의 401에서 같은 refresh 경로를 사용함
5
5
  * - qgrid 가 DB 의 refresh_token 으로 새 access_token 을 발급받아 응답
6
6
  * - per-token inflight promise 로 concurrent refresh dedup
7
7
  * - rotation 감지: 새 refresh_token 있으면 DB 즉시 업데이트
@@ -104,11 +104,18 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
104
104
  // 위 3개는 모두 영구 실패. 재등록 외 복구 불가.
105
105
  let errorCode: string | undefined;
106
106
  try {
107
- errorCode = (JSON.parse(body) as { error?: string }).error;
107
+ const error = (JSON.parse(body) as { error?: unknown }).error;
108
+ const code =
109
+ typeof error === "string"
110
+ ? error
111
+ : error && typeof error === "object" && "code" in error
112
+ ? error.code
113
+ : undefined;
114
+ if (typeof code === "string" && /^[a-z_]{1,80}$/.test(code)) errorCode = code;
108
115
  } catch {}
109
116
  logger.error(
110
117
  `OpenAI refresh FAILED token=${token.name}(id=${tokenId}) status=${resp.status} ` +
111
- `code=${errorCode ?? "?"} body=${body}`,
118
+ `code=${errorCode ?? "unknown"}`,
112
119
  );
113
120
  // 영구 실패는 errorCode 로만 판정한다 — status 를 함께 요구하면 provider 가
114
121
  // 상태코드를 바꿀 때 판정이 조용히 멎는다.
@@ -118,7 +125,7 @@ async function doRefresh(tokenId: number): Promise<RefreshResult> {
118
125
  `openai:${errorCode}`,
119
126
  );
120
127
  }
121
- throw new Error(`OpenAI refresh failed: ${resp.status} ${body}`);
128
+ throw new Error(`OpenAI refresh failed: ${resp.status} ${errorCode ?? "unknown"}`);
122
129
  }
123
130
 
124
131
  const data = (await resp.json()) as {
@@ -19,6 +19,20 @@ const noSettings = () => undefined;
19
19
  const WORKING_HOURS = seoul("2026-08-05T14:00:00");
20
20
 
21
21
  describe("notifySlack", () => {
22
+ it.each([
23
+ ["not_in_channel", () => Promise.resolve(new Response(JSON.stringify({ ok: false, error: "not_in_channel" })))],
24
+ ["HTTP 503", () => Promise.resolve(new Response("unavailable", { status: 503 }))],
25
+ ["network", () => Promise.reject(new Error("private network detail"))],
26
+ ] as const)("reports %s to manual send callers", async (_name, response) => {
27
+ vi.spyOn(globalThis, "fetch").mockImplementation(response);
28
+ await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
29
+ });
30
+
31
+ it("reports missing configuration to manual send callers", async () => {
32
+ settingMock.mockReturnValue(undefined);
33
+ await expect(notify({ title: "hello", urgent: true, throwOnFailure: true })).rejects.toThrow("Slack");
34
+ });
35
+
22
36
  beforeEach(() => {
23
37
  settingMock.mockImplementation((key: string) =>
24
38
  key === "slack.botToken" ? "xoxb-test" : key === "slack.channelId" ? "C123" : undefined,
@@ -15,6 +15,8 @@ const logger = getLogger(["qgrid", "slack"]);
15
15
  const SLACK_POST_MESSAGE_URL = "https://slack.com/api/chat.postMessage";
16
16
  const SLACK_TIMEOUT_MS = 5_000;
17
17
 
18
+ export class SlackNotificationError extends Error {}
19
+
18
20
  /** 상태를 색으로 먼저 읽히게 하는 attachment 색상 바. */
19
21
  export const SLACK_COLOR = {
20
22
  good: "#2eb886",
@@ -34,6 +36,8 @@ export type SlackNotification = {
34
36
  * 커지는 사건에만 쓴다 — 남용하면 조용 시간 자체가 무의미해진다.
35
37
  */
36
38
  urgent?: boolean;
39
+ /** Manual sends must report delivery failures; background notifications remain fail-open. */
40
+ throwOnFailure?: boolean;
37
41
  /** 조용 시간 판정 기준 시각. 테스트에서 시계를 고정할 때만 넘긴다. */
38
42
  now?: Date;
39
43
  };
@@ -47,6 +51,9 @@ export async function notifySlack(
47
51
  const { title, subject, context, color, urgent, now } = notification;
48
52
  if (!botToken || !channel) {
49
53
  logger.debug(`slack not configured, skipping notification: ${title} ${subject ?? ""}`);
54
+ if (notification.throwOnFailure) {
55
+ throw new SlackNotificationError("Slack 봇 토큰과 채널 ID를 설정해 주세요");
56
+ }
50
57
  return;
51
58
  }
52
59
 
@@ -99,10 +106,19 @@ export async function notifySlack(
99
106
  // bot 미초대(not_in_channel)·잘못된 채널 등 가장 흔한 오설정은 HTTP 200 + ok:false 로 온다.
100
107
  // status 만 보면 조용히 성공 처리돼 알림이 통째로 사라진다.
101
108
  const body = (await res.json().catch(() => null)) as { ok?: boolean; error?: string } | null;
102
- if (!body?.ok) {
103
- logger.warn(`slack notification rejected: ${body?.error ?? `http ${res.status}`}`);
109
+ if (!res.ok || body?.ok !== true) {
110
+ const code =
111
+ typeof body?.error === "string" && /^[a-z_]{1,80}$/.test(body.error)
112
+ ? body.error
113
+ : `HTTP ${res.status}`;
114
+ throw new SlackNotificationError(`Slack 알림 발송 실패: ${code}`);
104
115
  }
105
116
  } catch (e) {
106
- logger.warn(`slack notification failed: ${(e as Error).message}`);
117
+ const error =
118
+ e instanceof SlackNotificationError
119
+ ? e
120
+ : new SlackNotificationError("Slack 알림 발송 실패: 네트워크 오류 또는 응답 시간 초과");
121
+ logger.warn(error.message);
122
+ if (notification.throwOnFailure) throw error;
107
123
  }
108
124
  }