@lobehub/chat 1.133.2 → 1.133.3

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/CHANGELOG.md +33 -0
  2. package/changelog/v1.json +12 -0
  3. package/locales/ar/image.json +7 -0
  4. package/locales/ar/models.json +1 -1
  5. package/locales/bg-BG/image.json +7 -0
  6. package/locales/de-DE/image.json +7 -0
  7. package/locales/en-US/image.json +7 -0
  8. package/locales/es-ES/image.json +7 -0
  9. package/locales/es-ES/tool.json +1 -1
  10. package/locales/fa-IR/image.json +7 -0
  11. package/locales/fa-IR/models.json +1 -1
  12. package/locales/fr-FR/image.json +7 -0
  13. package/locales/fr-FR/models.json +1 -1
  14. package/locales/it-IT/image.json +7 -0
  15. package/locales/ja-JP/image.json +7 -0
  16. package/locales/ko-KR/image.json +7 -0
  17. package/locales/nl-NL/image.json +7 -0
  18. package/locales/pl-PL/image.json +7 -0
  19. package/locales/pt-BR/image.json +7 -0
  20. package/locales/ru-RU/image.json +7 -0
  21. package/locales/ru-RU/tool.json +1 -1
  22. package/locales/tr-TR/image.json +7 -0
  23. package/locales/tr-TR/models.json +1 -1
  24. package/locales/vi-VN/image.json +7 -0
  25. package/locales/zh-CN/image.json +7 -0
  26. package/locales/zh-TW/image.json +7 -0
  27. package/package.json +4 -5
  28. package/packages/const/package.json +4 -0
  29. package/packages/const/src/currency.ts +2 -0
  30. package/packages/const/src/index.ts +1 -0
  31. package/packages/model-bank/package.json +2 -1
  32. package/packages/model-bank/src/aiModels/google.ts +6 -0
  33. package/packages/model-bank/src/aiModels/openai.ts +6 -22
  34. package/packages/model-bank/src/standard-parameters/index.ts +56 -46
  35. package/packages/model-runtime/package.json +1 -0
  36. package/packages/model-runtime/src/core/RouterRuntime/createRuntime.ts +4 -2
  37. package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +12 -2
  38. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +16 -5
  39. package/packages/model-runtime/src/core/streams/anthropic.ts +25 -36
  40. package/packages/model-runtime/src/core/streams/google/google-ai.test.ts +1 -1
  41. package/packages/model-runtime/src/core/streams/google/index.ts +18 -42
  42. package/packages/model-runtime/src/core/streams/openai/openai.test.ts +7 -10
  43. package/packages/model-runtime/src/core/streams/openai/openai.ts +14 -11
  44. package/packages/model-runtime/src/core/streams/openai/responsesStream.ts +11 -5
  45. package/packages/model-runtime/src/core/streams/protocol.ts +25 -6
  46. package/packages/model-runtime/src/core/streams/qwen.ts +2 -2
  47. package/packages/model-runtime/src/core/streams/spark.ts +3 -3
  48. package/packages/model-runtime/src/core/streams/vertex-ai.test.ts +2 -2
  49. package/packages/model-runtime/src/core/streams/vertex-ai.ts +14 -23
  50. package/packages/model-runtime/src/core/usageConverters/anthropic.test.ts +99 -0
  51. package/packages/model-runtime/src/core/usageConverters/anthropic.ts +73 -0
  52. package/packages/model-runtime/src/core/usageConverters/google-ai.test.ts +88 -0
  53. package/packages/model-runtime/src/core/usageConverters/google-ai.ts +55 -0
  54. package/packages/model-runtime/src/core/usageConverters/index.ts +4 -0
  55. package/packages/model-runtime/src/core/usageConverters/openai.test.ts +429 -0
  56. package/packages/model-runtime/src/core/usageConverters/openai.ts +152 -0
  57. package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +455 -0
  58. package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.ts +293 -0
  59. package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.test.ts +47 -0
  60. package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.ts +121 -0
  61. package/packages/model-runtime/src/core/usageConverters/utils/index.ts +11 -0
  62. package/packages/model-runtime/src/core/usageConverters/utils/withUsageCost.ts +19 -0
  63. package/packages/model-runtime/src/index.ts +2 -0
  64. package/packages/model-runtime/src/providers/anthropic/index.ts +48 -1
  65. package/packages/model-runtime/src/providers/google/createImage.ts +11 -2
  66. package/packages/model-runtime/src/providers/google/index.ts +8 -1
  67. package/packages/model-runtime/src/providers/openai/__snapshots__/index.test.ts.snap +7 -0
  68. package/packages/model-runtime/src/providers/zhipu/index.ts +3 -1
  69. package/packages/model-runtime/src/types/chat.ts +5 -3
  70. package/packages/model-runtime/src/types/image.ts +20 -9
  71. package/packages/model-runtime/src/utils/getModelPricing.ts +36 -0
  72. package/packages/obervability-otel/package.json +2 -2
  73. package/packages/ssrf-safe-fetch/index.test.ts +343 -0
  74. package/packages/ssrf-safe-fetch/index.ts +37 -0
  75. package/packages/ssrf-safe-fetch/package.json +17 -0
  76. package/packages/ssrf-safe-fetch/vitest.config.mts +10 -0
  77. package/packages/types/src/message/base.ts +43 -17
  78. package/packages/utils/src/client/apiKeyManager.test.ts +70 -0
  79. package/packages/utils/src/client/apiKeyManager.ts +41 -0
  80. package/packages/utils/src/client/index.ts +2 -0
  81. package/packages/utils/src/fetch/fetchSSE.ts +4 -4
  82. package/packages/utils/src/index.ts +1 -0
  83. package/packages/utils/src/toolManifest.ts +2 -1
  84. package/src/app/(backend)/webapi/proxy/route.ts +2 -13
  85. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/QualitySelect.tsx +23 -0
  86. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +9 -0
  87. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.test.ts +13 -13
  88. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +1 -1
  89. package/src/locales/default/image.ts +7 -0
  90. package/src/server/modules/EdgeConfig/index.ts +1 -1
  91. package/src/server/routers/async/image.ts +9 -1
  92. package/src/services/_auth.ts +12 -12
  93. package/src/services/chat/contextEngineering.ts +2 -3
