@cartanova/qgrid-cli 2.3.4 → 2.3.6

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 (93) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +15 -12
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +23 -10
  5. package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
  6. package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
  7. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +12 -7
  9. package/bundle/dist/application/sonamu.generated.js +11 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +7 -2
  11. package/bundle/dist/application/token/token.model.js +10 -2
  12. package/bundle/dist/application/token/token.types.js +5 -3
  13. package/bundle/dist/i18n/sd.generated.js +4 -1
  14. package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
  15. package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
  16. package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
  17. package/bundle/dist/sonamu.config.js +13 -6
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
  19. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +37 -14
  21. package/bundle/dist/utils/providers/common/ttft.js +25 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
  24. package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
  25. package/bundle/src/application/qgrid/oauth.ts +17 -7
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +128 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +41 -20
  32. package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
  34. package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
  35. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  36. package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
  37. package/bundle/src/application/request-log/request-log.entity.json +3 -0
  38. package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
  39. package/bundle/src/application/request-log/request-log.model.ts +20 -6
  40. package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
  41. package/bundle/src/application/sonamu.generated.http +7 -2
  42. package/bundle/src/application/sonamu.generated.sso.ts +5 -0
  43. package/bundle/src/application/sonamu.generated.ts +11 -1
  44. package/bundle/src/application/token/token.entity.json +8 -2
  45. package/bundle/src/application/token/token.model.test.ts +69 -0
  46. package/bundle/src/application/token/token.model.ts +8 -1
  47. package/bundle/src/application/token/token.types.test.ts +34 -0
  48. package/bundle/src/application/token/token.types.ts +5 -0
  49. package/bundle/src/i18n/sd.generated.ts +3 -0
  50. package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
  51. package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
  52. package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
  53. package/bundle/src/sonamu.config.ts +8 -1
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
  56. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
  57. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
  58. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +40 -0
  59. package/bundle/src/utils/providers/anthropic/claude-session.ts +58 -6
  60. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
  61. package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
  62. package/bundle/src/utils/providers/common/ttft.ts +26 -0
  63. package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
  64. package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
  65. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
  66. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
  67. package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
  68. package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
  69. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
  70. package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
  71. package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
  72. package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
  73. package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
  74. package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
  75. package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
  76. package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
  77. package/bundle/web-dist/client/index.html +3 -3
  78. package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
  79. package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
  80. package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
  81. package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
  82. package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
  83. package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
  84. package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
  85. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  86. package/dist/cli.js +67 -15
  87. package/package.json +1 -1
  88. package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
  89. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
  90. package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
  91. package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
  92. package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
  93. /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
