@cartanova/qgrid-cli 2.3.5 → 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 (92) 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 +8 -3
  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.ts +7 -2
  59. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
  60. package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
  61. package/bundle/src/utils/providers/common/ttft.ts +26 -0
  62. package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
  63. package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
  65. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
  66. package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
  67. package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
  68. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
  69. package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
  70. package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
  71. package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
  72. package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
  73. package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
  74. package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
  75. package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
  76. package/bundle/web-dist/client/index.html +3 -3
  77. package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
  78. package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
  79. package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
  80. package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
  81. package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
  82. package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
  83. package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
  84. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  85. package/dist/cli.js +67 -15
  86. package/package.json +1 -1
  87. package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
  88. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
  89. package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
  90. package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
  91. package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
  92. /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
@@ -39,7 +39,8 @@ const SETUP_SQL = `
39
39
  OLD.active IS DISTINCT FROM NEW.active OR
40
40
  OLD.credentials IS DISTINCT FROM NEW.credentials OR
41
41
  OLD.provider IS DISTINCT FROM NEW.provider OR
42
- OLD.name IS DISTINCT FROM NEW.name
42
+ OLD.name IS DISTINCT FROM NEW.name OR
43
+ OLD.quota_threshold IS DISTINCT FROM NEW.quota_threshold
43
44
  )
44
45
  EXECUTE FUNCTION public.tokens_notify();
45
46
  `;
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { buildToolCallSchema } from "./tool-emulation";
4
3
  import { type QgridTool } from "./qgrid.types";
4
+ import { buildToolCallSchema } from "./tool-emulation";
5
5
 
