@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
@@ -39,7 +39,7 @@ describe("deactivateAuthDeadToken", () => {
39
39
  it("deactivates and notifies when the failed attempt used the current refresh token", async () => {
40
40
  deactivateMock.mockResolvedValue({
41
41
  marked: true,
42
- keptAsLastActive: false,
42
+ wasLastActive: false,
43
43
  staleCredentials: false,
44
44
  });
45
45
 
@@ -60,7 +60,7 @@ describe("deactivateAuthDeadToken", () => {
60
60
  it("skips deactivation when credentials rotated since the failed attempt", async () => {
61
61
  deactivateMock.mockResolvedValue({
62
62
  marked: false,
63
- keptAsLastActive: false,
63
+ wasLastActive: false,
64
64
  staleCredentials: true,
65
65
  });
66
66
 
@@ -73,7 +73,7 @@ describe("deactivateAuthDeadToken", () => {
73
73
  it("does not notify when another process won the deactivation race", async () => {
74
74
  deactivateMock.mockResolvedValue({
75
75
  marked: false,
76
- keptAsLastActive: false,
76
+ wasLastActive: false,
77
77
  staleCredentials: false,
78
78
  });
79
79
 
@@ -82,18 +82,19 @@ describe("deactivateAuthDeadToken", () => {
82
82
  expect(notifySlackMock).not.toHaveBeenCalled();
83
83
  });
84
84
 
85
- it("alerts instead of deactivating when the dying token is the last active one", async () => {
85
+ it("deactivates the last active token and sends an urgent expiry alert", async () => {
86
86
  deactivateMock.mockResolvedValue({
87
87
  marked: true,
88
- keptAsLastActive: true,
88
+ wasLastActive: true,
89
89
  staleCredentials: false,
90
90
  });
91
91
 
92
- await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(false);
92
+ await expect(deactivateAuthDeadToken(token, "anthropic:400")).resolves.toBe(true);
93
93
 
94
94
  expect(notifySlackMock).toHaveBeenCalledTimes(1);
95
95
  const notification = notifySlackMock.mock.calls[0]![0] as { title: string; context: string };
96
- expect(notification.title).toContain("마지막");
96
+ expect(notification.title).toBe("세션 만료");
97
+ expect(notifySlackMock.mock.calls[0]![0]).toMatchObject({ urgent: true });
97
98
  expect(notification.context).toContain("anthropic:400");
98
99
  });
99
100
 
@@ -101,12 +102,12 @@ describe("deactivateAuthDeadToken", () => {
101
102
  deactivateMock
102
103
  .mockResolvedValueOnce({
103
104
  marked: true,
104
- keptAsLastActive: true,
105
+ wasLastActive: true,
105
106
  staleCredentials: false,
106
107
  })
107
108
  .mockResolvedValue({
108
109
  marked: false,
109
- keptAsLastActive: false,
110
+ wasLastActive: false,
110
111
  staleCredentials: false,
111
112
  });
112
113
 
@@ -120,7 +121,7 @@ describe("deactivateAuthDeadToken", () => {
120
121
  it("never puts the raw provider error body in the notification", async () => {
121
122
  deactivateMock.mockResolvedValue({
122
123
  marked: true,
123
- keptAsLastActive: false,
124
+ wasLastActive: false,
124
125
  staleCredentials: false,
125
126
  });
126
127
 
@@ -24,7 +24,7 @@ export async function deactivateAuthDeadToken(
24
24
  token: DeadTokenRef,
25
25
  reasonCode: string,
26
26
  ): Promise<boolean> {
27
- const { marked, keptAsLastActive, staleCredentials } = await TokenModel.markReauthRequired(
27
+ const { marked, wasLastActive, staleCredentials } = await TokenModel.markReauthRequired(
28
28
  token.id,
29
29
  token.credentials,
30
30
  );
@@ -35,10 +35,6 @@ export async function deactivateAuthDeadToken(
35
35
  return false;
36
36
  }
37
37
  if (!marked) return false;
38
- if (keptAsLastActive) {
39
- notifyLastActiveTokenDying(token, reasonCode);
40
- return false;
41
- }
42
38
 
43
39
  logger.warn(`token requires re-login: ${token.name}(id=${token.id}) reason=${reasonCode}`);
44
40
  // 만료 직후가 재로그인하기 가장 좋은 타이밍이다. 소유자를 멘션해 그 순간 당사자에게
@@ -47,29 +43,21 @@ export async function deactivateAuthDeadToken(
47
43
  void notifySlack({
48
44
  title: "세션 만료",
49
45
  subject: token.name,
50
- context: [mention, token.provider, reasonCode, "재로그인이 필요합니다"]
46
+ context: [
47
+ mention,
48
+ token.provider,
49
+ reasonCode,
50
+ "재로그인이 필요합니다",
51
+ wasLastActive ? "사용 가능한 토큰이 없습니다" : null,
52
+ ]
51
53
  .filter(Boolean)
52
54
  .join(" · "),
53
55
  color: SLACK_COLOR.bad,
56
+ urgent: wasLastActive,
54
57
  });
55
58
  return true;
56
59
  }
57
60
 
58
- function notifyLastActiveTokenDying(token: DeadTokenRef, reasonCode: string): void {
59
- const mention = mentionFor(token.name, getSlackUserMap());
60
- void notifySlack({
61
- title: "마지막 토큰 사망",
62
- subject: token.name,
63
- context:
64
- [mention, token.provider, reasonCode].filter(Boolean).join(" · ") +
65
- ` · 풀이 비지 않도록 유지 중입니다. ` +
66
- `전 토큰이 동시에 실패했다면 client_id 취소나 OAuth 계약 변경을 의심하세요`,
67
- color: SLACK_COLOR.bad,
68
- // provider 전체가 죽었다는 신호다. 조용 시간에 묻히면 다음 근무일까지 서비스가 멈춘다.
69
- urgent: true,
70
- });
71
- }
72
-
73
61
  /** 로그인이 완료돼 토큰이 저장됐을 때. 신규·재로그인을 가리지 않는다. */
74
62
  export function notifyTokenAdded(name: string, provider: string): void {
75
63
  logger.info(`token added: ${name} (${provider})`);
@@ -2,15 +2,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { TokenSubscriber } from "./token-subscriber";
4
4
 
5
- const { findOneMock, findActiveMock } = vi.hoisted(() => ({
5
+ const { findOneMock, findActiveMock, deactivateExpiredTokensMock } = vi.hoisted(() => ({
6
6
  findOneMock: vi.fn(),
7
7
  findActiveMock: vi.fn(),
8
+ deactivateExpiredTokensMock: vi.fn(),
8
9
  }));
9
10
 
10
11
  vi.mock("../token/token.model", () => ({
11
12
  TokenModel: {
12
13
  findOne: findOneMock,
13
14
  findActive: findActiveMock,
15
+ deactivateExpiredTokens: deactivateExpiredTokensMock,
14
16
  },
15
17
  }));
16
18
 
@@ -264,6 +266,8 @@ describe("TokenSubscriber OpenAI notifications", () => {
264
266
 
265
267
  await subscriber.reconcile();
266
268
 
269
+ expect(deactivateExpiredTokensMock).toHaveBeenCalled();
270
+
267
271
  expect(anthropicDispatcher.replaceTokens).toHaveBeenCalledWith([
268
272
  expect.objectContaining({ id: 1, quotaThreshold: 80, weight: 4 }),
269
273
  ]);
@@ -271,6 +271,7 @@ export class TokenSubscriber {
271
271
  .map((row) => row.id),
272
272
  )
273
273
  : null;
274
+ await TokenModel.deactivateExpiredTokens();
274
275
  const rows = await TokenModel.findActive("A");
275
276
  this.dispatcher.replaceCache(rows);
276
277
  // NOTIFY 유실 대비: AnthropicDispatcher 풀도 DB active anthropic 토큰 기준으로 재동기화.
@@ -56,6 +56,24 @@ describe("applyToolCallEmulation image parts", () => {
56
56
  });
57
57
  expect(out.content).toEqual([{ type: "text", text: "here is your image" }]);
58
58
  });
59
+
60
+ it("preserves observed image settings and attaches response usage only once", () => {
61
+ const generation = {
62
+ route: "codex-images" as const, model: "gpt-image-2" as const,
63
+ size: "1254x1254", quality: "medium", background: "transparent",
64
+ };
65
+ const usage = { input_tokens: 10, output_tokens: 100, total_tokens: 110 };
66
+ const out = applyToolCallEmulation(baseResult, undefined, {
67
+ answerKind: "text",
68
+ images: [
69
+ { ...img, generation: { ...generation, usage } },
70
+ { ...img, generation },
71
+ ],
72
+ });
73
+ expect(out.content.filter((item) => item.type === "image").map((item) => item.generation))
74
+ .toEqual([{ ...generation, usage }, generation]);
75
+ expect(out.usage).toEqual(baseResult.usage);
76
+ });
59
77
  });
60
78
 
61
79
  describe("applyToolCallEmulation envelope validation", () => {
@@ -12,6 +12,7 @@ const STRUCTURED_ENVELOPE_LIMITS = {
12
12
  maxDepth: 132,
13
13
  } as const;
14
14
  import {
15
+ type ImageGenerationMetadata,
15
16
  type QgridContent,
16
17
  type QgridThreadCoord,
17
18
  type QgridTool,
@@ -70,6 +71,7 @@ export class ToolCallEmulationError extends Error {
70
71
  interface EmulationImage {
71
72
  data: string;
72
73
  revisedPrompt?: string | null;
74
+ generation?: ImageGenerationMetadata;
73
75
  }
74
76
 
75
77
  // answer 인코딩 종류. envelope 디코더는 한 벌이고 이 값은 answer 브랜치의 타입과
@@ -212,6 +214,7 @@ function appendImages(content: QgridContent[], images?: EmulationImage[]): Qgrid
212
214
  type: "image",
213
215
  data: image.data,
214
216
  revisedPrompt: image.revisedPrompt ?? null,
217
+ ...(image.generation ? { generation: image.generation } : {}),
215
218
  }),
216
219
  ),
217
220
  ];
@@ -6,6 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { resetNativeRunRegistryForTests } from "../qgrid/qgrid-run-lifecycle";
7
7
  import { resetServerRestartForTests } from "../../utils/server-restart";
8
8
  import { SettingModel } from "./setting.model";
9
+ import * as reminders from "../qgrid/expired-token-reminder";
10
+ import { SlackNotificationError } from "../../utils/slack-notify";
11
+
12
+ describe("SettingModel.triggerExpiryReminder", () => {
13
+ afterEach(() => vi.restoreAllMocks());
14
+
15
+ it("returns the sent count after successful delivery", async () => {
16
+ vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockResolvedValue(3);
17
+ await expect(SettingModel.triggerExpiryReminder()).resolves.toEqual({ sent: 3 });
18
+ });
19
+
20
+ it("returns a readable API error when Slack rejects a manual send", async () => {
21
+ vi.spyOn(reminders, "sendExpiredTokenReminderNow").mockRejectedValue(
22
+ new SlackNotificationError("Slack 알림 발송 실패: not_in_channel"),
23
+ );
24
+ await expect(SettingModel.triggerExpiryReminder()).rejects.toMatchObject({
25
+ statusCode: 400, message: "Slack 알림 발송 실패: not_in_channel",
26
+ });
27
+ });
28
+ });
9
29
 
10
30
  function installContext(headers: Record<string, string>) {
11
31
  const raw = Object.assign(new EventEmitter(), {
@@ -11,9 +11,10 @@ import {
11
11
  exhaustive,
12
12
  } from "sonamu";
13
13
 
14
- import { SD } from "../../i18n/sd.generated";
14
+ import { SD, type LocalizedString } from "../../i18n/sd.generated";
15
15
  import { detectSupervisor } from "../../utils/process-supervisor";
16
16
  import { armServerRestartExit, beginServerRestart } from "../../utils/server-restart";
17
+ import { SlackNotificationError } from "../../utils/slack-notify";
17
18
  import { type SettingSubsetKey, type SettingSubsetMapping } from "../sonamu.generated";
18
19
  import { settingSubsetQueries, settingLoaderQueries } from "../sonamu.generated.sso";
19
20
  import {
@@ -315,7 +316,14 @@ class SettingModelClass extends BaseModelClass<
315
316
  // setting.model → expired-token-reminder → token.model 경로가 생겨,
316
317
  // token.model 을 mock 하는 테스트가 실제 모듈을 먼저 로드하게 된다.
317
318
  const { sendExpiredTokenReminderNow } = await import("../qgrid/expired-token-reminder");
318
- return { sent: await sendExpiredTokenReminderNow() };
319
+ try {
320
+ return { sent: await sendExpiredTokenReminderNow() };
321
+ } catch (error) {
322
+ if (error instanceof SlackNotificationError) {
323
+ throw new BadRequestException(error.message as LocalizedString);
324
+ }
325
+ throw error;
326
+ }
319
327
  }
320
328
 
321
329
  /**
@@ -326,7 +326,8 @@ Content-Type: application/json
326
326
  "imageGeneration": false,
327
327
  "imageGenerationOptions": {
328
328
  "quality": "low",
329
- "size": "1024x1024"
329
+ "size": "1024x1024",
330
+ "background": "auto"
330
331
  }
331
332
  }
332
333
  }
@@ -388,7 +389,8 @@ Content-Type: application/json
388
389
  "imageGeneration": false,
389
390
  "imageGenerationOptions": {
390
391
  "quality": "low",
391
- "size": "1024x1024"
392
+ "size": "1024x1024",
393
+ "background": "auto"
392
394
  }
393
395
  }
394
396
  }
@@ -213,7 +213,25 @@ describe("TokenModel.markReauthRequired", () => {
213
213
  await Sonamu.initForTesting();
214
214
  });
215
215
 
216
- it("serializes provider deaths, preserves one active token, and rejects stale credentials", async () => {
216
+ it("repairs legacy active expired tokens without changing healthy or manually inactive tokens", async () => {
217
+ const ids = await TokenModel.save([
218
+ { ...baseToken, name: "legacy-expired", active: true, reauth_required: true },
219
+ { ...baseToken, name: "healthy", active: true, reauth_required: false },
220
+ { ...baseToken, name: "manual-inactive", active: false, reauth_required: false },
221
+ ]);
222
+ try {
223
+ await TokenModel.deactivateExpiredTokens();
224
+ await TokenModel.deactivateExpiredTokens();
225
+ const { rows } = await TokenModel.findMany("A", { id: ids, num: 3 });
226
+ expect(rows.find((row) => row.id === ids[0])).toMatchObject({ active: false, reauth_required: true });
227
+ expect(rows.find((row) => row.id === ids[1])).toMatchObject({ active: true, reauth_required: false });
228
+ expect(rows.find((row) => row.id === ids[2])).toMatchObject({ active: false, reauth_required: false });
229
+ } finally {
230
+ await TokenModel.del(ids);
231
+ }
232
+ });
233
+
234
+ it("deactivates every expired token and rejects stale credentials", async () => {
217
235
  const suffix = randomUUID();
218
236
  const tokens = [
219
237
  {
@@ -237,7 +255,7 @@ describe("TokenModel.markReauthRequired", () => {
237
255
  }),
238
256
  ).resolves.toEqual({
239
257
  marked: false,
240
- keptAsLastActive: false,
258
+ wasLastActive: false,
241
259
  staleCredentials: true,
242
260
  });
243
261
 
@@ -246,16 +264,17 @@ describe("TokenModel.markReauthRequired", () => {
246
264
  TokenModel.markReauthRequired(ids[1]!, tokens[1]!.credentials),
247
265
  ]);
248
266
  expect(results.every((result) => result.marked)).toBe(true);
267
+ expect(results.filter((result) => result.wasLastActive)).toHaveLength(1);
249
268
 
250
269
  const { rows } = await TokenModel.findMany("A", { id: ids, num: 2 });
251
270
  expect(rows.every((row) => row.reauth_required)).toBe(true);
252
- expect(rows.filter((row) => row.active)).toHaveLength(1);
271
+ expect(rows.filter((row) => row.active)).toHaveLength(0);
253
272
 
254
273
  await expect(
255
274
  TokenModel.markReauthRequired(ids[0]!, tokens[0]!.credentials),
256
275
  ).resolves.toEqual({
257
276
  marked: false,
258
- keptAsLastActive: false,
277
+ wasLastActive: false,
259
278
  staleCredentials: false,
260
279
  });
261
280
 
@@ -278,7 +297,7 @@ describe("TokenModel.markReauthRequired", () => {
278
297
  TokenModel.markReauthRequired(inactive.id, inactive.credentials),
279
298
  ).resolves.toEqual({
280
299
  marked: false,
281
- keptAsLastActive: false,
300
+ wasLastActive: false,
282
301
  staleCredentials: true,
283
302
  });
284
303
 
@@ -267,14 +267,13 @@ class TokenModelClass extends BaseModelClass<
267
267
  }
268
268
 
269
269
  /**
270
- * 인증 사망을 한 번만 기록하고, 다른 활성 토큰이 있으면 라우팅에서도 제외한다.
271
- * 마지막 활성 토큰은 systemic 장애 안전판 때문에 active 상태를 유지하되 재로그인
272
- * 필요 상태는 기록한다. `marked`가 공유 DB 인스턴스들의 알림 발송 게이트다.
270
+ * Record confirmed expiry and remove the token from routing atomically.
271
+ * The last active token is also deactivated; its notification is urgent.
273
272
  */
274
273
  async markReauthRequired(
275
274
  id: number,
276
275
  expectedCredentials: TokenCredentials,
277
- ): Promise<{ marked: boolean; keptAsLastActive: boolean; staleCredentials: boolean }> {
276
+ ): Promise<{ marked: boolean; wasLastActive: boolean; staleCredentials: boolean }> {
278
277
  const wdb = this.getPuri("w");
279
278
  return wdb.transaction(async (trx) => {
280
279
  const token = await trx
@@ -288,42 +287,50 @@ class TokenModelClass extends BaseModelClass<
288
287
  .forUpdate()
289
288
  .first();
290
289
  if (!token || token.reauth_required) {
291
- return { marked: false, keptAsLastActive: false, staleCredentials: false };
290
+ return { marked: false, wasLastActive: false, staleCredentials: false };
292
291
  }
293
292
  if (!isDeepStrictEqual(token.credentials, expectedCredentials)) {
294
- return { marked: false, keptAsLastActive: false, staleCredentials: true };
293
+ return { marked: false, wasLastActive: false, staleCredentials: true };
295
294
  }
296
295
 
297
296
  await trx.knex.raw("SELECT pg_advisory_xact_lock(?, hashtext(?))", [
298
297
  TOKEN_AUTH_DEATH_LOCK_CLASS_ID,
299
298
  token.provider,
300
299
  ]);
301
- const updated = await trx.knex.raw<{ rows: { active: boolean }[] }>(
302
- `UPDATE tokens
300
+ const updated = await trx.knex.raw<{ rows: { was_last_active: boolean }[] }>(
301
+ `WITH previous AS (
302
+ SELECT active AND NOT EXISTS (
303
+ SELECT 1 FROM tokens peer
304
+ WHERE peer.provider = tokens.provider AND peer.active
305
+ AND NOT peer.reauth_required AND peer.id <> tokens.id
306
+ ) AS was_last_active FROM tokens WHERE id = ?
307
+ )
308
+ UPDATE tokens
303
309
  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
+ active = false
310
311
  WHERE id = ? AND reauth_required = false
311
- RETURNING active`,
312
- [id],
312
+ RETURNING (SELECT was_last_active FROM previous) AS was_last_active`,
313
+ [id, id],
313
314
  );
314
315
  const marked = updated.rows[0];
315
- if (!marked) return { marked: false, keptAsLastActive: false, staleCredentials: false };
316
+ if (!marked) return { marked: false, wasLastActive: false, staleCredentials: false };
316
317
 
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
- );
318
+ if (marked.was_last_active) {
319
+ logger.error(`deactivated expired token ${id}: no usable provider tokens remain`);
322
320
  }
323
- return { marked: true, keptAsLastActive: marked.active, staleCredentials: false };
321
+ return { marked: true, wasLastActive: marked.was_last_active, staleCredentials: false };
324
322
  });
325
323
  }
326
324
 
325
+ /** Repair rows retained by the former last-active exception before pool reconciliation. */
326
+ async deactivateExpiredTokens(): Promise<void> {
327
+ await this.getPuri("w")
328
+ .table("tokens")
329
+ .where("reauth_required", true)
330
+ .where("active", true)
331
+ .update({ active: false });
332
+ }
333
+
327
334
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
328
335
  async reorder(ids: number[]): Promise<{ done: boolean }> {
329
336
  const wdb = this.getPuri("w");
@@ -8,6 +8,14 @@ import {
8
8
  } from "./effort";
9
9
 
10
10
  describe("resolveOpenAIEffort", () => {
11
+ it("preserves every Astra Codex catalog effort including ultra", () => {
12
+ for (const effort of OPENAI_EFFORTS) {
13
+ expect(resolveOpenAIEffort("gpt-6-astra", effort)).toBe(effort);
14
+ }
15
+ expect(resolveOpenAIEffort("gpt-6-astra", "none")).toBeUndefined();
16
+ expect(resolveOpenAIEffort("gpt-6-astra", "minimal")).toBeUndefined();
17
+ });
18
+
11
19
  it("Codex 카탈로그 어휘는 그대로 통과한다", () => {
12
20
  for (const effort of ["low", "medium", "high", "xhigh"]) {
13
21
  expect(resolveOpenAIEffort("gpt-5.5", effort)).toBe(effort);
@@ -4,9 +4,18 @@ import { calculateCostUsd, getModelCosts } from "./model-cost";
4
4
 
5
5
  describe("calculateCostUsd", () => {
6
6
  it.each([
7
- ["gpt-5.6-sol", 5, 30, 0.5, 6.25],
8
- ["gpt-5.6-terra", 2.5, 15, 0.25, 3.125],
9
- ["gpt-5.6-luna", 1, 6, 0.1, 1.25],
7
+ [272_000, 2.77],
8
+ [272_001, 5.51502],
9
+ ])("Astra applies the long-context rate only above 272K (%i input)", (inputTokens, expected) => {
10
+ expect(
11
+ calculateCostUsd("openai/gpt-6-astra", { inputTokens, outputTokens: 1_000 }),
12
+ ).toBeCloseTo(expected, 10);
13
+ });
14
+ it.each([
15
+ ["gpt-6-astra", 10, 50, 1, 12.5],
16
+ ["gpt-5.6-sol", 4, 20, 0.4, 5],
17
+ ["gpt-5.6-terra", 2, 12, 0.2, 2.5],
18
+ ["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25],
10
19
  ["gpt-5.5", 5, 30, 0.5, undefined],
11
20
  ["gpt-5.4", 2.5, 15, 0.25, undefined],
12
21
  ["gpt-5.4-mini", 0.75, 4.5, 0.075, undefined],
@@ -77,13 +86,15 @@ describe("calculateCostUsd", () => {
77
86
  expect(getModelCosts("anthropic/claude-sonnet-4-6[1m]")).toBe(
78
87
  getModelCosts("claude-sonnet-4-6"),
79
88
  );
89
+ expect(getModelCosts("openai/gpt-6-astra")).toBe(getModelCosts("gpt-6-astra"));
80
90
  expect(getModelCosts("openai/gpt-5.6-sol")).toBe(getModelCosts("gpt-5.6-sol"));
81
91
  });
82
92
 
83
93
  it.each([
84
- ["gpt-5.6-sol", 5, 30, 0.5, 6.25, 3.30625],
85
- ["gpt-5.6-terra", 2.5, 15, 0.25, 3.125, 1.653125],
86
- ["gpt-5.6-luna", 1, 6, 0.1, 1.25, 0.66125],
94
+ ["gpt-6-astra", 10, 50, 1, 12.5, 5.6125],
95
+ ["gpt-5.6-sol", 4, 20, 0.4, 5, 2.245],
96
+ ["gpt-5.6-terra", 2, 12, 0.2, 2.5, 1.3225],
97
+ ["gpt-5.6-luna", 0.2, 1.2, 0.02, 0.25, 0.13225],
87
98
  ])(
88
99
  "%s applies published input, output, cache-read, and cache-write pricing",
89
100
  (model, inputTokens, outputTokens, cachedInputTokens, cacheCreationInputTokens, expectedCost) => {
@@ -106,9 +117,10 @@ describe("calculateCostUsd", () => {
106
117
  );
107
118
 
108
119
  it.each([
109
- ["gpt-5.6-sol", 1.245],
110
- ["gpt-5.6-terra", 0.6225],
111
- ["gpt-5.6-luna", 0.249],
120
+ ["gpt-6-astra", 2.475],
121
+ ["gpt-5.6-sol", 0.99],
122
+ ["gpt-5.6-terra", 0.498],
123
+ ["gpt-5.6-luna", 0.0498],
112
124
  ])("%s applies the published long-context surcharge", (model, expectedCost) => {
113
125
  expect(
114
126
  calculateCostUsd(model, {
@@ -120,9 +132,10 @@ describe("calculateCostUsd", () => {
120
132
  });
121
133
 
122
134
  it.each([
123
- ["gpt-5.6-sol", 1.37],
124
- ["gpt-5.6-terra", 0.685],
125
- ["gpt-5.6-luna", 0.274],
135
+ ["gpt-6-astra", 2.725],
136
+ ["gpt-5.6-sol", 1.09],
137
+ ["gpt-5.6-terra", 0.548],
138
+ ["gpt-5.6-luna", 0.0548],
126
139
  ])("%s applies the long-context input multiplier to cache writes", (model, expectedCost) => {
127
140
  expect(
128
141
  calculateCostUsd(model, {
@@ -34,7 +34,7 @@ export interface ModelCosts {
34
34
 
35
35
  // ── OpenAI ─────────────────────────────────────────────────────────
36
36
  //
37
- // 가격 출처: https://developers.openai.com/api/docs/models (2026-07-18 확인)
37
+ // Standard pricing verified 2026-09-07: https://developers.openai.com/api/docs/pricing
38
38
  // 신모델 출시마다 단가가 바뀌므로(5.2→5.4→5.5) 모델 추가 시 반드시 공식 페이지 재확인해야함
39
39
 
40
40
  // GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini/nano는 공식 표에서 long-context 단가 없음)
@@ -48,38 +48,52 @@ const LONG_CONTEXT_272K: NonNullable<ModelCosts["longContext"]> = {
48
48
  };
49
49
 
50
50
  // OpenAI 모델 카탈로그. 단가와 함께 Codex 백엔드가 모델별로 받는 최대 reasoning effort 를 한 곳에 둔다.
51
- // maxEffort 출처: ~/.codex/models_cache.json 의 supported_reasoning_levels (2026-09-02 확인).
51
+ // maxEffort 출처: ~/.codex/models_cache.json 의 supported_reasoning_levels (2026-09-07 확인).
52
52
  // 미기재 모델은 xhigh 까지만 받는다.
53
53
  type OpenAIModelSpec = ModelCosts & { maxEffort?: OpenAIEffort };
54
54
 
55
55
  const OPENAI_COSTS: Record<string, OpenAIModelSpec> = {
56
+ // GPT-6 Astra: Standard API-equivalent pricing, verified 2026-09-07.
57
+ // Codex subscription catalog supports ultra; the public API lists only through max.
58
+ // @see https://developers.openai.com/api/docs/models/gpt-6-astra
59
+ // @see https://developers.openai.com/api/docs/pricing
60
+ "gpt-6-astra": {
61
+ maxEffort: "ultra",
62
+ inputTokens: 10,
63
+ outputTokens: 50,
64
+ cachedInputTokens: 1,
65
+ cacheCreationInputTokens: 12.5,
66
+ longContext: LONG_CONTEXT_272K,
67
+ },
56
68
  // GPT-5.6 Sol, Terra, Luna. cache write 단가는 외부 logger/manual usage 입력을 위해
57
69
  // 유지한다. 현재 OpenAI 응답 usage 에 cache write 토큰 필드가 없으면 비용은 0으로 계산된다.
58
70
  // @see https://developers.openai.com/api/docs/models/gpt-5.6-sol
59
71
  // @see https://developers.openai.com/api/docs/models/gpt-5.6-terra
60
72
  // @see https://developers.openai.com/api/docs/models/gpt-5.6-luna
73
+ // Sol promotional rates are available at least through 2026-11-21.
74
+ // No confirmed end date/replacement rate: recheck official pricing instead of auto-reverting.
61
75
  "gpt-5.6-sol": {
62
76
  maxEffort: "ultra",
63
- inputTokens: 5,
64
- outputTokens: 30,
65
- cachedInputTokens: 0.5,
66
- cacheCreationInputTokens: 6.25,
77
+ inputTokens: 4,
78
+ outputTokens: 20,
79
+ cachedInputTokens: 0.4,
80
+ cacheCreationInputTokens: 5,
67
81
  longContext: LONG_CONTEXT_272K,
68
82
  },
69
83
  "gpt-5.6-terra": {
70
84
  maxEffort: "ultra",
71
- inputTokens: 2.5,
72
- outputTokens: 15,
73
- cachedInputTokens: 0.25,
74
- cacheCreationInputTokens: 3.125,
85
+ inputTokens: 2,
86
+ outputTokens: 12,
87
+ cachedInputTokens: 0.2,
88
+ cacheCreationInputTokens: 2.5,
75
89
  longContext: LONG_CONTEXT_272K,
76
90
  },
77
91
  "gpt-5.6-luna": {
78
92
  maxEffort: "max",
79
- inputTokens: 1,
80
- outputTokens: 6,
81
- cachedInputTokens: 0.1,
82
- cacheCreationInputTokens: 1.25,
93
+ inputTokens: 0.2,
94
+ outputTokens: 1.2,
95
+ cachedInputTokens: 0.02,
96
+ cacheCreationInputTokens: 0.25,
83
97
  longContext: LONG_CONTEXT_272K,
84
98
  },
85
99
  // https://openai.com/index/introducing-gpt-5-5/ (2026-04 출시)
@@ -4,7 +4,10 @@
4
4
  * MVP 에서는 stream() 만. getRateLimits(), listModels() 는 future.
5
5
  */
6
6
 
7
- import { type ImageGenerationOptions } from "../../../application/qgrid/qgrid.types";
7
+ import {
8
+ type ImageGenerationMetadata,
9
+ type ImageGenerationOptions,
10
+ } from "../../../application/qgrid/qgrid.types";
8
11
  import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "./provider-types";
9
12
 
10
13
  // qgrid provider 내부 표준 usage. provider 전용 cache write 세부 필드는 여기서 확장해 보존한다.
@@ -27,6 +30,7 @@ export interface ReuseThreadCoord {
27
30
  export interface GeneratedImage {
28
31
  data: string; // base64 PNG
29
32
  revisedPrompt: string | null;
33
+ generation?: ImageGenerationMetadata;
30
34
  }
31
35
 
32
36
  // Provider/런타임이 요청 모델 대신 다른 모델로 실제 응답을 생성한 이력.
@@ -1,5 +1,6 @@
1
1
  import os from "node:os";
2
2
 
3
+ import { type ImageGenerationMetadata } from "../../../application/qgrid/qgrid.types";
3
4
  import { type OpenAIEffort } from "../common/effort";
4
5
 
5
6
  export const CHATGPT_CODEX_RESPONSES_URL =
@@ -170,7 +171,14 @@ export interface OpenAIUsage {
170
171
  export type OpenAINormalizedEvent =
171
172
  | { type: "created"; responseId?: string }
172
173
  | { type: "text-delta"; text: string }
173
- | { type: "image"; id?: string; base64: string; mimeType: "image/png"; revisedPrompt?: string }
174
+ | {
175
+ type: "image";
176
+ id?: string;
177
+ base64: string;
178
+ mimeType: "image/png";
179
+ revisedPrompt?: string;
180
+ generation?: ImageGenerationMetadata;
181
+ }
174
182
  | { type: "output-item"; item: OpenAIResponseItem }
175
183
  | { type: "completed"; responseId: string; usage?: OpenAIUsage; model?: string }
176
184
  | { type: "error"; error: OpenAIProtocolError };