@@ -0,0 +1,455 @@
1
+ import anthropicChatModels from 'model-bank/anthropic';
2
+ import googleChatModels from 'model-bank/google';
3
+ import lobehubChatModels from 'model-bank/lobehub';
4
+ import openaiChatModels from 'model-bank/openai';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { ModelTokensUsage } from '@/types/message';
8
+
9
+ import { computeChatCost } from './computeChatCost';
10
+
11
+ describe('computeChatPricing', () => {
12
+ describe('OpenAI', () => {
13
+ it('handles simple request without cache for gpt-4.1', () => {
14
+ const pricing = openaiChatModels.find(
15
+ (model: { id: string }) => model.id === 'gpt-4.1',
16
+ )?.pricing;
17
+ expect(pricing).toBeDefined();
18
+
19
+ const usage: ModelTokensUsage = {
20
+ inputCacheMissTokens: 8,
21
+ inputTextTokens: 8,
22
+ outputTextTokens: 11,
23
+ totalInputTokens: 8,
24
+ totalOutputTokens: 11,
25
+ totalTokens: 19,
26
+ };
27
+
28
+ const result = computeChatCost(pricing, usage);
29
+ expect(result).toBeDefined();
30
+ expect(result?.issues).toHaveLength(0);
31
+
32
+ const { breakdown, totalCost, totalCredits } = result!;
33
+ expect(breakdown).toHaveLength(2); // Only input and output, no cache
34
+
35
+ // Verify input tokens
36
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
37
+ expect(input?.quantity).toBe(8);
38
+ expect(input?.credits).toBe(16); // 8 * 2 = 16
39
+
40
+ // Verify output tokens
41
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
42
+ expect(output?.quantity).toBe(11);
43
+ expect(output?.credits).toBe(88); // 11 * 8 = 88
44
+
45
+ // Verify totals match the actual billing log
46
+ expect(totalCredits).toBe(104); // 16 + 88 = 104
47
+ expect(totalCost).toBeCloseTo(0.000104, 6); // 104 credits = $0.000104
48
+ });
49
+
50
+ it('handles request with cache read for gpt-4.1', () => {
51
+ const pricing = openaiChatModels.find(
52
+ (model: { id: string }) => model.id === 'gpt-4.1',
53
+ )?.pricing;
54
+ expect(pricing).toBeDefined();
55
+
56
+ const usage: ModelTokensUsage = {
57
+ inputCacheMissTokens: 145,
58
+ inputCachedTokens: 1024,
59
+ inputTextTokens: 1169,
60
+ outputTextTokens: 59,
61
+ totalInputTokens: 1169,
62
+ totalOutputTokens: 59,
63
+ totalTokens: 1228,
64
+ };
65
+
66
+ const result = computeChatCost(pricing, usage);
67
+ expect(result).toBeDefined();
68
+ expect(result?.issues).toHaveLength(0);
69
+
70
+ const { breakdown, totalCost, totalCredits } = result!;
71
+ expect(breakdown).toHaveLength(3); // Input, output, and cache read
72
+
73
+ // Verify cache miss tokens (regular input)
74
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
75
+ expect(input?.quantity).toBe(145);
76
+ expect(input?.credits).toBe(290); // 145 * 2 = 290
77
+
78
+ // Verify output tokens
79
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
80
+ expect(output?.quantity).toBe(59);
81
+ expect(output?.credits).toBe(472); // 59 * 8 = 472
82
+
83
+ // Verify cached tokens (discounted rate)
84
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
85
+ expect(cached?.quantity).toBe(1024);
86
+ expect(cached?.credits).toBe(512); // 1024 * 0.5 = 512
87
+
88
+ // Verify totals match the actual billing log
89
+ expect(totalCredits).toBe(1274); // 290 + 472 + 512 = 1274
90
+ expect(totalCost).toBeCloseTo(0.001274, 6); // 1274 credits = $0.001274
91
+ });
92
+
93
+ it('handles reasoning tokens in output pricing for o3 model', () => {
94
+ const pricing = openaiChatModels.find(
95
+ (model: { id: string }) => model.id === 'gpt-4.1',
96
+ )?.pricing;
97
+ expect(pricing).toBeDefined();
98
+
99
+ const usage: ModelTokensUsage = {
100
+ inputCacheMissTokens: 58,
101
+ inputTextTokens: 58,
102
+ outputReasoningTokens: 384,
103
+ outputTextTokens: 1243,
104
+ totalInputTokens: 58,
105
+ totalOutputTokens: 1627, // 1243 + 384
106
+ totalTokens: 1685,
107
+ };
108
+
109
+ const result = computeChatCost(pricing, usage);
110
+ expect(result).toBeDefined();
111
+ expect(result?.issues).toHaveLength(0);
112
+
113
+ const { breakdown, totalCost, totalCredits } = result!;
114
+ expect(breakdown).toHaveLength(2); // Input and output
115
+
116
+ // Verify input tokens
117
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
118
+ expect(input?.quantity).toBe(58);
119
+ expect(input?.credits).toBe(116); // 58 * 2 = 116
120
+
121
+ // Verify output tokens include reasoning tokens
122
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
123
+ expect(output?.quantity).toBe(1627); // 1243 + 384 (reasoning tokens included)
124
+ expect(output?.credits).toBe(13_016); // 1627 * 8 = 13016
125
+
126
+ // Verify totals match the actual billing log
127
+ expect(totalCredits).toBe(13_132); // 116 + 13016 = 13132
128
+ expect(totalCost).toBeCloseTo(0.013132, 6); // 13132 credits = $0.013132
129
+ });
130
+ });
131
+
132
+ describe('Google', () => {
133
+ it('computes tiered pricing with reasoning tokens for large context conversation', () => {
134
+ const pricing = googleChatModels.find(
135
+ (model: { id: string }) => model.id === 'gemini-2.5-pro',
136
+ )?.pricing;
137
+ expect(pricing).toBeDefined();
138
+
139
+ const usage: ModelTokensUsage = {
140
+ inputCachedTokens: 253_891,
141
+ inputCacheMissTokens: 4_275, // totalInputTokens - inputCachedTokens = 258_166 - 253_891
142
+ inputTextTokens: 258_166,
143
+ outputReasoningTokens: 1_601,
144
+ outputTextTokens: 1_462,
145
+ totalInputTokens: 258_166,
146
+ totalOutputTokens: 3_063,
147
+ totalTokens: 261_229,
148
+ };
149
+
150
+ const result = computeChatCost(pricing, usage);
151
+ expect(result).toBeDefined();
152
+ expect(result?.issues).toHaveLength(0);
153
+
154
+ const { breakdown, totalCost, totalCredits } = result!;
155
+ expect(breakdown).toHaveLength(3); // Input, cache read, and output
156
+
157
+ // Verify cached tokens (over 200k threshold, use higher tier rate)
158
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
159
+ expect(cached?.quantity).toBe(253_891);
160
+ expect(cached?.credits).toBeCloseTo(158_681.875, 6);
161
+ expect(cached?.segments).toEqual([{ quantity: 253_891, rate: 0.625, credits: 158_681.875 }]);
162
+
163
+ // Verify input cache miss tokens (calculated as totalInputTokens - inputCachedTokens = 4275)
164
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
165
+ expect(input?.quantity).toBe(4_275); // 258_166 - 253_891 = 4_275 (cache miss)
166
+ expect(input?.credits).toBeCloseTo(5_343.75, 6);
167
+ expect(input?.segments).toEqual([{ quantity: 4_275, rate: 1.25, credits: 5_343.75 }]);
168
+
169
+ // Verify output tokens include reasoning tokens (under 200k threshold, use lower tier rate)
170
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
171
+ expect(output?.quantity).toBe(3_063); // 1462 + 1601 = 3063 (reasoning tokens included)
172
+ expect(output?.credits).toBe(30_630); // 3063 * 10 = 30630
173
+ expect(output?.segments).toEqual([{ quantity: 3_063, rate: 10, credits: 30_630 }]);
174
+
175
+ // Verify corrected totals (no double counting of cached tokens)
176
+ expect(totalCredits).toBe(194_656); // ceil(158681.875 + 5343.75 + 30630) = 194656
177
+ expect(totalCost).toBeCloseTo(0.194656, 6); // 194656 credits = $0.194656
178
+ });
179
+
180
+ it('supports multi-modal fixed units for Gemini 2.5 Flash Image Preview', () => {
181
+ const pricing = googleChatModels.find(
182
+ (model: { id: string }) => model.id === 'gemini-2.5-flash-image-preview',
183
+ )?.pricing;
184
+ expect(pricing).toBeDefined();
185
+
186
+ const usage: ModelTokensUsage = {
187
+ inputCacheMissTokens: 10_000,
188
+ outputTextTokens: 5_000,
189
+ outputImageTokens: 400,
190
+ };
191
+
192
+ const result = computeChatCost(pricing, usage);
193
+ expect(result).toBeDefined();
194
+ expect(result?.issues).toHaveLength(0);
195
+ expect(result?.totalCredits).toBe(27_500);
196
+ expect(result?.totalCost).toBeCloseTo(0.0275, 10);
197
+
198
+ const input = result?.breakdown.find((item) => item.unit.name === 'textInput');
199
+ expect(input?.credits).toBe(3_000);
200
+
201
+ const outputText = result?.breakdown.find((item) => item.unit.name === 'textOutput');
202
+ expect(outputText?.credits).toBe(12_500);
203
+
204
+ const imageOutput = result?.breakdown.find((item) => item.unit.name === 'imageOutput');
205
+ expect(imageOutput?.credits).toBe(12_000);
206
+ });
207
+
208
+ it('handles multi-modal image generation for Nano Banana', () => {
209
+ const pricing = googleChatModels.find(
210
+ (model: { id: string }) => model.id === 'gemini-2.5-flash-image-preview',
211
+ )?.pricing;
212
+ expect(pricing).toBeDefined();
213
+
214
+ const usage: ModelTokensUsage = {
215
+ inputImageTokens: 5160,
216
+ inputTextTokens: 60,
217
+ outputImageTokens: 1290,
218
+ outputTextTokens: 0,
219
+ totalInputTokens: 5220,
220
+ totalOutputTokens: 1290,
221
+ totalTokens: 6510,
222
+ };
223
+
224
+ const result = computeChatCost(pricing, usage);
225
+ expect(result).toBeDefined();
226
+ expect(result?.issues).toHaveLength(0);
227
+ expect(result?.totalCredits).toBe(40_266);
228
+ expect(result?.totalCost).toBeCloseTo(0.040266, 6);
229
+
230
+ const { breakdown } = result!;
231
+ expect(breakdown).toHaveLength(4); // Text input, image input, text output, image output
232
+
233
+ const textInput = result?.breakdown.find((item) => item.unit.name === 'textInput');
234
+ expect(textInput?.quantity).toBe(60);
235
+ expect(textInput?.credits).toBe(18); // 60 * 0.3 = 18
236
+
237
+ const imageInput = result?.breakdown.find((item) => item.unit.name === 'imageInput');
238
+ expect(imageInput?.quantity).toBe(5160);
239
+ expect(imageInput?.credits).toBe(1_548); // 5160 * 0.3 = 1548
240
+
241
+ const textOutput = result?.breakdown.find((item) => item.unit.name === 'textOutput');
242
+ expect(textOutput?.quantity).toBe(0);
243
+ expect(textOutput?.credits).toBe(0); // 0 * 2.5 = 0
244
+
245
+ const imageOutput = result?.breakdown.find((item) => item.unit.name === 'imageOutput');
246
+ expect(imageOutput?.quantity).toBe(1290);
247
+ expect(imageOutput?.credits).toBe(38_700); // 1290 * 30 = 38700
248
+ });
249
+
250
+ it('handles large context conversation with cache cross-tier pricing for Gemini 2.5 Pro', () => {
251
+ const pricing = googleChatModels.find(
252
+ (model: { id: string }) => model.id === 'gemini-2.5-pro',
253
+ )?.pricing;
254
+ expect(pricing).toBeDefined();
255
+
256
+ const usage: ModelTokensUsage = {
257
+ inputCachedTokens: 257_955,
258
+ inputCacheMissTokens: 5_005,
259
+ inputTextTokens: 262_960,
260
+ outputTextTokens: 1_744,
261
+ totalInputTokens: 262_960,
262
+ totalOutputTokens: 1_744,
263
+ totalTokens: 264_704,
264
+ };
265
+
266
+ const result = computeChatCost(pricing, usage);
267
+ expect(result).toBeDefined();
268
+ expect(result?.issues).toHaveLength(0);
269
+
270
+ const { breakdown, totalCost, totalCredits } = result!;
271
+ expect(breakdown).toHaveLength(3); // Cache read, input, and output
272
+
273
+ // Verify cached tokens (cross-tier: over 200k threshold, use higher tier rate)
274
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
275
+ expect(cached?.quantity).toBe(257_955);
276
+
277
+ expect(cached?.credits).toBeCloseTo(161_221.875, 6);
278
+ expect(cached?.segments).toEqual([{ quantity: 257_955, rate: 0.625, credits: 161_221.875 }]);
279
+
280
+ // Verify input cache miss tokens (under 200k tier, use lower rate)
281
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
282
+ expect(input?.quantity).toBe(5_005);
283
+ expect(input?.credits).toBeCloseTo(6_256.25, 6);
284
+ expect(input?.segments).toEqual([{ quantity: 5_005, rate: 1.25, credits: 6_256.25 }]);
285
+
286
+ // Verify output tokens (under 200k threshold, use lower tier rate)
287
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
288
+ expect(output?.quantity).toBe(1_744);
289
+ expect(output?.credits).toBe(17_440); // 1744 * 10 = 17440
290
+ expect(output?.segments).toEqual([{ quantity: 1_744, rate: 10, credits: 17_440 }]);
291
+
292
+ // Verify totals match actual billing log
293
+ expect(totalCredits).toBe(184_919); // ceil(161221.875 + 6256.25 + 17440) = 184919
294
+ expect(totalCost).toBeCloseTo(0.184919, 6); // 184919 credits = $0.184919
295
+ });
296
+ });
297
+
298
+ describe('Anthropic', () => {
299
+ it('handles lookup pricing with TTL for Claude Opus 4.1', () => {
300
+ const pricing = anthropicChatModels.find(
301
+ (model: { id: string }) => model.id === 'claude-opus-4-1-20250805',
302
+ )?.pricing;
303
+ expect(pricing).toBeDefined();
304
+
305
+ const usage: ModelTokensUsage = {
306
+ inputCacheMissTokens: 1_000,
307
+ inputCachedTokens: 200,
308
+ inputWriteCacheTokens: 300,
309
+ outputTextTokens: 500,
310
+ };
311
+
312
+ const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
313
+ expect(result).toBeDefined();
314
+ expect(result?.issues).toHaveLength(0);
315
+ expect(result?.totalCredits).toBe(58_425);
316
+ expect(result?.totalCost).toBeCloseTo(0.058425, 10);
317
+
318
+ const cacheWrite = result?.breakdown.find(
319
+ (item) => item.unit.name === 'textInput_cacheWrite',
320
+ );
321
+ expect(cacheWrite?.lookupKey).toBe('5m');
322
+ expect(cacheWrite?.credits).toBe(5_625);
323
+ });
324
+
325
+ it('handles simple request without thinking for Claude Sonnet 4', () => {
326
+ const pricing = anthropicChatModels.find(
327
+ (model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
328
+ )?.pricing;
329
+ expect(pricing).toBeDefined();
330
+
331
+ const usage: ModelTokensUsage = {
332
+ inputCacheMissTokens: 8,
333
+ totalInputTokens: 8,
334
+ totalOutputTokens: 24,
335
+ totalTokens: 32,
336
+ };
337
+
338
+ const result = computeChatCost(pricing, usage);
339
+ expect(result).toBeDefined();
340
+ expect(result?.issues).toHaveLength(0);
341
+
342
+ const { breakdown, totalCost, totalCredits } = result!;
343
+ expect(breakdown).toHaveLength(2); // Only input and output
344
+
345
+ // Verify input tokens
346
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
347
+ expect(input?.quantity).toBe(8);
348
+ expect(input?.credits).toBe(24); // 8 * 3 = 24
349
+
350
+ // Verify output tokens
351
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
352
+ expect(output?.quantity).toBe(24);
353
+ expect(output?.credits).toBe(360); // 24 * 15 = 360
354
+
355
+ // Verify totals match the actual billing log
356
+ expect(totalCredits).toBe(384); // 24 + 360 = 384
357
+ expect(totalCost).toBeCloseTo(0.000384, 6); // 384 credits = $0.000384
358
+ });
359
+
360
+ it('handles request with cache read and write for Claude Sonnet 4', () => {
361
+ const pricing = anthropicChatModels.find(
362
+ (model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
363
+ )?.pricing;
364
+ expect(pricing).toBeDefined();
365
+
366
+ const usage: ModelTokensUsage = {
367
+ inputCacheMissTokens: 4,
368
+ inputCachedTokens: 1183,
369
+ inputWriteCacheTokens: 458,
370
+ totalInputTokens: 1645,
371
+ totalOutputTokens: 522,
372
+ totalTokens: 2167,
373
+ };
374
+
375
+ const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
376
+ expect(result).toBeDefined();
377
+ expect(result?.issues).toHaveLength(0);
378
+
379
+ const { breakdown, totalCost, totalCredits } = result!;
380
+ expect(breakdown).toHaveLength(4); // Input, output, cache read, cache write
381
+
382
+ // Verify cache miss tokens (regular input)
383
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
384
+ expect(input?.quantity).toBe(4);
385
+ expect(input?.credits).toBe(12); // 4 * 3 = 12
386
+
387
+ // Verify output tokens
388
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
389
+ expect(output?.quantity).toBe(522);
390
+ expect(output?.credits).toBe(7_830); // 522 * 15 = 7830
391
+
392
+ // Verify cached tokens (discounted rate)
393
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
394
+ expect(cached?.quantity).toBe(1183);
395
+ expect(cached?.credits).toBeCloseTo(354.9, 6);
396
+
397
+ // Verify cache write tokens
398
+ const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
399
+ expect(cacheWrite?.quantity).toBe(458);
400
+ expect(cacheWrite?.lookupKey).toBe('5m');
401
+ expect(cacheWrite?.credits).toBeCloseTo(1_717.5, 6);
402
+
403
+ // Verify totals match the actual billing log
404
+ expect(totalCredits).toBe(9_915); // ceil(12 + 7830 + 354.9 + 1717.5) = 9915
405
+ expect(totalCost).toBeCloseTo(0.009915, 6); // 9915 credits = $0.009915
406
+ });
407
+
408
+ it('handles complex scenario with all cache types for Claude Sonnet 4 Latest', () => {
409
+ const pricing = anthropicChatModels.find(
410
+ (model: { id: string }) => model.id === 'claude-sonnet-4-20250514',
411
+ )?.pricing;
412
+ expect(pricing).toBeDefined();
413
+
414
+ const usage: ModelTokensUsage = {
415
+ inputCacheMissTokens: 10,
416
+ inputCachedTokens: 3021,
417
+ inputWriteCacheTokens: 1697,
418
+ totalInputTokens: 4728,
419
+ totalOutputTokens: 2841,
420
+ totalTokens: 7569,
421
+ };
422
+
423
+ const result = computeChatCost(pricing, usage, { lookupParams: { ttl: '5m' } });
424
+ expect(result).toBeDefined();
425
+ expect(result?.issues).toHaveLength(0);
426
+
427
+ const { breakdown, totalCost, totalCredits } = result!;
428
+ expect(breakdown).toHaveLength(4); // Input, output, cache read, cache write
429
+
430
+ // Verify cache miss tokens (regular input)
431
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
432
+ expect(input?.quantity).toBe(10);
433
+ expect(input?.credits).toBe(30); // 10 * 3 = 30
434
+
435
+ // Verify output tokens
436
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
437
+ expect(output?.quantity).toBe(2841);
438
+ expect(output?.credits).toBe(42_615); // 2841 * 15 = 42615
439
+
440
+ // Verify cached tokens (discounted rate)
441
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
442
+ expect(cached?.quantity).toBe(3021);
443
+ expect(cached?.credits).toBeCloseTo(906.3, 6);
444
+
445
+ // Verify cache write tokens (fixed strategy in lobehub model)
446
+ const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
447
+ expect(cacheWrite?.quantity).toBe(1697);
448
+ expect(cacheWrite?.credits).toBeCloseTo(6_363.75, 6);
449
+
450
+ // Verify totals match the actual billing log
451
+ expect(totalCredits).toBe(49_916); // ceil(30 + 42615 + 906.3 + 6363.75) = 49916
452
+ expect(totalCost).toBeCloseTo(0.049916, 6); // 49916 credits = $0.049916
453
+ });
454
+ });
455
+ });