@cartanova/qgrid-cli 2.4.6 → 2.4.8

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 (73) hide show
  1. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +15 -1
  2. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -4
  3. package/bundle/dist/application/qgrid/qgrid.frame.js +20 -1
  4. package/bundle/dist/application/qgrid/qgrid.types.js +38 -2
  5. package/bundle/dist/application/request-log/request-log.model.js +49 -22
  6. package/bundle/dist/application/request-log-step/request-log-step.types.js +8 -1
  7. package/bundle/dist/application/sonamu.generated.js +37 -1
  8. package/bundle/dist/application/sonamu.generated.sso.js +25 -1
  9. package/bundle/dist/i18n/sd.generated.js +13 -1
  10. package/bundle/dist/migrations/20260718180824_alter_request_logs_add5_alter1.js +20 -0
  11. package/bundle/dist/migrations/20260718180825_alter_request_log_steps_add7_alter1.js +22 -0
  12. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +8 -2
  13. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +6 -3
  14. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -7
  15. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +117 -7
  16. package/bundle/dist/utils/providers/common/model-cost.js +45 -98
  17. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +68 -0
  18. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +14 -0
  19. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +81 -0
  20. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -9
  21. package/bundle/src/application/qgrid/qgrid.frame.ts +19 -0
  22. package/bundle/src/application/qgrid/qgrid.types.ts +36 -0
  23. package/bundle/src/application/qgrid/tool-emulation.test.ts +1 -0
  24. package/bundle/src/application/request-log/request-log.entity.json +42 -0
  25. package/bundle/src/application/request-log/request-log.model.test.ts +58 -0
  26. package/bundle/src/application/request-log/request-log.model.ts +107 -18
  27. package/bundle/src/application/request-log-step/request-log-step.entity.json +54 -0
  28. package/bundle/src/application/request-log-step/request-log-step.types.ts +7 -0
  29. package/bundle/src/application/sonamu.generated.http +26 -0
  30. package/bundle/src/application/sonamu.generated.sso.ts +24 -0
  31. package/bundle/src/application/sonamu.generated.ts +48 -0
  32. package/bundle/src/i18n/sd.generated.ts +12 -0
  33. package/bundle/src/migrations/20260718180824_alter_request_logs_add5_alter1.ts +25 -0
  34. package/bundle/src/migrations/20260718180825_alter_request_log_steps_add7_alter1.ts +29 -0
  35. package/bundle/src/utils/providers/anthropic/anthropic-constants.test.ts +15 -0
  36. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +11 -2
  37. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +7 -2
  38. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +26 -0
  39. package/bundle/src/utils/providers/anthropic/claude-session.ts +16 -4
  40. package/bundle/src/utils/providers/anthropic/stream-json-adapter.test.ts +131 -0
  41. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +194 -11
  42. package/bundle/src/utils/providers/common/model-cost.test.ts +136 -2
  43. package/bundle/src/utils/providers/common/model-cost.ts +76 -99
  44. package/bundle/src/utils/providers/common/provider-dispatcher.ts +18 -0
  45. package/bundle/web-dist/client/assets/{index-BOXhNo0k.js → index-CmuPjbCl.js} +3 -3
  46. package/bundle/web-dist/client/assets/{logs-BShH_sut.js → logs-cXOsDli3.js} +1 -1
  47. package/bundle/web-dist/client/assets/{monit-CoEAp5S0.js → monit-B5Edhvy-.js} +1 -1
  48. package/bundle/web-dist/client/assets/{routes-Dr3cGnV3.js → routes-UPWScORY.js} +1 -1
  49. package/bundle/web-dist/client/assets/{sd.generated-D2Y9QeiN.js → sd.generated-DYuKzBY6.js} +1 -1
  50. package/bundle/web-dist/client/assets/{services.generated-CmxXl220.js → services.generated-D_SkWOo-.js} +1 -1
  51. package/bundle/web-dist/client/assets/show-pRp-eZIK.js +30 -0
  52. package/bundle/web-dist/client/assets/{tokens-hP1NecIg.js → tokens-DJlttxCM.js} +1 -1
  53. package/bundle/web-dist/client/assets/{use-update-token-mutation-6J1T9U2-.js → use-update-token-mutation-CpFGLWUl.js} +1 -1
  54. package/bundle/web-dist/client/index.html +2 -2
  55. package/bundle/web-dist/server/assets/{lazyRouteComponent-BlfOh9S3.js → lazyRouteComponent-Cu6IkL8A.js} +1 -1
  56. package/bundle/web-dist/server/assets/{logs-DYly4ipQ.js → logs-Dx2knN2T.js} +10 -7
  57. package/bundle/web-dist/server/assets/{monit-CYyY2S2f.js → monit-DU6PLatf.js} +2 -2
  58. package/bundle/web-dist/server/assets/{routes-Dqu3osXt.js → routes-CTco1oJZ.js} +3 -3
  59. package/bundle/web-dist/server/assets/{sd.generated-CE6D5clm.js → sd.generated-qzuRe35X.js} +12 -0
  60. package/bundle/web-dist/server/assets/{services.generated-DyZUzXiq.js → services.generated-C8q1M41a.js} +1 -1
  61. package/bundle/web-dist/server/assets/{show-BDul8xLb.js → show-B3q8_GDh.js} +47 -4
  62. package/bundle/web-dist/server/assets/{tokens-fzMW4G74.js → tokens-Bvjn15h9.js} +3 -3
  63. package/bundle/web-dist/server/assets/{use-update-token-mutation-BejUW5sw.js → use-update-token-mutation-BlZo6swF.js} +1 -1
  64. package/bundle/web-dist/server/entry-server.generated.js +8 -8
  65. package/package.json +2 -2
  66. package/skills/qgrid/SKILL.md +4 -0
  67. package/skills/qgrid/references/ai-sdk-provider-contract.md +30 -2
  68. package/skills/qgrid/references/anthropic-claude-code-runtime.md +43 -5
  69. package/skills/qgrid/references/decision-rationale.md +2 -0
  70. package/skills/qgrid/references/prompt-cache-and-usage.md +4 -1
  71. package/skills/qgrid/references/request-log-run-lifecycle.md +6 -2
  72. package/skills/qgrid/references/sonamu-api-web-flow.md +13 -0
  73. package/bundle/web-dist/client/assets/show-CXII6Iy7.js +0 -30