@@ -0,0 +1,30 @@
1
+ import "../../../_virtual/_rolldown/runtime.js";
2
+ //#region src/utils/providers/openai/openai-quota.ts
3
+ async function readOpenAIQuotaUsage(readRateLimits) {
4
+ try {
5
+ const { data, cachedAt } = await readRateLimits();
6
+ const rateLimits = data.rateLimits;
7
+ const primary = rateLimits?.primary;
8
+ if (!primary || typeof primary.usedPercent !== "number" || !Number.isFinite(primary.usedPercent)) return {
9
+ kind: "lookup_failed",
10
+ reason: "primary usedPercent unavailable"
11
+ };
12
+ return {
13
+ kind: "ok",
14
+ utilizationPct: primary.usedPercent,
15
+ cacheAgeMs: Math.max(Date.now() - cachedAt, 0),
16
+ windowDurationMins: primary.windowDurationMins,
17
+ resetsAt: primary.resetsAt,
18
+ limitId: rateLimits.limitId
19
+ };
20
+ } catch (e) {
21
+ return {
22
+ kind: "lookup_failed",
23
+ reason: e.message
24
+ };
25
+ }
26
+ }
27
+ //#endregion
28
+ export { readOpenAIQuotaUsage };
29
+
30
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3BlbmFpLXF1b3RhLmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3NyYy91dGlscy9wcm92aWRlcnMvb3BlbmFpL29wZW5haS1xdW90YS50cyJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyB0eXBlIEdldEFjY291bnRSYXRlTGltaXRzUmVzcG9uc2UgfSBmcm9tIFwiLi4vLi4vLi4vY29kZXgtcHJvdG9jb2wvdjIvR2V0QWNjb3VudFJhdGVMaW1pdHNSZXNwb25zZVwiO1xuXG5leHBvcnQgdHlwZSBPcGVuQUlSYXRlTGltaXRzV2l0aE1ldGEgPSB7XG4gIGRhdGE6IEdldEFjY291bnRSYXRlTGltaXRzUmVzcG9uc2U7XG4gIGNhY2hlZEF0OiBudW1iZXI7XG59O1xuXG5leHBvcnQgdHlwZSBPcGVuQUlRdW90YVVzYWdlUmVzdWx0ID1cbiAgfCB7XG4gICAgICBraW5kOiBcIm9rXCI7XG4gICAgICB1dGlsaXphdGlvblBjdDogbnVtYmVyO1xuICAgICAgY2FjaGVBZ2VNczogbnVtYmVyO1xuICAgICAgd2luZG93RHVyYXRpb25NaW5zOiBudW1iZXIgfCBudWxsO1xuICAgICAgcmVzZXRzQXQ6IG51bWJlciB8IG51bGw7XG4gICAgICBsaW1pdElkOiBzdHJpbmcgfCBudWxsO1xuICAgIH1cbiAgfCB7IGtpbmQ6IFwibG9va3VwX2ZhaWxlZFwiOyByZWFzb246IHN0cmluZyB9O1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gcmVhZE9wZW5BSVF1b3RhVXNhZ2UoXG4gIHJlYWRSYXRlTGltaXRzOiAoKSA9PiBQcm9taXNlPE9wZW5BSVJhdGVMaW1pdHNXaXRoTWV0YT4sXG4pOiBQcm9taXNlPE9wZW5BSVF1b3RhVXNhZ2VSZXN1bHQ+IHtcbiAgdHJ5IHtcbiAgICBjb25zdCB7IGRhdGEsIGNhY2hlZEF0IH0gPSBhd2FpdCByZWFkUmF0ZUxpbWl0cygpO1xuICAgIGNvbnN0IHJhdGVMaW1pdHMgPSBkYXRhLnJhdGVMaW1pdHM7XG4gICAgY29uc3QgcHJpbWFyeSA9IHJhdGVMaW1pdHM/LnByaW1hcnk7XG5cbiAgICBpZiAoXG4gICAgICAhcHJpbWFyeSB8fFxuICAgICAgdHlwZW9mIHByaW1hcnkudXNlZFBlcmNlbnQgIT09IFwibnVtYmVyXCIgfHxcbiAgICAgICFOdW1iZXIuaXNGaW5pdGUocHJpbWFyeS51c2VkUGVyY2VudClcbiAgICApIHtcbiAgICAgIHJldHVybiB7IGtpbmQ6IFwibG9va3VwX2ZhaWxlZFwiLCByZWFzb246IFwicHJpbWFyeSB1c2VkUGVyY2VudCB1bmF2YWlsYWJsZVwiIH07XG4gICAgfVxuXG4gICAgcmV0dXJuIHtcbiAgICAgIGtpbmQ6IFwib2tcIixcbiAgICAgIHV0aWxpemF0aW9uUGN0OiBwcmltYXJ5LnVzZWRQZXJjZW50LFxuICAgICAgY2FjaGVBZ2VNczogTWF0aC5tYXgoRGF0ZS5ub3coKSAtIGNhY2hlZEF0LCAwKSxcbiAgICAgIHdpbmRvd0R1cmF0aW9uTWluczogcHJpbWFyeS53aW5kb3dEdXJhdGlvbk1pbnMsXG4gICAgICByZXNldHNBdDogcHJpbWFyeS5yZXNldHNBdCxcbiAgICAgIGxpbWl0SWQ6IHJhdGVMaW1pdHMubGltaXRJZCxcbiAgICB9O1xuICB9IGNhdGNoIChlKSB7XG4gICAgcmV0dXJuIHsga2luZDogXCJsb29rdXBfZmFpbGVkXCIsIHJlYXNvbjogKGUgYXMgRXJyb3IpLm1lc3NhZ2UgfTtcbiAgfVxufVxuIl0sIm1hcHBpbmdzIjoiOztBQWtCQSxlQUFzQixxQkFDcEIsZ0JBQ2lDO0FBQ2pDLEtBQUk7RUFDRixNQUFNLEVBQUUsTUFBTSxhQUFhLE1BQU0sZ0JBQWdCO0VBQ2pELE1BQU0sYUFBYSxLQUFLO0VBQ3hCLE1BQU0sVUFBVSxZQUFZO0FBRTVCLE1BQ0UsQ0FBQyxXQUNELE9BQU8sUUFBUSxnQkFBZ0IsWUFDL0IsQ0FBQyxPQUFPLFNBQVMsUUFBUSxZQUFZLENBRXJDLFFBQU87R0FBRSxNQUFNO0dBQWlCLFFBQVE7R0FBbUM7QUFHN0UsU0FBTztHQUNMLE1BQU07R0FDTixnQkFBZ0IsUUFBUTtHQUN4QixZQUFZLEtBQUssSUFBSSxLQUFLLEtBQUssR0FBRyxVQUFVLEVBQUU7R0FDOUMsb0JBQW9CLFFBQVE7R0FDNUIsVUFBVSxRQUFRO0dBQ2xCLFNBQVMsV0FBVztHQUNyQjtVQUNNLEdBQUc7QUFDVixTQUFPO0dBQUUsTUFBTTtHQUFpQixRQUFTLEVBQVk7R0FBUyJ9
@@ -10,6 +10,10 @@ export type AnthropicUsageRaw = {
10
10
  five_hour?: { utilization: number | null; resets_at: string | null } | null;
11
11
  seven_day?: { utilization: number | null; resets_at: string | null } | null;
12
12
  };