6
6
  describe("buildToolCallSchema", () => {
7
7
  it("documents that emulated tools must be requested through StructuredOutput", () => {
@@ -36,9 +36,13 @@ describe("buildToolCallSchema", () => {
36
36
  };
37
37
 
38
38
  expect(schema.description).toContain("StructuredOutput");
39
- expect(schema.description).toContain("Do not invoke listed client tools as native Claude Code tools");
39
+ expect(schema.description).toContain(
40
+ "Do not invoke listed client tools as native Claude Code tools",
41
+ );
40
42
  expect(schema.properties?.action?.description).toContain("tool_call");
41
- expect(schema.properties?.toolCalls?.description).toContain("Do not call these tools as native");
43
+ expect(schema.properties?.toolCalls?.description).toContain(
44
+ "Do not call these tools as native",
45
+ );
42
46
  expect(
43
47
  schema.properties?.toolCalls?.anyOf?.[0]?.items?.properties?.toolName?.description,
44
48
  ).toContain("getWeather");
@@ -42,6 +42,7 @@
42
42
  "dbDefault": "0"
43
43
  },
44
44
  { "name": "duration_ms", "type": "integer", "desc": "응답 소요시간(ms)", "dbDefault": "0" },
45
+ { "name": "ttft_ms", "type": "integer", "desc": "first gen TTFT(ms)", "dbDefault": "0" },
45
46
  { "name": "cost_usd", "type": "integer", "nullable": true, "desc": "API 비용" },
46
47
  { "name": "effort", "type": "string", "length": 10, "nullable": true, "desc": "effort level" },
47
48
  {
@@ -91,6 +92,7 @@
91
92
  "cache_read_tokens",
92
93
  "cache_creation_tokens",
93
94
  "duration_ms",
95
+ "ttft_ms",
94
96
  "cost_usd",
95
97
  "effort",
96
98
  "history",
@@ -111,6 +113,7 @@
111
113
  "cache_read_tokens",
112
114
  "cache_creation_tokens",
113
115
  "duration_ms",
116
+ "ttft_ms",
114
117
  "cost_usd",
115
118
  "effort",
116
119
  "status",
@@ -0,0 +1,92 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { RequestLogModel } from "./request-log.model";
4
+
5
+ function mockQueryBuilder(rows: unknown[]) {
6
+ const chain = {
7
+ select: vi.fn(() => chain),
8
+ where: vi.fn(() => chain),
9
+ whereNotNull: vi.fn(() => chain),
10
+ orderBy: vi.fn(() => chain),
11
+ limit: vi.fn(async () => rows),
12
+ };
13
+ return chain;
14
+ }
15
+
16
+ describe("RequestLogModel TTFT", () => {
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it("reads the first non-null generate-step ttft", async () => {
22
+ const chain = mockQueryBuilder([{ ttft_ms: 42 }]);
23
+ const db = vi.fn(() => chain);
24
+ vi.spyOn(RequestLogModel as unknown as { getDB: () => typeof db }, "getDB").mockReturnValue(
25
+ db,
26
+ );
27
+
28
+ await expect(RequestLogModel.firstGenerateStepTtft(7)).resolves.toBe(42);
29
+
30
+ expect(db).toHaveBeenCalledWith("request_log_steps");
31
+ expect(chain.where).toHaveBeenCalledWith("request_log_id", 7);
32
+ expect(chain.where).toHaveBeenCalledWith("type", "generate");
33
+ expect(chain.whereNotNull).toHaveBeenCalledWith("ttft_ms");
34
+ expect(chain.orderBy).toHaveBeenNthCalledWith(1, "step_index", "asc");
35
+ expect(chain.orderBy).toHaveBeenNthCalledWith(2, "id", "asc");
36
+ });
37
+
38
+ it("returns null when no generate step has ttft", async () => {
39
+ const db = vi.fn(() => mockQueryBuilder([]));
40
+ vi.spyOn(RequestLogModel as unknown as { getDB: () => typeof db }, "getDB").mockReturnValue(
41
+ db,
42
+ );
43
+
44
+ await expect(RequestLogModel.firstGenerateStepTtft(7)).resolves.toBeNull();
45
+ });
46
+
47
+ it("stores server-derived ttft_ms during finishRun", async () => {
48
+ const ubRegister = vi.fn();
49
+ const transaction = vi.fn(async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => void) =>
50
+ cb({ ubUpsert: vi.fn(async () => [7]) }),
51
+ );
52
+ vi.spyOn(RequestLogModel, "firstGenerateStepTtft").mockResolvedValue(55);
53
+ vi.spyOn(
54
+ RequestLogModel as unknown as { getPuri: () => { ubRegister: typeof ubRegister; transaction: typeof transaction } },
55
+ "getPuri",
56
+ ).mockReturnValue({ ubRegister, transaction });
57
+
58
+ await RequestLogModel.finishRun(7, { status: "error", error_message: "boom" });
59
+
60
+ expect(ubRegister).toHaveBeenCalledWith(
61
+ "request_logs",
62
+ expect.objectContaining({
63
+ id: 7,
64
+ status: "error",
65
+ error_message: "boom",
66
+ ttft_ms: 55,
67
+ }),
68
+ );
69
+ });
70
+
71
+ it("stores zero during finishRun when no generate step has ttft", async () => {
72
+ const ubRegister = vi.fn();
73
+ const transaction = vi.fn(async (cb: (trx: { ubUpsert: () => Promise<number[]> }) => void) =>
74
+ cb({ ubUpsert: vi.fn(async () => [7]) }),
75
+ );
76
+ vi.spyOn(RequestLogModel, "firstGenerateStepTtft").mockResolvedValue(null);
77
+ vi.spyOn(
78
+ RequestLogModel as unknown as { getPuri: () => { ubRegister: typeof ubRegister; transaction: typeof transaction } },
79
+ "getPuri",
80
+ ).mockReturnValue({ ubRegister, transaction });
81
+
82
+ await RequestLogModel.finishRun(7, { status: "error", error_message: "boom" });
83
+
84
+ expect(ubRegister).toHaveBeenCalledWith(
85
+ "request_logs",
86
+ expect.objectContaining({
87
+ id: 7,
88
+ ttft_ms: 0,
89
+ }),
90
+ );
91
+ });
92
+ });
@@ -46,15 +46,15 @@ function normalizedUsageForCost(row: RequestLogUsageRow): {
46
46
  cachedInputTokens: number;
47
47
  cacheCreationInputTokens: number;
48
48
  } {
49
- const cacheRead = Number(row.cache_read_tokens ?? 0);
50
- const cacheCreation = Number(row.cache_creation_tokens ?? 0);
51
- const storedInput = Number(row.input_tokens ?? 0);
49
+ const cacheRead = row.cache_read_tokens;
50
+ const cacheCreation = row.cache_creation_tokens;
51
+ const storedInput = row.input_tokens;
52
52
  const legacyAnthropicSplitInput =
53
53
  isAnthropicUsageRow(row) && storedInput < cacheRead + cacheCreation;
54
54
  return {
55
55
  model: row.model_name ? canonicalModelName(row.model_name) : null,
56
56
  inputTokens: legacyAnthropicSplitInput ? storedInput + cacheRead + cacheCreation : storedInput,
57
- outputTokens: Number(row.output_tokens ?? 0),
57
+ outputTokens: row.output_tokens,
58
58
  cachedInputTokens: cacheRead,
59
59
  cacheCreationInputTokens: cacheCreation,
60
60
  };
@@ -62,7 +62,7 @@ function normalizedUsageForCost(row: RequestLogUsageRow): {
62
62
 
63
63
  function normalizeLegacyAnthropicRow<T extends RequestLogUsageRow>(row: T): T {
64
64
  const usage = normalizedUsageForCost(row);
65
- if (usage.inputTokens === Number(row.input_tokens ?? 0)) return row;
65
+ if (usage.inputTokens === row.input_tokens) return row;
66
66
  const normalized = { ...row, input_tokens: usage.inputTokens };
67
67
  if (usage.model) {
68
68
  normalized.cost_usd = Math.round(calculateCostUsd(usage.model, usage) * MICRO_USD);
@@ -280,6 +280,7 @@ class RequestLogModelClass extends BaseModelClass<
280
280
  if (params[key] !== undefined) update[key] = params[key];
281
281
  }
282
282
  if (params.history !== undefined) update.history = params.history;
283
+ update.ttft_ms = (await this.firstGenerateStepTtft(requestLogId)) ?? 0;
283
284
 
284
285
  if (params.input_tokens !== undefined && params.output_tokens !== undefined) {
285
286
  const db = this.getDB("r");
@@ -315,6 +316,19 @@ class RequestLogModelClass extends BaseModelClass<
315
316
  });
316
317
  }
317
318
 
319
+ async firstGenerateStepTtft(requestLogId: number): Promise<number | null> {
320
+ const db = this.getDB("r");
321
+ const [row] = await db("request_log_steps")
322
+ .select("ttft_ms")
323
+ .where("request_log_id", requestLogId)
324
+ .where("type", "generate")
325
+ .whereNotNull("ttft_ms")
326
+ .orderBy("step_index", "asc")
327
+ .orderBy("id", "asc")
328
+ .limit(1);
329
+ return row?.ttft_ms === null || row?.ttft_ms === undefined ? null : Number(row.ttft_ms);
330
+ }
331
+
318
332
  async aggregateStepUsage(requestLogId: number): Promise<{
319
333
  input_tokens: number;
320
334
  output_tokens: number;
@@ -410,7 +424,7 @@ class RequestLogModelClass extends BaseModelClass<
410
424
 
411
425
  return rows.reduce((sum, row) => {
412
426
  const usage = normalizedUsageForCost(row);
413
- if (!usage.model) return sum + Math.max(Number(row.cost_usd ?? 0), 0) / MICRO_USD;
427
+ if (!usage.model) return sum + Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
414
428
  return (
415
429
  sum +
416
430
  calculateCostUsd(usage.model, {
@@ -32,6 +32,7 @@
32
32
  "desc": "캐시 생성 토큰"
33
33
  },
34
34
  { "name": "duration_ms", "type": "integer", "nullable": true, "desc": "소요시간(ms)" },
35
+ { "name": "ttft_ms", "type": "integer", "nullable": true, "desc": "Gen TTFT(ms)" },
35
36
  {
36
37
  "name": "finish_reason",
37
38
  "type": "string",
@@ -99,6 +100,7 @@
99
100
  "cache_read_tokens",
100
101
  "cache_creation_tokens",
101
102
  "duration_ms",
103
+ "ttft_ms",
102
104
  "finish_reason",
103
105
  "reasoning_text",
104
106
  "reasoning_tokens",
@@ -120,6 +122,7 @@
120
122
  "cache_read_tokens",
121
123
  "cache_creation_tokens",
122
124
  "duration_ms",
125
+ "ttft_ms",
123
126
  "finish_reason",
124
127
  "reasoning_tokens",
125
128
  "tool_call_index",
@@ -39,7 +39,8 @@ Content-Type: application/json
39
39
  },
40
40
  "name": "NAME",
41
41
  "active": false,
42
- "ord": 0
42
+ "ord": 0,
43
+ "quota_threshold": null
43
44
  }
44
45
  ]
45
46
  }
@@ -106,6 +107,7 @@ Content-Type: application/json
106
107
  "cache_read_tokens": null,
107
108
  "cache_creation_tokens": null,
108
109
  "duration_ms": null,
110
+ "ttft_ms": null,
109
111
  "finish_reason": null,
110
112
  "reasoning_text": null,
111
113
  "reasoning_tokens": null,
@@ -178,6 +180,7 @@ Content-Type: application/json
178
180
  "cache_read_tokens": 0,
179
181
  "cache_creation_tokens": 0,
180
182
  "duration_ms": 0,
183
+ "ttft_ms": 0,
181
184
  "cost_usd": null,
182
185
  "effort": null,
183
186
  "history": null,
@@ -330,6 +333,7 @@ Content-Type: application/json
330
333
  "cacheReadTokens": 0,
331
334
  "cacheCreationTokens": 0,
332
335
  "durationMs": 0,
336
+ "ttftMs": null,
333
337
  "finishReason": "FINISHREASON",
334
338
  "reasoningText": "REASONINGTEXT",
335
339
  "reasoningTokens": 0,
@@ -405,7 +409,8 @@ Content-Type: application/json
405
409
 
406
410
  {
407
411
  "id": 0,
408
- "name": "NAME"
412
+ "name": "NAME",
413
+ "quotaThreshold": null
409
414
  }
410
415
 
411
416
  ###
@@ -31,6 +31,7 @@ export const requestLogSubsetQueries = {
31
31
  cache_read_tokens: "request_logs.cache_read_tokens",
32
32
  cache_creation_tokens: "request_logs.cache_creation_tokens",
33
33
  duration_ms: "request_logs.duration_ms",
34
+ ttft_ms: "request_logs.ttft_ms",
34
35
  cost_usd: "request_logs.cost_usd",
35
36
  effort: "request_logs.effort",
36
37
  history: "request_logs.history",
@@ -51,6 +52,7 @@ export const requestLogSubsetQueries = {
51
52
  cache_read_tokens: "request_logs.cache_read_tokens",
52
53
  cache_creation_tokens: "request_logs.cache_creation_tokens",
53
54
  duration_ms: "request_logs.duration_ms",
55
+ ttft_ms: "request_logs.ttft_ms",
54
56
  cost_usd: "request_logs.cost_usd",
55
57
  effort: "request_logs.effort",
56
58
  status: "request_logs.status",
@@ -78,6 +80,7 @@ export const requestLogStepSubsetQueries = {
78
80
  cache_read_tokens: "request_log_steps.cache_read_tokens",
79
81
  cache_creation_tokens: "request_log_steps.cache_creation_tokens",
80
82
  duration_ms: "request_log_steps.duration_ms",
83
+ ttft_ms: "request_log_steps.ttft_ms",
81
84
  finish_reason: "request_log_steps.finish_reason",
82
85
  reasoning_text: "request_log_steps.reasoning_text",
83
86
  reasoning_tokens: "request_log_steps.reasoning_tokens",
@@ -101,6 +104,7 @@ export const requestLogStepSubsetQueries = {
101
104
  cache_read_tokens: "request_log_steps.cache_read_tokens",
102
105
  cache_creation_tokens: "request_log_steps.cache_creation_tokens",
103
106
  duration_ms: "request_log_steps.duration_ms",
107
+ ttft_ms: "request_log_steps.ttft_ms",
104
108
  finish_reason: "request_log_steps.finish_reason",
105
109
  reasoning_tokens: "request_log_steps.reasoning_tokens",
106
110
  tool_call_index: "request_log_steps.tool_call_index",
@@ -129,6 +133,7 @@ export const tokenSubsetQueries = {
129
133
  name: "tokens.name",
130
134
  active: "tokens.active",
131
135
  ord: "tokens.ord",
136
+ quota_threshold: "tokens.quota_threshold",
132
137
  });
133
138
  },
134
139
  };
@@ -100,6 +100,7 @@ export const RequestLogBaseSchema = z.object({
100
100
  cache_read_tokens: z.int(),
101
101
  cache_creation_tokens: z.int(),
102
102
  duration_ms: z.int(),
103
+ ttft_ms: z.int(),
103
104
  cost_usd: z.int().nullable(),
104
105
  effort: z.string().max(10).nullable(),
105
106
  history: HistoryItems.nullable(),
@@ -121,6 +122,7 @@ export type RequestLogBaseSchema = z.infer<typeof RequestLogBaseSchema> & {
121
122
  "cache_read_tokens",
122
123
  "cache_creation_tokens",
123
124
  "duration_ms",
125
+ "ttft_ms",
124
126
  "cost_usd",
125
127
  "effort",
126
128
  "history",
@@ -143,6 +145,7 @@ export const RequestLogStepBaseSchema = z.object({
143
145
  cache_read_tokens: z.int().nullable(),
144
146
  cache_creation_tokens: z.int().nullable(),
145
147
  duration_ms: z.int().nullable(),
148
+ ttft_ms: z.int().nullable(),
146
149
  finish_reason: z.string().max(20).nullable(),
147
150
  reasoning_text: z.string().nullable(),
148
151
  reasoning_tokens: z.int().nullable(),
@@ -162,6 +165,7 @@ export type RequestLogStepBaseSchema = z.infer<typeof RequestLogStepBaseSchema>
162
165
  "cache_read_tokens",
163
166
  "cache_creation_tokens",
164
167
  "duration_ms",
168
+ "ttft_ms",
165
169
  "finish_reason",
166
170
  "reasoning_text",
167
171
  "reasoning_tokens",
@@ -185,9 +189,10 @@ export const TokenBaseSchema = z.object({
185
189
  name: z.string(),
186
190
  active: z.boolean(),
187
191
  ord: z.int(),
192
+ quota_threshold: z.int().nullable(),
188
193
  });
189
194
  export type TokenBaseSchema = z.infer<typeof TokenBaseSchema> & {
190
- readonly __hasDefault__: readonly ["created_at", "active", "ord", "id"];
195
+ readonly __hasDefault__: readonly ["created_at", "active", "ord", "quota_threshold", "id"];
191
196
  };
192
197
 
193
198
  // BaseListParams: RequestLog
@@ -254,6 +259,7 @@ export const RequestLogSubsetA = z.object({
254
259
  cache_read_tokens: z.int(),
255
260
  cache_creation_tokens: z.int(),
256
261
  duration_ms: z.int(),
262
+ ttft_ms: z.int(),
257
263
  cost_usd: z.int().nullable(),
258
264
  effort: z.string().max(10).nullable(),
259
265
  history: HistoryItems.nullable(),
@@ -273,6 +279,7 @@ export const RequestLogSubsetP = z.object({
273
279
  cache_read_tokens: z.int(),
274
280
  cache_creation_tokens: z.int(),
275
281
  duration_ms: z.int(),
282
+ ttft_ms: z.int(),
276
283
  cost_usd: z.int().nullable(),
277
284
  effort: z.string().max(10).nullable(),
278
285
  status: RequestLogStatus,
@@ -297,6 +304,7 @@ export const RequestLogStepSubsetA = z.object({
297
304
  cache_read_tokens: z.int().nullable(),
298
305
  cache_creation_tokens: z.int().nullable(),
299
306
  duration_ms: z.int().nullable(),
307
+ ttft_ms: z.int().nullable(),
300
308
  finish_reason: z.string().max(20).nullable(),
301
309
  reasoning_text: z.string().nullable(),
302
310
  reasoning_tokens: z.int().nullable(),
@@ -319,6 +327,7 @@ export const RequestLogStepSubsetT = z.object({
319
327
  cache_read_tokens: z.int().nullable(),
320
328
  cache_creation_tokens: z.int().nullable(),
321
329
  duration_ms: z.int().nullable(),
330
+ ttft_ms: z.int().nullable(),
322
331
  finish_reason: z.string().max(20).nullable(),
323
332
  reasoning_tokens: z.int().nullable(),
324
333
  tool_call_index: z.int().nullable(),
@@ -344,6 +353,7 @@ export const TokenSubsetA = z.object({
344
353
  name: z.string(),
345
354
  active: z.boolean(),
346
355
  ord: z.int(),
356
+ quota_threshold: z.int().nullable(),
347
357
  });
348
358
  export type TokenSubsetA = z.infer<typeof TokenSubsetA>;
349
359
  export type TokenSubsetMapping = {
@@ -14,11 +14,17 @@
14
14
  },
15
15
  { "name": "name", "desc": "토큰 이름", "type": "string" },
16
16
  { "name": "active", "type": "boolean", "desc": "활성 여부", "dbDefault": "true" },
17
- { "name": "ord", "type": "integer", "desc": "정렬 순서", "dbDefault": "0" }
17
+ { "name": "ord", "type": "integer", "desc": "정렬 순서", "dbDefault": "0" },
18
+ {
19
+ "name": "quota_threshold",
20
+ "type": "integer",
21
+ "nullable": true,
22
+ "desc": "quota 사용률 임계치(%). null이면 미설정"
23
+ }
18
24
  ],
19
25
  "indexes": [],
20
26
  "subsets": {
21
- "A": ["id", "created_at", "provider", "credentials", "name", "active", "ord"]
27
+ "A": ["id", "created_at", "provider", "credentials", "name", "active", "ord", "quota_threshold"]
22
28
  },
23
29
  "enums": {
24
30
  "TokenOrderBy": { "id-desc": "ID최신순", "ord-asc": "순서순" },
@@ -0,0 +1,69 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { TokenModel } from "./token.model";
4
+
5
+ const baseToken = {
6
+ provider: "anthropic",
7
+ credentials: {
8
+ accessToken: "sk-ant-oat01-test",
9
+ refreshToken: "sk-ant-ort01-test",
10
+ expiresAt: Date.now() + 3_600_000,
11
+ accountUuid: "acc-1",
12
+ },
13
+ name: "tok-A",
14
+ };
15
+
16
+ type MockPuri = {
17
+ ubRegister: ReturnType<typeof vi.fn>;
18
+ transaction: ReturnType<typeof vi.fn>;
19
+ };
20
+
21
+ function mockWritePuri() {
22
+ const ubRegister = vi.fn();
23
+ const ubUpsert = vi.fn(async () => [1]);
24
+ const transaction = vi.fn(
25
+ async (cb: (trx: { ubUpsert: typeof ubUpsert }) => Promise<number[]>) =>
26
+ cb({ ubUpsert }),
27
+ );
28
+ vi.spyOn(TokenModel as unknown as { getPuri: (mode: "w") => MockPuri }, "getPuri")
29
+ .mockReturnValue({ ubRegister, transaction });
30
+
31
+ return { ubRegister, ubUpsert, transaction };
32
+ }
33
+
34
+ describe("TokenModel.save", () => {
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("applies 80% quota threshold to newly created tokens by default", async () => {
40
+ const { ubRegister } = mockWritePuri();
41
+
42
+ await TokenModel.save([baseToken]);
43
+
44
+ expect(ubRegister).toHaveBeenCalledWith(
45
+ "tokens",
46
+ expect.objectContaining({ quota_threshold: 80 }),
47
+ );
48
+ });
49
+
50
+ it("preserves explicit null quota threshold on newly created tokens", async () => {
51
+ const { ubRegister } = mockWritePuri();
52
+
53
+ await TokenModel.save([{ ...baseToken, quota_threshold: null }]);
54
+
55
+ expect(ubRegister).toHaveBeenCalledWith(
56
+ "tokens",
57
+ expect.objectContaining({ quota_threshold: null }),
58
+ );
59
+ });
60
+
61
+ it("does not inject a quota threshold into existing token updates", async () => {
62
+ const { ubRegister } = mockWritePuri();
63
+
64
+ await TokenModel.save([{ ...baseToken, id: 1 }]);
65
+
66
+ const saved = ubRegister.mock.calls[0]?.[1];
67
+ expect(saved).not.toHaveProperty("quota_threshold");
68
+ });
69
+ });
@@ -13,6 +13,13 @@ import { type TokenSubsetKey, type TokenSubsetMapping } from "../sonamu.generate
13
13
  import { tokenLoaderQueries, tokenSubsetQueries } from "../sonamu.generated.sso";
14
14
  import { type TokenListParams, type TokenSaveParams } from "./token.types";
15
15
 
16
+ const DEFAULT_QUOTA_THRESHOLD = 80;
17
+
18
+ function applyCreateDefaults(sp: TokenSaveParams): TokenSaveParams {
19
+ if (sp.id !== undefined || sp.quota_threshold !== undefined) return sp;
20
+ return { ...sp, quota_threshold: DEFAULT_QUOTA_THRESHOLD };
21
+ }
22
+
16
23
  class TokenModelClass extends BaseModelClass<
17
24
  TokenSubsetKey,
18
25
  TokenSubsetMapping,
@@ -143,7 +150,7 @@ class TokenModelClass extends BaseModelClass<
143
150
  async save(spa: TokenSaveParams[]): Promise<number[]> {
144
151
  const wdb = this.getPuri("w");
145
152
  spa.forEach((sp) => {
146
- wdb.ubRegister("tokens", sp);
153
+ wdb.ubRegister("tokens", applyCreateDefaults(sp));
147
154
  });
148
155
  return wdb.transaction(async (trx) => {
149
156
  return trx.ubUpsert("tokens");
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { TokenSaveParams } from "./token.types";
4
+
5
+ const baseToken = {
6
+ provider: "anthropic",
7
+ credentials: {
8
+ accessToken: "sk-ant-oat01-test",
9
+ refreshToken: "sk-ant-ort01-test",
10
+ expiresAt: Date.now() + 3_600_000,
11
+ accountUuid: "acc-1",
12
+ },
13
+ name: "tok-A",
14
+ };
15
+
16
+ describe("TokenSaveParams", () => {
17
+ it("keeps quota_threshold optional for existing token saves", () => {
18
+ const parsed = TokenSaveParams.parse(baseToken);
19
+
20
+ expect(parsed).not.toHaveProperty("quota_threshold");
21
+ });
22
+
23
+ it("accepts valid nullable quota_threshold values", () => {
24
+ expect(TokenSaveParams.parse({ ...baseToken, quota_threshold: 80 }).quota_threshold).toBe(80);
25
+ expect(
26
+ TokenSaveParams.parse({ ...baseToken, quota_threshold: null }).quota_threshold,
27
+ ).toBeNull();
28
+ });
29
+
30
+ it("rejects quota_threshold values outside 1..100", () => {
31
+ expect(() => TokenSaveParams.parse({ ...baseToken, quota_threshold: 0 })).toThrow();
32
+ expect(() => TokenSaveParams.parse({ ...baseToken, quota_threshold: 101 })).toThrow();
33
+ });
34
+ });
@@ -30,11 +30,16 @@ export type TokenCredentials = z.infer<typeof TokenCredentials>;
30
30
  export const TokenListParams = TokenBaseListParams;
31
31
  export type TokenListParams = z.infer<typeof TokenListParams>;
32
32
 
33
+ const TokenQuotaThreshold = z.int().min(1).max(100).nullable();
34
+
33
35
  // Token - SaveParams
34
36
  export const TokenSaveParams = TokenBaseSchema.partial({
35
37
  id: true,
36
38
  created_at: true,
37
39
  active: true,
38
40
  ord: true,
41
+ quota_threshold: true,
42
+ }).extend({
43
+ quota_threshold: TokenQuotaThreshold.optional(),
39
44
  });
40
45
  export type TokenSaveParams = z.infer<typeof TokenSaveParams>;
@@ -223,6 +223,7 @@ const entityLabels = {
223
223
  "entity.RequestLog.cache_read_tokens": "캐시 읽기 토큰",
224
224
  "entity.RequestLog.cache_creation_tokens": "캐시 생성 토큰",
225
225
  "entity.RequestLog.duration_ms": "응답 소요시간(ms)",
226
+ "entity.RequestLog.ttft_ms": "first gen TTFT(ms)",
226
227
  "entity.RequestLog.cost_usd": "API 비용",
227
228
  "entity.RequestLog.effort": "effort level",
228
229
  "entity.RequestLog.history": "inject_items 히스토리",
@@ -248,6 +249,7 @@ const entityLabels = {
248
249
  "entity.RequestLogStep.cache_read_tokens": "캐시 읽기 토큰",
249
250
  "entity.RequestLogStep.cache_creation_tokens": "캐시 생성 토큰",
250
251
  "entity.RequestLogStep.duration_ms": "소요시간(ms)",
252
+ "entity.RequestLogStep.ttft_ms": "Gen TTFT(ms)",
251
253
  "entity.RequestLogStep.finish_reason": "완료 사유",
252
254
  "entity.RequestLogStep.reasoning_text": "reasoning 텍스트",
253
255
  "entity.RequestLogStep.reasoning_tokens": "reasoning 토큰 수",
@@ -270,6 +272,7 @@ const entityLabels = {
270
272
  "entity.Token.name": "토큰 이름",
271
273
  "entity.Token.active": "활성 여부",
272
274
  "entity.Token.ord": "정렬 순서",
275
+ "entity.Token.quota_threshold": "quota 사용률 임계치(%). null이면 미설정",
273
276
  "enum.TokenOrderBy.id-desc": "ID최신순",
274
277
  "enum.TokenOrderBy.ord-asc": "순서순",
275
278
  "enum.TokenSearchField.id": "ID",
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("tokens", (table) => {
5
+ // add
6
+ table.integer("quota_threshold").nullable();
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("tokens", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("quota_threshold");
14
+ });
15
+ }
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("request_logs", (table) => {
5
+ // add
6
+ table.integer("ttft_ms").notNullable().defaultTo(knex.raw("0"));
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("request_logs", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("ttft_ms");
14
+ });
15
+ }
@@ -0,0 +1,15 @@
1
+ import { type Knex } from "knex";
2
+
3
+ export async function up(knex: Knex): Promise<void> {
4
+ await knex.schema.alterTable("request_log_steps", (table) => {
5
+ // add
6
+ table.integer("ttft_ms").nullable();
7
+ });
8
+ }
9
+
10
+ export async function down(knex: Knex): Promise<void> {
11
+ await knex.schema.alterTable("request_log_steps", (table) => {
12
+ // rollback - add
13
+ table.dropColumns("ttft_ms");
14
+ });
15
+ }