@@ -369,6 +369,87 @@ describe("QgridDispatcherClass", () => {
369
369
  expect(result.usage.cache_creation_input_tokens).toBe(600);
370
370
  });
371
371
 
372
+ it("provider cost 가 0이면 Anthropic 5m/1h cache breakdown 으로 fallback cost 를 계산한다", async () => {
373
+ const dispatcher = new QgridDispatcherClass();
374
+ const generate = vi.fn(
375
+ async (_req: GenerateRequest): Promise<GenerateResult> => ({
376
+ text: "ok",
377
+ tokenName: "anthropic/test",
378
+ usage: {
379
+ totalTokens: 100_000,
380
+ inputTokens: 100_000,
381
+ cachedInputTokens: 0,
382
+ cacheCreationInputTokens: 80_000,
383
+ cacheCreationInputTokens5m: 30_000,
384
+ cacheCreationInputTokens1h: 50_000,
385
+ outputTokens: 0,
386
+ reasoningOutputTokens: 0,
387
+ },
388
+ durationMs: 12,
389
+ costUsd: 0,
390
+ model: "claude-sonnet-4-6",
391
+ threadCoord: { workerId: 1, threadId: "sess-1", epoch: 0 },
392
+ }),
393
+ );
394
+ dispatcher.anthropicDispatcher = { generate } as never;
395
+
396
+ const result = await dispatcher.query({ prompt: "hi", model: "anthropic/claude-sonnet-4-6" });
397
+
398
+ expect(result.costUsd).toBeCloseTo(0.4725, 10);
399
+ expect(result.usage.cache_creation_input_tokens).toBe(80_000);
400
+ expect(result.usage.cache_creation_5m_input_tokens).toBe(30_000);
401
+ expect(result.usage.cache_creation_1h_input_tokens).toBe(50_000);
402
+ expect(result.costSource).toBe("pricing_table");
403
+ });
404
+
405
+ it("Fable refusal fallback은 실제 serving model과 requested model을 구분한다", async () => {
406
+ const dispatcher = new QgridDispatcherClass();
407
+ const generate = vi.fn(
408
+ async (_req: GenerateRequest): Promise<GenerateResult> => ({
409
+ text: "served by opus",
410
+ tokenName: "anthropic/test",
411
+ usage: {
412
+ totalTokens: 120,
413
+ inputTokens: 100,
414
+ cachedInputTokens: 0,
415
+ outputTokens: 20,
416
+ reasoningOutputTokens: 0,
417
+ },
418
+ durationMs: 12,
419
+ costUsd: 0.003,
420
+ requestedModel: "claude-fable-5",
421
+ model: "claude-opus-4-8",
422
+ modelFallbacks: [
423
+ {
424
+ trigger: "refusal",
425
+ fromModel: "claude-fable-5",
426
+ toModel: "claude-opus-4-8",
427
+ category: "cyber",
428
+ },
429
+ ],
430
+ threadCoord: { workerId: 1, threadId: "sess-1", epoch: 0 },
431
+ }),
432
+ );
433
+ dispatcher.anthropicDispatcher = { generate } as never;
434
+
435
+ const result = await dispatcher.query({ prompt: "hi", model: "anthropic/claude-fable-5" });
436
+
437
+ expect(result).toMatchObject({
438
+ model: "claude-opus-4-8",
439
+ requestedModel: "claude-fable-5",
440
+ costUsd: 0.003,
441
+ costSource: "provider",
442
+ modelFallbacks: [
443
+ {
444
+ trigger: "refusal",
445
+ fromModel: "claude-fable-5",
446
+ toModel: "claude-opus-4-8",
447
+ category: "cyber",
448
+ },
449
+ ],
450
+ });
451
+ });
452
+
372
453
  it("provider ttftMs 를 QueryOutput.ttftMs 로 매핑하고 누락 값은 0 으로 둔다", async () => {
373
454
  const dispatcher = new QgridDispatcherClass();
374
455
  const generate = vi
@@ -256,27 +256,34 @@ function buildRawOutputSchema(
256
256
  function toEmulationResult(
257
257
  result: GenerateResult,
258
258
  ): Omit<QueryOutput, "content" | "finishReason" | "runContext"> {
259
+ const hasProviderCost = result.costUsd !== undefined && result.costUsd > 0;
259
260
  return {
260
261
  text: result.text,
261
262
  tokenName: result.tokenName,
262
263
  model: result.model,
264
+ requestedModel: result.requestedModel,
265
+ modelFallbacks: result.modelFallbacks,
263
266
  usage: {
264
267
  input_tokens: result.usage.inputTokens,
265
268
  output_tokens: result.usage.outputTokens,
266
269
  cache_creation_input_tokens: result.usage.cacheCreationInputTokens ?? 0,
270
+ cache_creation_5m_input_tokens: result.usage.cacheCreationInputTokens5m,
271
+ cache_creation_1h_input_tokens: result.usage.cacheCreationInputTokens1h,
267
272
  cache_read_input_tokens: result.usage.cachedInputTokens,
268
273
  },
269
274
  durationMs: result.durationMs,
270
275
  ttftMs: result.ttftMs ?? 0,
271
- costUsd:
272
- result.costUsd !== undefined && result.costUsd > 0
273
- ? result.costUsd
274
- : calculateCostUsd(result.model, {
275
- inputTokens: result.usage.inputTokens,
276
- outputTokens: result.usage.outputTokens,
277
- cachedInputTokens: result.usage.cachedInputTokens,
278
- cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
279
- }),
276
+ costUsd: hasProviderCost
277
+ ? result.costUsd!
278
+ : calculateCostUsd(result.model, {
279
+ inputTokens: result.usage.inputTokens,
280
+ outputTokens: result.usage.outputTokens,
281
+ cachedInputTokens: result.usage.cachedInputTokens,
282
+ cacheCreationInputTokens: result.usage.cacheCreationInputTokens ?? 0,
283
+ cacheCreationInputTokens5m: result.usage.cacheCreationInputTokens5m,
284
+ cacheCreationInputTokens1h: result.usage.cacheCreationInputTokens1h,
285
+ }),
286
+ costSource: hasProviderCost ? "provider" : "pricing_table",
280
287
  };
281
288
  }
282
289
 
@@ -157,6 +157,8 @@ class QgridFrameClass extends BaseFrameClass {
157
157
  token_name: result.tokenName,
158
158
  project_name: args.projectName?.length ? args.projectName : null,
159
159
  model_name: result.model ?? null,
160
+ requested_model_name: result.requestedModel ?? result.model ?? args.model ?? null,
161
+ fallback_count: result.modelFallbacks?.length ?? 0,
160
162
  user_prompt: args.prompt,
161
163
  system_prompt: args.system ?? null,
162
164
  response: responseText,
@@ -164,9 +166,12 @@ class QgridFrameClass extends BaseFrameClass {
164
166
  output_tokens: result.usage.output_tokens,
165
167
  cache_read_tokens: result.usage.cache_read_input_tokens,
166
168
  cache_creation_tokens: result.usage.cache_creation_input_tokens,
169
+ cache_creation_5m_tokens: result.usage.cache_creation_5m_input_tokens ?? null,
170
+ cache_creation_1h_tokens: result.usage.cache_creation_1h_input_tokens ?? null,
167
171
  duration_ms: result.durationMs,
168
172
  ttft_ms: result.ttftMs,
169
173
  cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
174
+ cost_source: result.costSource,
170
175
  image_cost_usd: imageCostMicroUsd,
171
176
  image_cost_method:
172
177
  imageCostMicroUsd !== null
@@ -332,10 +337,17 @@ class QgridFrameClass extends BaseFrameClass {
332
337
  const stepId = await RequestLogModel.appendStep(input.requestLogId, {
333
338
  step_index: input.stepIndex,
334
339
  type: input.type,
340
+ model_name: input.modelName,
341
+ requested_model_name: input.requestedModelName,
342
+ fallback_count: input.fallbackCount,
335
343
  input_tokens: input.inputTokens,
336
344
  output_tokens: input.outputTokens,
337
345
  cache_read_tokens: input.cacheReadTokens,
338
346
  cache_creation_tokens: input.cacheCreationTokens,
347
+ cache_creation_5m_tokens: input.cacheCreation5mTokens,
348
+ cache_creation_1h_tokens: input.cacheCreation1hTokens,
349
+ cost_usd: input.costUsd === undefined ? undefined : Math.round(input.costUsd * MICRO_USD),
350
+ cost_source: input.costSource,
339
351
  duration_ms: input.durationMs,
340
352
  ttft_ms: input.ttftMs ?? null,
341
353
  finish_reason: input.finishReason,
@@ -358,10 +370,17 @@ class QgridFrameClass extends BaseFrameClass {
358
370
  status: input.status,
359
371
  response: input.response,
360
372
  token_name: input.tokenName,
373
+ model_name: input.modelName,
374
+ requested_model_name: input.requestedModelName,
375
+ fallback_count: input.fallbackCount,
361
376
  input_tokens: input.totalInputTokens,
362
377
  output_tokens: input.totalOutputTokens,
363
378
  cache_read_tokens: input.totalCacheReadTokens,
364
379
  cache_creation_tokens: input.totalCacheCreationTokens,
380
+ cache_creation_5m_tokens: input.totalCacheCreation5mTokens,
381
+ cache_creation_1h_tokens: input.totalCacheCreation1hTokens,
382
+ cost_usd: input.costUsd === undefined ? undefined : Math.round(input.costUsd * MICRO_USD),
383
+ cost_source: input.costSource,
365
384
  duration_ms: input.totalDurationMs,
366
385
  history: input.history ? JSON.parse(input.history) : undefined,
367
386
  error_message: input.errorMessage,
@@ -125,21 +125,38 @@ export const QueryInput = z.object({
125
125
  });
126
126
  export type QueryInput = z.infer<typeof QueryInput>;
127
127
 
128
+ export const ModelFallback = z.object({
129
+ trigger: z.literal("refusal"),
130
+ fromModel: z.string(),
131
+ toModel: z.string(),
132
+ category: z.string().optional(),
133
+ explanation: z.string().optional(),
134
+ });
135
+ export type ModelFallback = z.infer<typeof ModelFallback>;
136
+
137
+ export const CostSource = z.enum(["provider", "pricing_table", "mixed"]);
138
+ export type CostSource = z.infer<typeof CostSource>;
139
+
128
140
  export const QueryOutput = z.object({
129
141
  text: z.string(),
130
142
  content: z.array(QgridContent),
131
143
  finishReason: FinishReason,
132
144
  tokenName: z.string().optional(),
133
145
  model: z.string().optional(),
146
+ requestedModel: z.string().optional(),
147
+ modelFallbacks: z.array(ModelFallback).optional(),
134
148
  usage: z.object({
135
149
  input_tokens: z.number(),
136
150
  output_tokens: z.number(),
137
151
  cache_creation_input_tokens: z.number(),
152
+ cache_creation_5m_input_tokens: z.number().optional(),
153
+ cache_creation_1h_input_tokens: z.number().optional(),
138
154
  cache_read_input_tokens: z.number(),
139
155
  }),
140
156
  durationMs: z.number(),
141
157
  ttftMs: z.number(),
142
158
  costUsd: z.number(),
159
+ costSource: CostSource,
143
160
  runContext: QgridRunContext.optional(),
144
161
  });
145
162
  export type QueryOutput = z.infer<typeof QueryOutput>;
@@ -156,17 +173,22 @@ export const StreamEvents = z.object({
156
173
  done: z.object({
157
174
  text: z.string(),
158
175
  model: z.string().optional(),
176
+ requestedModel: z.string().optional(),
177
+ modelFallbacks: z.array(ModelFallback).optional(),
159
178
  tokenName: z.string().optional(),
160
179
  finishReason: FinishReason,
161
180
  usage: z.object({
162
181
  input_tokens: z.number(),
163
182
  output_tokens: z.number(),
164
183
  cache_creation_input_tokens: z.number(),
184
+ cache_creation_5m_input_tokens: z.number().optional(),
185
+ cache_creation_1h_input_tokens: z.number().optional(),
165
186
  cache_read_input_tokens: z.number(),
166
187
  }),
167
188
  durationMs: z.number(),
168
189
  ttftMs: z.number(),
169
190
  costUsd: z.number(),
191
+ costSource: CostSource,
170
192
  content: z.array(QgridContent),
171
193
  runContext: QgridRunContext.optional(),
172
194
  }),
@@ -190,10 +212,17 @@ export const AppendStepInput = z.object({
190
212
  requestLogId: z.number(),
191
213
  stepIndex: z.number(),
192
214
  type: z.enum(["generate", "tool_call"]),
215
+ modelName: z.string().optional(),
216
+ requestedModelName: z.string().optional(),
217
+ fallbackCount: z.number().optional(),
193
218
  inputTokens: z.number().optional(),
194
219
  outputTokens: z.number().optional(),
195
220
  cacheReadTokens: z.number().optional(),
196
221
  cacheCreationTokens: z.number().optional(),
222
+ cacheCreation5mTokens: z.number().optional(),
223
+ cacheCreation1hTokens: z.number().optional(),
224
+ costUsd: z.number().optional(),
225
+ costSource: CostSource.optional(),
197
226
  durationMs: z.number().optional(),
198
227
  ttftMs: z.number().nullable().optional(),
199
228
  finishReason: z.string().optional(),
@@ -214,10 +243,17 @@ export const FinishRunInput = z.object({
214
243
  status: z.enum(["succeeded", "error", "aborted"]),
215
244
  response: z.string().optional(),
216
245
  tokenName: z.string().optional(),
246
+ modelName: z.string().optional(),
247
+ requestedModelName: z.string().optional(),
248
+ fallbackCount: z.number().optional(),
217
249
  totalInputTokens: z.number().optional(),
218
250
  totalOutputTokens: z.number().optional(),
219
251
  totalCacheReadTokens: z.number().optional(),
220
252
  totalCacheCreationTokens: z.number().optional(),
253
+ totalCacheCreation5mTokens: z.number().optional(),
254
+ totalCacheCreation1hTokens: z.number().optional(),
255
+ costUsd: z.number().optional(),
256
+ costSource: CostSource.optional(),
221
257
  totalDurationMs: z.number().optional(),
222
258
  history: z.string().optional(),
223
259
  errorMessage: z.string().optional(),
@@ -63,6 +63,7 @@ describe("applyToolCallEmulation image parts", () => {
63
63
  durationMs: 10,
64
64
  ttftMs: 0,
65
65
  costUsd: 0,
66
+ costSource: "pricing_table" as const,
66
67
  };
67
68
  const img = { data: "iVBORw0KGgoBAgM", revisedPrompt: "a red circle" };
68
69
 
@@ -29,6 +29,19 @@
29
29
  "length": 50,
30
30
  "nullable": true
31
31
  },
32
+ {
33
+ "name": "requested_model_name",
34
+ "desc": "요청 모델 이름 (fallback 전)",
35
+ "type": "string",
36
+ "length": 50,
37
+ "nullable": true
38
+ },
39
+ {
40
+ "name": "fallback_count",
41
+ "type": "integer",
42
+ "nullable": true,
43
+ "desc": "모델 fallback 횟수"
44
+ },
32
45
  { "name": "user_prompt", "type": "string", "nullable": true, "desc": "사용자 프롬프트" },
33
46
  { "name": "system_prompt", "type": "string", "nullable": true, "desc": "시스템 프롬프트" },
34
47
  { "name": "response", "desc": "llm 응답 전문", "type": "string", "dbDefault": "\"\"" },
@@ -41,9 +54,28 @@
41
54
  "desc": "캐시 생성 토큰",
42
55
  "dbDefault": "0"
43
56
  },
57
+ {
58
+ "name": "cache_creation_5m_tokens",
59
+ "type": "integer",
60
+ "nullable": true,
61
+ "desc": "5분 TTL 캐시 생성 토큰"
62
+ },
63
+ {
64
+ "name": "cache_creation_1h_tokens",
65
+ "type": "integer",
66
+ "nullable": true,
67
+ "desc": "1시간 TTL 캐시 생성 토큰"
68
+ },
44
69
  { "name": "duration_ms", "type": "integer", "desc": "응답 소요시간(ms)", "dbDefault": "0" },
45
70
  { "name": "ttft_ms", "type": "integer", "desc": "first gen TTFT(ms)", "dbDefault": "0" },
46
71
  { "name": "cost_usd", "type": "integer", "nullable": true, "desc": "API 비용" },
72
+ {
73
+ "name": "cost_source",
74
+ "type": "string",
75
+ "length": 20,
76
+ "nullable": true,
77
+ "desc": "비용 출처(provider/pricing_table/mixed)"
78
+ },
47
79
  {
48
80
  "name": "image_cost_usd",
49
81
  "type": "integer",
@@ -102,6 +134,8 @@
102
134
  "token_name",
103
135
  "project_name",
104
136
  "model_name",
137
+ "requested_model_name",
138
+ "fallback_count",
105
139
  "user_prompt",
106
140
  "system_prompt",
107
141
  "response",
@@ -109,9 +143,12 @@
109
143
  "output_tokens",
110
144
  "cache_read_tokens",
111
145
  "cache_creation_tokens",
146
+ "cache_creation_5m_tokens",
147
+ "cache_creation_1h_tokens",
112
148
  "duration_ms",
113
149
  "ttft_ms",
114
150
  "cost_usd",
151
+ "cost_source",
115
152
  "image_cost_usd",
116
153
  "image_cost_method",
117
154
  "effort",
@@ -127,13 +164,18 @@
127
164
  "token_name",
128
165
  "project_name",
129
166
  "model_name",
167
+ "requested_model_name",
168
+ "fallback_count",
130
169
  "input_tokens",
131
170
  "output_tokens",
132
171
  "cache_read_tokens",
133
172
  "cache_creation_tokens",
173
+ "cache_creation_5m_tokens",
174
+ "cache_creation_1h_tokens",
134
175
  "duration_ms",
135
176
  "ttft_ms",
136
177
  "cost_usd",
178
+ "cost_source",
137
179
  "image_cost_usd",
138
180
  "image_cost_method",
139
181
  "effort",
@@ -90,3 +90,61 @@ describe("RequestLogModel TTFT", () => {
90
90
  );
91
91
  });
92
92
  });
93
+
94
+ describe("RequestLogModel cost provenance", () => {
95
+ afterEach(() => {
96
+ vi.restoreAllMocks();
97
+ });
98
+
99
+ it("uses exact stored cost for rows with a cost source", async () => {
100
+ const chain = {
101
+ where: vi.fn(),
102
+ select: vi.fn(async () => [
103
+ {
104
+ model_name: "claude-opus-4-8",
105
+ input_tokens: 1_000_000,
106
+ output_tokens: 1_000_000,
107
+ cache_read_tokens: 0,
108
+ cache_creation_tokens: 0,
109
+ cache_creation_5m_tokens: null,
110
+ cache_creation_1h_tokens: null,
111
+ cost_usd: 3_000,
112
+ cost_source: "provider",
113
+ },
114
+ ]),
115
+ };
116
+ chain.where.mockReturnValue(chain);
117
+ const db = vi.fn(() => chain);
118
+ vi.spyOn(RequestLogModel as unknown as { getDB: () => typeof db }, "getDB").mockReturnValue(
119
+ db,
120
+ );
121
+
122
+ await expect(RequestLogModel.totalCost()).resolves.toBe(0.003);
123
+ });
124
+
125
+ it("keeps TTL-aware price-table recomputation for legacy rows", async () => {
126
+ const chain = {
127
+ where: vi.fn(),
128
+ select: vi.fn(async () => [
129
+ {
130
+ model_name: "claude-sonnet-4-6",
131
+ input_tokens: 100_000,
132
+ output_tokens: 0,
133
+ cache_read_tokens: 0,
134
+ cache_creation_tokens: 80_000,
135
+ cache_creation_5m_tokens: 30_000,
136
+ cache_creation_1h_tokens: 50_000,
137
+ cost_usd: null,
138
+ cost_source: null,
139
+ },
140
+ ]),
141
+ };
142
+ chain.where.mockReturnValue(chain);
143
+ const db = vi.fn(() => chain);
144
+ vi.spyOn(RequestLogModel as unknown as { getDB: () => typeof db }, "getDB").mockReturnValue(
145
+ db,
146
+ );
147
+
148
+ await expect(RequestLogModel.totalCost()).resolves.toBeCloseTo(0.4725, 10);
149
+ });
150
+ });
@@ -24,7 +24,10 @@ type RequestLogUsageRow = {
24
24
  output_tokens: number;
25
25
  cache_read_tokens: number;
26
26
  cache_creation_tokens: number;
27
+ cache_creation_5m_tokens?: number | null;
28
+ cache_creation_1h_tokens?: number | null;
27
29
  cost_usd?: number | null;
30
+ cost_source?: string | null;
28
31
  };
29
32
 
30
33
  function isAnthropicUsageRow(row: Pick<RequestLogUsageRow, "token_name" | "model_name">): boolean {
@@ -45,6 +48,8 @@ function normalizedUsageForCost(row: RequestLogUsageRow): {
45
48
  outputTokens: number;
46
49
  cachedInputTokens: number;
47
50
  cacheCreationInputTokens: number;
51
+ cacheCreationInputTokens5m?: number;
52
+ cacheCreationInputTokens1h?: number;
48
53
  } {
49
54
  const cacheRead = row.cache_read_tokens;
50
55
  const cacheCreation = row.cache_creation_tokens;
@@ -57,10 +62,18 @@ function normalizedUsageForCost(row: RequestLogUsageRow): {
57
62
  outputTokens: row.output_tokens,
58
63
  cachedInputTokens: cacheRead,
59
64
  cacheCreationInputTokens: cacheCreation,
65
+ ...(row.cache_creation_5m_tokens !== null && row.cache_creation_5m_tokens !== undefined
66
+ ? { cacheCreationInputTokens5m: row.cache_creation_5m_tokens }
67
+ : {}),
68
+ ...(row.cache_creation_1h_tokens !== null && row.cache_creation_1h_tokens !== undefined
69
+ ? { cacheCreationInputTokens1h: row.cache_creation_1h_tokens }
70
+ : {}),
60
71
  };
61
72
  }
62
73
 
63
74
  function normalizeLegacyAnthropicRow<T extends RequestLogUsageRow>(row: T): T {
75
+ // 새 row 는 당시의 provider/가격표 비용을 확정 저장한다. 현재 가격표로 덮어쓰지 않는다.
76
+ if (row.cost_source) return row;
64
77
  const usage = normalizedUsageForCost(row);
65
78
  if (usage.inputTokens === row.input_tokens) return row;
66
79
  const normalized = { ...row, input_tokens: usage.inputTokens };
@@ -258,7 +271,14 @@ class RequestLogModelClass extends BaseModelClass<
258
271
  output_tokens?: number;
259
272
  cache_read_tokens?: number;
260
273
  cache_creation_tokens?: number;
274
+ cache_creation_5m_tokens?: number | null;
275
+ cache_creation_1h_tokens?: number | null;
261
276
  duration_ms?: number;
277
+ requested_model_name?: string | null;
278
+ model_name?: string | null;
279
+ fallback_count?: number | null;
280
+ cost_usd?: number | null;
281
+ cost_source?: string | null;
262
282
  history?: unknown;
263
283
  error_message?: string;
264
284
  tool_call_count?: number;
@@ -277,7 +297,14 @@ class RequestLogModelClass extends BaseModelClass<
277
297
  "output_tokens",
278
298
  "cache_read_tokens",
279
299
  "cache_creation_tokens",
300
+ "cache_creation_5m_tokens",
301
+ "cache_creation_1h_tokens",
280
302
  "duration_ms",
303
+ "requested_model_name",
304
+ "model_name",
305
+ "fallback_count",
306
+ "cost_usd",
307
+ "cost_source",
281
308
  "error_message",
282
309
  "tool_call_count",
283
310
  "image_cost_usd",
@@ -289,21 +316,30 @@ class RequestLogModelClass extends BaseModelClass<
289
316
  if (params.history !== undefined) update.history = params.history;
290
317
  update.ttft_ms = (await this.firstGenerateStepTtft(requestLogId)) ?? 0;
291
318
 
292
- if (params.input_tokens !== undefined && params.output_tokens !== undefined) {
319
+ // 서버 run 경로는 step 에서 확정한 exact cost 를 넘긴다. 외부/legacy caller 가 비용을
320
+ // 생략한 경우에만 저장된 모델+usage 로 계산한다.
321
+ if (
322
+ params.cost_usd === undefined &&
323
+ params.input_tokens !== undefined &&
324
+ params.output_tokens !== undefined
325
+ ) {
293
326
  const db = this.getDB("r");
294
327
  const [row] = await db("request_logs")
295
328
  .select("model_name")
296
329
  .where("id", requestLogId)
297
330
  .limit(1);
298
- if (row?.model_name) {
299
- const model = canonicalModelName(row.model_name);
331
+ const effectiveModelName = params.model_name ?? row?.model_name;
332
+ if (effectiveModelName) {
333
+ const model = canonicalModelName(effectiveModelName);
300
334
  const usage = normalizedUsageForCost({
301
335
  token_name: params.token_name,
302
- model_name: row.model_name,
336
+ model_name: effectiveModelName,
303
337
  input_tokens: params.input_tokens,
304
338
  output_tokens: params.output_tokens,
305
339
  cache_read_tokens: params.cache_read_tokens ?? 0,
306
340
  cache_creation_tokens: params.cache_creation_tokens ?? 0,
341
+ cache_creation_5m_tokens: params.cache_creation_5m_tokens,
342
+ cache_creation_1h_tokens: params.cache_creation_1h_tokens,
307
343
  });
308
344
  update.cost_usd = Math.round(
309
345
  calculateCostUsd(model, {
@@ -311,8 +347,11 @@ class RequestLogModelClass extends BaseModelClass<
311
347
  outputTokens: usage.outputTokens,
312
348
  cachedInputTokens: usage.cachedInputTokens,
313
349
  cacheCreationInputTokens: usage.cacheCreationInputTokens,
350
+ cacheCreationInputTokens5m: usage.cacheCreationInputTokens5m,
351
+ cacheCreationInputTokens1h: usage.cacheCreationInputTokens1h,
314
352
  }) * 1_000_000,
315
353
  );
354
+ update.cost_source = "pricing_table";
316
355
  }
317
356
  }
318
357
 
@@ -341,25 +380,62 @@ class RequestLogModelClass extends BaseModelClass<
341
380
  output_tokens: number;
342
381
  cache_read_tokens: number;
343
382
  cache_creation_tokens: number;
383
+ cache_creation_5m_tokens?: number;
384
+ cache_creation_1h_tokens?: number;
344
385
  duration_ms: number;
386
+ requested_model_name?: string;
387
+ model_name?: string;
388
+ fallback_count: number;
389
+ cost_usd: number;
390
+ cost_source?: string;
345
391
  }> {
346
392
  const db = this.getDB("r");
347
- const [row] = await db("request_log_steps")
393
+ const rows = (await db("request_log_steps")
394
+ .select(
395
+ "input_tokens",
396
+ "output_tokens",
397
+ "cache_read_tokens",
398
+ "cache_creation_tokens",
399
+ "cache_creation_5m_tokens",
400
+ "cache_creation_1h_tokens",
401
+ "duration_ms",
402
+ "requested_model_name",
403
+ "model_name",
404
+ "fallback_count",
405
+ "cost_usd",
406
+ "cost_source",
407
+ )
348
408
  .where("request_log_id", requestLogId)
349
- .where("type", "generate")
350
- .sum({
351
- input_tokens: "input_tokens",
352
- output_tokens: "output_tokens",
353
- cache_read_tokens: "cache_read_tokens",
354
- cache_creation_tokens: "cache_creation_tokens",
355
- duration_ms: "duration_ms",
356
- });
409
+ .where("type", "generate")) as Array<Record<string, unknown>>;
410
+
411
+ const sum = (field: string): number =>
412
+ rows.reduce((total, row) => total + Number(row[field] ?? 0), 0);
413
+ const optionalSum = (field: string): number | undefined =>
414
+ rows.some((row) => row[field] !== null && row[field] !== undefined) ? sum(field) : undefined;
415
+ const collapse = (field: string): string | undefined => {
416
+ const values = [
417
+ ...new Set(
418
+ rows
419
+ .map((row) => row[field])
420
+ .filter((value): value is string => typeof value === "string" && value.length > 0),
421
+ ),
422
+ ];
423
+ if (values.length === 0) return undefined;
424
+ return values.length === 1 ? values[0] : "mixed";
425
+ };
357
426
  return {
358
- input_tokens: Number(row?.input_tokens ?? 0),
359
- output_tokens: Number(row?.output_tokens ?? 0),
360
- cache_read_tokens: Number(row?.cache_read_tokens ?? 0),
361
- cache_creation_tokens: Number(row?.cache_creation_tokens ?? 0),
362
- duration_ms: Number(row?.duration_ms ?? 0),
427
+ input_tokens: sum("input_tokens"),
428
+ output_tokens: sum("output_tokens"),
429
+ cache_read_tokens: sum("cache_read_tokens"),
430
+ cache_creation_tokens: sum("cache_creation_tokens"),
431
+ cache_creation_5m_tokens: optionalSum("cache_creation_5m_tokens"),
432
+ cache_creation_1h_tokens: optionalSum("cache_creation_1h_tokens"),
433
+ duration_ms: sum("duration_ms"),
434
+ requested_model_name: collapse("requested_model_name"),
435
+ model_name: collapse("model_name"),
436
+ fallback_count: sum("fallback_count"),
437
+ cost_usd: sum("cost_usd"),
438
+ cost_source: collapse("cost_source"),
363
439
  };
364
440
  }
365
441
 
@@ -419,17 +495,28 @@ class RequestLogModelClass extends BaseModelClass<
419
495
  "output_tokens",
420
496
  "cache_read_tokens",
421
497
  "cache_creation_tokens",
498
+ "cache_creation_5m_tokens",
499
+ "cache_creation_1h_tokens",
422
500
  "cost_usd",
501
+ "cost_source",
423
502
  )) as Array<{
424
503
  model_name: string | null;
425
504
  input_tokens: number;
426
505
  output_tokens: number;
427
506
  cache_read_tokens: number;
428
507
  cache_creation_tokens: number;
508
+ cache_creation_5m_tokens: number | null;
509
+ cache_creation_1h_tokens: number | null;
429
510
  cost_usd: number | null;
511
+ cost_source: string | null;
430
512
  }>;
431
513
 
432
514
  return rows.reduce((sum, row) => {
515
+ // cost_source 가 있으면 새 계약으로 확정 저장된 값이다. 프로모션/가격 변경 이후에도
516
+ // 당시 비용을 유지한다. NULL인 legacy row 만 현재 가격표로 보정한다.
517
+ if (row.cost_source && row.cost_usd !== null) {
518
+ return sum + Math.max(row.cost_usd, 0) / MICRO_USD;
519
+ }
433
520
  const usage = normalizedUsageForCost(row);
434
521
  if (!usage.model) return sum + Math.max(row.cost_usd ?? 0, 0) / MICRO_USD;
435
522
  return (
@@ -439,6 +526,8 @@ class RequestLogModelClass extends BaseModelClass<
439
526
  outputTokens: usage.outputTokens,
440
527
  cachedInputTokens: usage.cachedInputTokens,
441
528
  cacheCreationInputTokens: usage.cacheCreationInputTokens,
529
+ cacheCreationInputTokens5m: usage.cacheCreationInputTokens5m,
530
+ cacheCreationInputTokens1h: usage.cacheCreationInputTokens1h,
442
531
  })
443
532
  );
444
533
  }, 0);