13
+ export type AnthropicUsageWithMeta = {
14
+ data: AnthropicUsageRaw;
15
+ cachedAt: number;
16
+ };
13
17
 
14
18
  const logger = getLogger(["qgrid", "oauth"]);
15
19
 
@@ -140,14 +144,18 @@ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTok
140
144
  };
141
145
  }
142
146
 
143
- const usageCache: Record<string, { data: AnthropicUsageRaw; cachedAt: number }> = {};
147
+ const usageCache: Record<string, AnthropicUsageWithMeta> = {};
144
148
  const USAGE_API_CACHE_TTL = 60_000; // 1분
145
149
 
146
150
  export async function fetchUsage(accessToken: string): Promise<AnthropicUsageRaw> {
151
+ return (await fetchUsageWithMeta(accessToken)).data;
152
+ }
153
+
154
+ export async function fetchUsageWithMeta(accessToken: string): Promise<AnthropicUsageWithMeta> {
147
155
  const cacheKey = accessToken.slice(-10);
148
156
  const cached = usageCache[cacheKey];
149
157
  if (cached && Date.now() - cached.cachedAt < USAGE_API_CACHE_TTL) {
150
- return cached.data;
158
+ return cached;
151
159
  }
152
160
 
153
161
  const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
@@ -173,16 +181,18 @@ export async function fetchUsage(accessToken: string): Promise<AnthropicUsageRaw
173
181
  logger.warn(`${res.status}: ${errorMessage}`);
174
182
 
175
183
  // 이전 성공 캐시 있으면 유지
176
- if (cached?.data && !cached.data.error) return cached.data;
184
+ if (cached?.data && !cached.data.error) return cached;
177
185
 
178
186
  // 실패도 캐시 (반복 호출 방지)
179
187
  const errorResult = { error: errorMessage };
180
- usageCache[cacheKey] = { data: errorResult, cachedAt: Date.now() };
181
- return errorResult;
188
+ const entry = { data: errorResult, cachedAt: Date.now() };
189
+ usageCache[cacheKey] = entry;
190
+ return entry;
182
191
  }
183
192
 
184
193
  const data = (await res.json()) as AnthropicUsageRaw;
185
194
  // cache invalidate
186
- usageCache[cacheKey] = { data, cachedAt: Date.now() };
187
- return data;
195
+ const entry = { data, cachedAt: Date.now() };
196
+ usageCache[cacheKey] = entry;
197
+ return entry;
188
198
  }
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { afterQuery } from "./qgrid-run-lifecycle";
4
+ import { type QueryOutput } from "./qgrid.types";
5
+
6
+ const { appendStepMock, aggregateStepUsageMock, finishRunMock } = vi.hoisted(() => ({
7
+ appendStepMock: vi.fn(),
8
+ aggregateStepUsageMock: vi.fn(),
9
+ finishRunMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../request-log/request-log.model", () => ({
13
+ RequestLogModel: {
14
+ appendStep: appendStepMock,
15
+ aggregateStepUsage: aggregateStepUsageMock,
16
+ finishRun: finishRunMock,
17
+ },
18
+ }));
19
+
20
+ function queryOutput(overrides: Partial<QueryOutput> = {}): QueryOutput {
21
+ return {
22
+ text: "hello",
23
+ content: [],
24
+ finishReason: "stop",
25
+ tokenName: "tok-A",
26
+ model: "gpt-5-codex",
27
+ usage: {
28
+ input_tokens: 5,
29
+ output_tokens: 7,
30
+ cache_creation_input_tokens: 0,
31
+ cache_read_input_tokens: 0,
32
+ },
33
+ durationMs: 120,
34
+ ttftMs: 39,
35
+ costUsd: 0.001,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ describe("qgrid run lifecycle TTFT", () => {
41
+ beforeEach(() => {
42
+ appendStepMock.mockReset();
43
+ aggregateStepUsageMock.mockReset();
44
+ aggregateStepUsageMock.mockResolvedValue({
45
+ input_tokens: 5,
46
+ output_tokens: 7,
47
+ cache_read_tokens: 0,
48
+ cache_creation_tokens: 0,
49
+ duration_ms: 120,
50
+ });
51
+ finishRunMock.mockReset();
52
+ });
53
+
54
+ it("appends generate step ttft_ms from QueryOutput", async () => {
55
+ await afterQuery(10, 2, { prompt: "hi" }, queryOutput({ ttftMs: 44 }));
56
+
57
+ expect(appendStepMock).toHaveBeenCalledWith(
58
+ 10,
59
+ expect.objectContaining({
60
+ step_index: 2,
61
+ type: "generate",
62
+ duration_ms: 120,
63
+ ttft_ms: 44,
64
+ }),
65
+ );
66
+ });
67
+
68
+ it("preserves generate step ttft_ms zero when no first-token timing is available", async () => {
69
+ await afterQuery(
70
+ 10,
71
+ 2,
72
+ { prompt: "hi" },
73
+ queryOutput({ finishReason: "tool-calls", ttftMs: 0 }),
74
+ );
75
+
76
+ expect(appendStepMock).toHaveBeenCalledWith(
77
+ 10,
78
+ expect.objectContaining({
79
+ ttft_ms: 0,
80
+ }),
81
+ );
82
+ expect(finishRunMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("does not pass ttft into finishRun params because the model derives the run value", async () => {
86
+ await afterQuery(10, 2, { prompt: "hi" }, queryOutput({ ttftMs: 44 }));
87
+
88
+ expect(finishRunMock).toHaveBeenCalledWith(
89
+ 10,
90
+ expect.not.objectContaining({
91
+ ttft_ms: expect.anything(),
92
+ ttftMs: expect.anything(),
93
+ }),
94
+ );
95
+ });
96
+ });
@@ -85,6 +85,7 @@ export async function afterQuery(
85
85
  cache_read_tokens: result.usage.cache_read_input_tokens,
86
86
  cache_creation_tokens: result.usage.cache_creation_input_tokens,
87
87
  duration_ms: result.durationMs,
88
+ ttft_ms: result.ttftMs,
88
89
  finish_reason: result.finishReason,
89
90
  });
90
91
 
@@ -335,4 +335,20 @@ describe("QgridDispatcherClass", () => {
335
335
  expect(result.costUsd).toBe(0.123456);
336
336
  expect(result.usage.cache_creation_input_tokens).toBe(600);
337
337
  });
338
+
339
+ it("provider ttftMs 를 QueryOutput.ttftMs 로 매핑하고 누락 값은 0 으로 둔다", async () => {
340
+ const dispatcher = new QgridDispatcherClass();
341
+ const generate = vi
342
+ .fn()
343
+ .mockResolvedValueOnce(providerResult({ ttftMs: 37 }))
344
+ .mockResolvedValueOnce(providerResult());
345
+ dispatcher.openaiDispatcher = { generate } as never;
346
+
347
+ await expect(
348
+ dispatcher.query({ prompt: "hi", model: "openai/gpt-5.5" }),
349
+ ).resolves.toMatchObject({ ttftMs: 37 });
350
+ await expect(
351
+ dispatcher.query({ prompt: "hi", model: "openai/gpt-5.5" }),
352
+ ).resolves.toMatchObject({ ttftMs: 0 });
353
+ });
338
354
  });
@@ -256,6 +256,7 @@ function toEmulationResult(
256
256
  cache_read_input_tokens: result.usage.cachedInputTokens,
257
257
  },
258
258
  durationMs: result.durationMs,
259
+ ttftMs: result.ttftMs ?? 0,
259
260
  costUsd:
260
261
  result.costUsd !== undefined && result.costUsd > 0
261
262
  ? result.costUsd
@@ -0,0 +1,128 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { QgridFrame } from "./qgrid.frame";
4
+
5
+ const { findOneMock, saveMock, requestLogSaveMock, dispatcherQueryMock } = vi.hoisted(() => ({
6
+ findOneMock: vi.fn(),
7
+ saveMock: vi.fn(),
8
+ requestLogSaveMock: vi.fn(),
9
+ dispatcherQueryMock: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("../request-log/request-log.model", () => ({
13
+ MICRO_USD: 1_000_000,
14
+ RequestLogModel: {
15
+ save: requestLogSaveMock,
16
+ },
17
+ }));
18
+
19
+ vi.mock("../token/token.model", () => ({
20
+ TokenModel: {
21
+ findOne: findOneMock,
22
+ save: saveMock,
23
+ },
24
+ }));
25
+
26
+ vi.mock("./qgrid.dispatcher", () => ({
27
+ QgridDispatcher: {
28
+ query: dispatcherQueryMock,
29
+ },
30
+ }));
31
+
32
+ const tokenEntry = {
33
+ id: 1,
34
+ created_at: new Date("2026-06-30T00:00:00.000Z"),
35
+ provider: "anthropic",
36
+ credentials: {
37
+ accessToken: "sk-ant-oat01-test",
38
+ refreshToken: "sk-ant-ort01-test",
39
+ expiresAt: Date.now() + 3_600_000,
40
+ accountUuid: "acc-1",
41
+ },
42
+ name: "tok-A",
43
+ active: true,
44
+ ord: 0,
45
+ quota_threshold: null,
46
+ };
47
+
48
+ describe("QgridFrame.updateToken", () => {
49
+ beforeEach(() => {
50
+ findOneMock.mockReset();
51
+ saveMock.mockReset();
52
+ saveMock.mockResolvedValue([1]);
53
+ requestLogSaveMock.mockReset();
54
+ requestLogSaveMock.mockResolvedValue([1]);
55
+ dispatcherQueryMock.mockReset();
56
+ });
57
+
58
+ it("rejects quota thresholds outside TokenSaveParams bounds before saving", async () => {
59
+ findOneMock.mockResolvedValueOnce(tokenEntry);
60
+
61
+ await expect(QgridFrame.updateToken(1, "tok-A", 0)).rejects.toThrow(
62
+ "quotaThreshold must be an integer between 1 and 100, or null",
63
+ );
64
+
65
+ expect(saveMock).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("saves valid quota thresholds through the same schema used by token saves", async () => {
69
+ findOneMock.mockResolvedValueOnce(tokenEntry);
70
+
71
+ await expect(QgridFrame.updateToken(1, "tok-A", 80)).resolves.toEqual({ updated: true });
72
+
73
+ expect(saveMock).toHaveBeenCalledWith([
74
+ expect.objectContaining({
75
+ id: 1,
76
+ name: "tok-A",
77
+ quota_threshold: 80,
78
+ }),
79
+ ]);
80
+ });
81
+ });
82
+
83
+ describe("QgridFrame.query auto logging", () => {
84
+ beforeEach(() => {
85
+ requestLogSaveMock.mockReset();
86
+ requestLogSaveMock.mockResolvedValue([1]);
87
+ dispatcherQueryMock.mockReset();
88
+ });
89
+
90
+ function queryOutput(ttftMs: number) {
91
+ return {
92
+ text: "hello",
93
+ content: [{ type: "text", text: "hello" }],
94
+ finishReason: "stop",
95
+ tokenName: "tok-A",
96
+ model: "gpt-5-codex",
97
+ usage: {
98
+ input_tokens: 5,
99
+ output_tokens: 7,
100
+ cache_creation_input_tokens: 0,
101
+ cache_read_input_tokens: 0,
102
+ },
103
+ durationMs: 120,
104
+ ttftMs,
105
+ costUsd: 0.001,
106
+ };
107
+ }
108
+
109
+ it("persists auto request ttft_ms from QueryOutput", async () => {
110
+ dispatcherQueryMock.mockResolvedValueOnce(queryOutput(39));
111
+
112
+ await QgridFrame.query({ prompt: "hi", model: "openai/gpt-5-codex", logMode: "auto" });
113
+
114
+ expect(requestLogSaveMock).toHaveBeenCalledWith([
115
+ expect.objectContaining({ duration_ms: 120, ttft_ms: 39 }),
116
+ ]);
117
+ });
118
+
119
+ it("persists auto request ttft_ms zero when no first-token timing is available", async () => {
120
+ dispatcherQueryMock.mockResolvedValueOnce(queryOutput(0));
121
+
122
+ await QgridFrame.query({ prompt: "hi", model: "openai/gpt-5-codex", logMode: "auto" });
123
+
124
+ expect(requestLogSaveMock).toHaveBeenCalledWith([
125
+ expect.objectContaining({ ttft_ms: 0 }),
126
+ ]);
127
+ });
128
+ });
@@ -2,9 +2,10 @@ import crypto from "node:crypto";
2
2
 
3
3
  import { getLogger } from "@logtape/logtape";
4
4
  import { type FastifyReply } from "fastify";
5
- import { api, BaseFrameClass, Sonamu, stream } from "sonamu";
5
+ import { api, BadRequestException, BaseFrameClass, Sonamu, stream } from "sonamu";
6
6
  import { getCacheManagerRef } from "sonamu/cache";
7
7
 
8
+ import { type LocalizedString } from "../../i18n/sd.generated";
8
9
  import {
9
10
  getAccessToken,
10
11
  getExpiresAt,
@@ -13,7 +14,11 @@ import {
13
14
  import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
14
15
  import { type TokenSubsetA } from "../sonamu.generated";
15
16
  import { TokenModel } from "../token/token.model";
16
- import { TokenCredentials } from "../token/token.types";
17
+ import {
18
+ TokenCredentials,
19
+ TokenSaveParams,
20
+ type TokenSaveParams as TokenSaveParamsType,
21
+ } from "../token/token.types";
17
22
  import {
18
23
  type AnthropicUsageRaw,
19
24
  buildAuthUrl,
@@ -49,6 +54,10 @@ type PendingOAuth = { codeVerifier: string; name: string; redirectUri: string };
49
54
  const OAUTH_STATE_PREFIX = "oauth:state:";
50
55
  const OAUTH_STATE_TTL = "5m";
51
56
 
57
+ function unixSecondsToIso(seconds: number | null | undefined): string | null {
58
+ return typeof seconds === "number" ? new Date(seconds * 1000).toISOString() : null;
59
+ }
60
+
52
61
  async function setOAuthState(state: string, data: PendingOAuth): Promise<void> {
53
62
  const cache = getCacheManagerRef();
54
63
  if (!cache) throw new Error("CacheManager not initialized");
@@ -142,6 +151,7 @@ class QgridFrameClass extends BaseFrameClass {
142
151
  cache_read_tokens: result.usage.cache_read_input_tokens,
143
152
  cache_creation_tokens: result.usage.cache_creation_input_tokens,
144
153
  duration_ms: result.durationMs,
154
+ ttft_ms: result.ttftMs,
145
155
  cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
146
156
  effort: args.effort ?? null,
147
157
  // malformed history가 와도 성공한 턴(특히 stream sse.end())을 깨지 않도록 방어.
@@ -296,6 +306,7 @@ class QgridFrameClass extends BaseFrameClass {
296
306
  cache_read_tokens: input.cacheReadTokens,
297
307
  cache_creation_tokens: input.cacheCreationTokens,
298
308
  duration_ms: input.durationMs,
309
+ ttft_ms: input.ttftMs ?? null,
299
310
  finish_reason: input.finishReason,
300
311
  reasoning_text: input.reasoningText,
301
312
  reasoning_tokens: input.reasoningTokens,
@@ -359,18 +370,33 @@ class QgridFrameClass extends BaseFrameClass {
359
370
  }
360
371
 
361
372
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
362
- async updateToken(id: number, name?: string): Promise<{ updated: boolean }> {
373
+ async updateToken(
374
+ id: number,
375
+ name?: string,
376
+ quotaThreshold?: number | null,
377
+ ): Promise<{ updated: boolean }> {
363
378
  const entry = await TokenModel.findOne("A", { id });
364
379
  if (!entry) return { updated: false };
365
380
 
366
- await TokenModel.save([
367
- {
368
- id: entry.id,
369
- provider: entry.provider,
370
- credentials: entry.credentials,
371
- name: name !== undefined ? name : entry.name,
372
- },
373
- ]);
381
+ const patch: TokenSaveParamsType = {
382
+ id: entry.id,
383
+ provider: entry.provider,
384
+ credentials: entry.credentials,
385
+ name: name !== undefined ? name : entry.name,
386
+ };
387
+ if (quotaThreshold !== undefined) {
388
+ patch.quota_threshold = quotaThreshold;
389
+ }
390
+
391
+ const parsed = TokenSaveParams.safeParse(patch);
392
+ if (!parsed.success) {
393
+ throw new BadRequestException(
394
+ "quotaThreshold must be an integer between 1 and 100, or null" as LocalizedString,
395
+ { zodError: parsed.error },
396
+ );
397
+ }
398
+
399
+ await TokenModel.save([parsed.data]);
374
400
  return { updated: true };
375
401
  }
376
402
 
@@ -513,25 +539,20 @@ class QgridFrameClass extends BaseFrameClass {
513
539
 
514
540
  if (entry.provider === "openai") {
515
541
  try {
516
- const raw = (await QgridDispatcher.openaiDispatcher?.getRateLimits(entry.name)) as {
517
- rateLimits?: {
518
- primary?: { usedPercent: number; windowDurationMins: number; resetsAt: number };
519
- secondary?: { usedPercent: number; windowDurationMins: number; resetsAt: number };
520
- };
521
- };
522
- const rl = raw?.rateLimits;
542
+ const raw = await QgridDispatcher.openaiDispatcher?.getRateLimitsByTokenId(entry.id);
543
+ const rl = raw?.data.rateLimits;
523
544
  return {
524
545
  provider: "openai",
525
546
  fiveHour: rl?.primary
526
547
  ? {
527
548
  utilization: rl.primary.usedPercent,
528
- resetsAt: new Date(rl.primary.resetsAt * 1000).toISOString(),
549
+ resetsAt: unixSecondsToIso(rl.primary.resetsAt),
529
550
  }
530
551
  : null,
531
552
  sevenDay: rl?.secondary
532
553
  ? {
533
554
  utilization: rl.secondary.usedPercent,
534
- resetsAt: new Date(rl.secondary.resetsAt * 1000).toISOString(),
555
+ resetsAt: unixSecondsToIso(rl.secondary.resetsAt),
535
556
  }
536
557
  : null,
537
558
  };
@@ -102,6 +102,7 @@ export const QueryOutput = z.object({
102
102
  cache_read_input_tokens: z.number(),
103
103
  }),
104
104
  durationMs: z.number(),
105
+ ttftMs: z.number(),
105
106
  costUsd: z.number(),
106
107
  runContext: QgridRunContext.optional(),
107
108
  });
@@ -128,6 +129,7 @@ export const StreamEvents = z.object({
128
129
  cache_read_input_tokens: z.number(),
129
130
  }),
130
131
  durationMs: z.number(),
132
+ ttftMs: z.number(),
131
133
  costUsd: z.number(),
132
134
  content: z.array(QgridContent),
133
135
  runContext: QgridRunContext.optional(),
@@ -157,6 +159,7 @@ export const AppendStepInput = z.object({
157
159
  cacheReadTokens: z.number().optional(),
158
160
  cacheCreationTokens: z.number().optional(),
159
161
  durationMs: z.number().optional(),
162
+ ttftMs: z.number().nullable().optional(),
160
163
  finishReason: z.string().optional(),
161
164
  reasoningText: z.string().optional(),
162
165
  reasoningTokens: z.number().optional(),
@@ -250,6 +253,9 @@ export type HealthResponse = z.infer<typeof HealthResponse>;
250
253
  export class QuotaError extends Error {
251
254
  readonly code = "QUOTA_EXHAUSTED" as const;
252
255
  }
256
+ export class QuotaThresholdExceededError extends Error {
257
+ readonly code = "QUOTA_THRESHOLD_EXCEEDED" as const;
258
+ }
253
259
  export class TimeoutError extends Error {
254
260
  readonly code = "TIMEOUT" as const;
255
261
  }
@@ -0,0 +1,89 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { TokenSubscriber } from "./token-subscriber";
4
+
5
+ const { findOneMock, findActiveMock } = vi.hoisted(() => ({
6
+ findOneMock: vi.fn(),
7
+ findActiveMock: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("../token/token.model", () => ({
11
+ TokenModel: {
12
+ findOne: findOneMock,
13
+ findActive: findActiveMock,
14
+ },
15
+ }));
16
+
17
+ function openaiToken(active = true) {
18
+ return {
19
+ id: 1,
20
+ provider: "openai",
21
+ active,
22
+ name: "tok-A",
23
+ credentials: { accessToken: "access", accountId: "account" },
24
+ quota_threshold: 80,
25
+ };
26
+ }
27
+
28
+ function subscriberWith(openaiDispatcher: Record<string, unknown>) {
29
+ return new TokenSubscriber(
30
+ {} as never,
31
+ {
32
+ removeCache: vi.fn(),
33
+ upsertCache: vi.fn(),
34
+ replaceCache: vi.fn(),
35
+ openaiDispatcher,
36
+ anthropicDispatcher: null,
37
+ } as never,
38
+ );
39
+ }
40
+
41
+ async function flushPromises(): Promise<void> {
42
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
+ }
44
+
45
+ describe("TokenSubscriber OpenAI notifications", () => {
46
+ beforeEach(() => {
47
+ findOneMock.mockReset();
48
+ findActiveMock.mockReset();
49
+ });
50
+
51
+ it("updates OpenAI token metadata before activating workers", async () => {
52
+ const calls: string[] = [];
53
+ const openaiDispatcher = {
54
+ onTokenUpdated: vi.fn(async () => {
55
+ calls.push("updated");
56
+ }),
57
+ onTokenActivated: vi.fn(() => {
58
+ calls.push("activated");
59
+ }),
60
+ };
61
+ const subscriber = subscriberWith(openaiDispatcher);
62
+ findOneMock.mockResolvedValueOnce(openaiToken(true));
63
+
64
+ await subscriber.handleNotification(JSON.stringify({ op: "UPDATE", id: 1 }));
65
+ await flushPromises();
66
+
67
+ expect(calls).toEqual(["updated", "activated"]);
68
+ expect(openaiDispatcher.onTokenUpdated).toHaveBeenCalledWith(
69
+ 1,
70
+ "tok-A",
71
+ { accessToken: "access", accountId: "account" },
72
+ 80,
73
+ );
74
+ });
75
+
76
+ it("does not spawn workers for inactive OpenAI inserts", async () => {
77
+ const openaiDispatcher = {
78
+ onTokenAdded: vi.fn(async () => {}),
79
+ onTokenDeactivated: vi.fn(),
80
+ };
81
+ const subscriber = subscriberWith(openaiDispatcher);
82
+ findOneMock.mockResolvedValueOnce(openaiToken(false));
83
+
84
+ await subscriber.handleNotification(JSON.stringify({ op: "INSERT", id: 1 }));
85
+
86
+ expect(openaiDispatcher.onTokenAdded).not.toHaveBeenCalled();
87
+ expect(openaiDispatcher.onTokenDeactivated).toHaveBeenCalledWith(1);
88
+ });
89
+ });
@@ -177,17 +177,20 @@ export class TokenSubscriber {
177
177
 
178
178
  if (row.provider === "openai") {
179
179
  const creds = row.credentials as Record<string, unknown>;
180
- if (payload.op === "INSERT") {
181
- this.dispatcher.openaiDispatcher
182
- ?.onTokenAdded(payload.id, row.name, creds as OpenAICredentials)
180
+ const openaiDispatcher = this.dispatcher.openaiDispatcher;
181
+ if (payload.op === "INSERT" && row.active) {
182
+ openaiDispatcher
183
+ ?.onTokenAdded(payload.id, row.name, creds as OpenAICredentials, row.quota_threshold)
183
184
  .catch((e) => logger.warn(`openai worker spawn failed: ${(e as Error).message}`));
184
185
  } else if (row.active) {
185
- this.dispatcher.openaiDispatcher?.onTokenActivated(payload.id);
186
- this.dispatcher.openaiDispatcher
187
- ?.onTokenUpdated(payload.id, row.name, creds as OpenAICredentials)
188
- .catch((e) => logger.warn(`openai worker update failed: ${(e as Error).message}`));
186
+ if (openaiDispatcher) {
187
+ openaiDispatcher
188
+ .onTokenUpdated(payload.id, row.name, creds as OpenAICredentials, row.quota_threshold)
189
+ .then(() => openaiDispatcher.onTokenActivated(payload.id))
190
+ .catch((e) => logger.warn(`openai worker update failed: ${(e as Error).message}`));
191
+ }
189
192
  } else {
190
- this.dispatcher.openaiDispatcher?.onTokenDeactivated(payload.id);
193
+ openaiDispatcher?.onTokenDeactivated(payload.id);
191
194
  }
192
195
  } else if (row.provider === "anthropic") {
193
196
  // anthropic 토큰 이벤트는 동기(void). worker 가 없고 풀(Map)만 관리하므로 단순:
@@ -195,9 +198,19 @@ export class TokenSubscriber {
195
198
  // (별도 activate/deactivate 콜백이 없어 active 토글을 add/remove 로 매핑.)
196
199
  const creds = row.credentials as AnthropicCredentials;
197
200
  if (payload.op === "INSERT" && row.active) {
198
- this.dispatcher.anthropicDispatcher?.onTokenAdded(payload.id, row.name, creds);
201
+ this.dispatcher.anthropicDispatcher?.onTokenAdded(
202
+ payload.id,
203
+ row.name,
204
+ creds,
205
+ row.quota_threshold,
206
+ );
199
207
  } else if (row.active) {
200
- this.dispatcher.anthropicDispatcher?.onTokenUpdated(payload.id, row.name, creds);
208
+ this.dispatcher.anthropicDispatcher?.onTokenUpdated(
209
+ payload.id,
210
+ row.name,
211
+ creds,
212
+ row.quota_threshold,
213
+ );
201
214
  } else {
202
215
  // inactive 면 INSERT 든 UPDATE 든 풀에 넣지 않는다.
203
216
  this.dispatcher.anthropicDispatcher?.onTokenRemoved(payload.id);
@@ -213,8 +226,24 @@ export class TokenSubscriber {
213
226
  // (rows 는 active 만 — inactive/삭제된 토큰은 여기 없으므로 replaceTokens 가 풀에서 제거한다.)
214
227
  const anthropicRows = rows
215
228
  .filter((r) => r.provider === "anthropic")
216
- .map((r) => ({ id: r.id, name: r.name, credentials: r.credentials as AnthropicCredentials }));
229
+ .map((r) => ({
230
+ id: r.id,
231
+ name: r.name,
232
+ credentials: r.credentials as AnthropicCredentials,
233
+ quotaThreshold: r.quota_threshold,
234
+ }));
217
235
  this.dispatcher.anthropicDispatcher?.replaceTokens(anthropicRows);
236
+ const openaiRows = rows
237
+ .filter((r) => r.provider === "openai")
238
+ .map((r) => ({
239
+ id: r.id,
240
+ name: r.name,
241
+ credentials: r.credentials as OpenAICredentials,
242
+ quotaThreshold: r.quota_threshold,
243
+ }));
244
+ await this.dispatcher.openaiDispatcher
245
+ ?.replaceTokens(openaiRows)
246
+ .catch((e) => logger.warn(`openai reconcile failed: ${(e as Error).message}`));
218
247
  this.lastReconcileAt = new Date();
219
248
  }
220
249
  }