@lobehub/chat 1.141.2 → 1.141.4
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.
- package/.github/workflows/sync.yml +2 -2
- package/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/changelog/v1.json +18 -0
- package/locales/ar/error.json +12 -0
- package/locales/ar/modelProvider.json +52 -0
- package/locales/ar/models.json +33 -0
- package/locales/ar/providers.json +3 -3
- package/locales/bg-BG/error.json +12 -0
- package/locales/bg-BG/modelProvider.json +52 -0
- package/locales/bg-BG/models.json +33 -0
- package/locales/bg-BG/providers.json +3 -3
- package/locales/de-DE/error.json +12 -0
- package/locales/de-DE/modelProvider.json +52 -0
- package/locales/de-DE/models.json +33 -0
- package/locales/de-DE/providers.json +3 -3
- package/locales/en-US/error.json +12 -0
- package/locales/en-US/modelProvider.json +14 -14
- package/locales/en-US/models.json +33 -0
- package/locales/en-US/providers.json +3 -3
- package/locales/es-ES/error.json +12 -0
- package/locales/es-ES/modelProvider.json +52 -0
- package/locales/es-ES/models.json +33 -0
- package/locales/es-ES/providers.json +3 -3
- package/locales/fa-IR/error.json +12 -0
- package/locales/fa-IR/modelProvider.json +52 -0
- package/locales/fa-IR/models.json +33 -0
- package/locales/fa-IR/providers.json +3 -3
- package/locales/fr-FR/error.json +12 -0
- package/locales/fr-FR/modelProvider.json +52 -0
- package/locales/fr-FR/models.json +33 -0
- package/locales/fr-FR/providers.json +3 -3
- package/locales/it-IT/error.json +12 -0
- package/locales/it-IT/modelProvider.json +52 -0
- package/locales/it-IT/models.json +33 -0
- package/locales/it-IT/providers.json +3 -3
- package/locales/ja-JP/error.json +12 -0
- package/locales/ja-JP/modelProvider.json +52 -0
- package/locales/ja-JP/models.json +33 -0
- package/locales/ja-JP/providers.json +3 -3
- package/locales/ko-KR/error.json +12 -0
- package/locales/ko-KR/modelProvider.json +52 -0
- package/locales/ko-KR/models.json +33 -0
- package/locales/ko-KR/providers.json +3 -3
- package/locales/nl-NL/error.json +12 -0
- package/locales/nl-NL/modelProvider.json +52 -0
- package/locales/nl-NL/models.json +33 -0
- package/locales/nl-NL/providers.json +3 -3
- package/locales/pl-PL/error.json +12 -0
- package/locales/pl-PL/modelProvider.json +52 -0
- package/locales/pl-PL/models.json +33 -0
- package/locales/pl-PL/providers.json +3 -3
- package/locales/pt-BR/error.json +12 -0
- package/locales/pt-BR/modelProvider.json +52 -0
- package/locales/pt-BR/models.json +33 -0
- package/locales/pt-BR/providers.json +3 -3
- package/locales/ru-RU/error.json +12 -0
- package/locales/ru-RU/modelProvider.json +52 -0
- package/locales/ru-RU/models.json +33 -0
- package/locales/ru-RU/providers.json +3 -0
- package/locales/tr-TR/error.json +12 -0
- package/locales/tr-TR/modelProvider.json +52 -0
- package/locales/tr-TR/models.json +33 -0
- package/locales/tr-TR/providers.json +3 -3
- package/locales/vi-VN/error.json +12 -0
- package/locales/vi-VN/modelProvider.json +52 -0
- package/locales/vi-VN/models.json +33 -0
- package/locales/vi-VN/providers.json +3 -3
- package/locales/zh-CN/chat.json +1 -1
- package/locales/zh-CN/components.json +4 -4
- package/locales/zh-CN/error.json +12 -0
- package/locales/zh-CN/modelProvider.json +14 -14
- package/locales/zh-CN/models.json +8 -27
- package/locales/zh-TW/error.json +12 -0
- package/locales/zh-TW/modelProvider.json +52 -0
- package/locales/zh-TW/models.json +33 -0
- package/locales/zh-TW/providers.json +3 -3
- package/package.json +1 -1
- package/packages/agent-runtime/src/core/runtime.ts +23 -12
- package/packages/agent-runtime/src/types/instruction.ts +4 -0
- package/packages/const/src/currency.ts +2 -2
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +205 -12
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.ts +47 -11
- package/src/app/__tests__/desktop.routes.test.ts +18 -0
- package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +56 -25
- package/src/features/FileManager/FileList/index.tsx +12 -3
- package/src/store/file/slices/fileManager/action.test.ts +88 -0
- package/src/store/file/slices/fileManager/action.ts +21 -9
|
@@ -35,9 +35,6 @@
|
|
|
35
35
|
"cerebras": {
|
|
36
36
|
"description": "Cerebras 是一個基於其專用 CS-3 系統的 AI 推理平台,旨在提供全球最快、即時回應、高吞吐量的 LLM 服務,專為消除延遲並加速複雜的 AI 工作流程(如即時程式碼生成與代理任務)而設計。"
|
|
37
37
|
},
|
|
38
|
-
"cerebras": {
|
|
39
|
-
"description": "Cerebras 是一個基於其專用 CS-3 系統的 AI 推理平台,旨在提供全球最快、即時回應、高吞吐量的 LLM 服務,專為消除延遲並加速複雜的 AI 工作流程(如即時程式碼生成與代理任務)而設計。"
|
|
40
|
-
},
|
|
41
38
|
"cloudflare": {
|
|
42
39
|
"description": "在 Cloudflare 的全球網絡上運行由無伺服器 GPU 驅動的機器學習模型。"
|
|
43
40
|
},
|
|
@@ -47,6 +44,9 @@
|
|
|
47
44
|
"cometapi": {
|
|
48
45
|
"description": "CometAPI 是一個提供多種前沿大型模型介面的服務平台,支援 OpenAI、Anthropic、Google 及更多,適合多樣化的開發和應用需求。使用者可根據自身需求靈活選擇最優的模型和價格,助力 AI 體驗的提升。"
|
|
49
46
|
},
|
|
47
|
+
"comfyui": {
|
|
48
|
+
"description": "強大的開源圖像、影片、音訊生成工作流程引擎,支援 SD、FLUX、Qwen、Hunyuan、WAN 等先進模型,提供節點化工作流程編輯與私有化部署能力"
|
|
49
|
+
},
|
|
50
50
|
"deepseek": {
|
|
51
51
|
"description": "DeepSeek 是一家專注於人工智慧技術研究和應用的公司,其最新模型 DeepSeek-V2.5 融合了通用對話和代碼處理能力,並在人類偏好對齊、寫作任務和指令跟隨等方面實現了顯著提升。"
|
|
52
52
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "1.141.
|
|
3
|
+
"version": "1.141.4",
|
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -256,15 +256,11 @@ export class AgentRuntime {
|
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/**
|
|
259
|
-
* Create
|
|
260
|
-
* @
|
|
261
|
-
* @returns Complete AgentState with defaults filled in
|
|
259
|
+
* Create default usage statistics structure
|
|
260
|
+
* @returns Default Usage object with all counters set to 0
|
|
262
261
|
*/
|
|
263
|
-
static
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// Default usage statistics
|
|
267
|
-
const defaultUsage: Usage = {
|
|
262
|
+
static createDefaultUsage(): Usage {
|
|
263
|
+
return {
|
|
268
264
|
humanInteraction: {
|
|
269
265
|
approvalRequests: 0,
|
|
270
266
|
promptRequests: 0,
|
|
@@ -282,9 +278,15 @@ export class AgentRuntime {
|
|
|
282
278
|
totalTimeMs: 0,
|
|
283
279
|
},
|
|
284
280
|
};
|
|
281
|
+
}
|
|
285
282
|
|
|
286
|
-
|
|
287
|
-
|
|
283
|
+
/**
|
|
284
|
+
* Create default cost structure
|
|
285
|
+
* @returns Default Cost object with all costs set to 0
|
|
286
|
+
*/
|
|
287
|
+
static createDefaultCost(): Cost {
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
return {
|
|
288
290
|
calculatedAt: now,
|
|
289
291
|
currency: 'USD',
|
|
290
292
|
llm: {
|
|
@@ -299,16 +301,25 @@ export class AgentRuntime {
|
|
|
299
301
|
},
|
|
300
302
|
total: 0,
|
|
301
303
|
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Create a new agent state with flexible initialization
|
|
308
|
+
* @param partialState - Partial state to override defaults
|
|
309
|
+
* @returns Complete AgentState with defaults filled in
|
|
310
|
+
*/
|
|
311
|
+
static createInitialState(partialState: Partial<AgentState> & { sessionId: string }): AgentState {
|
|
312
|
+
const now = new Date().toISOString();
|
|
302
313
|
|
|
303
314
|
return {
|
|
304
|
-
cost:
|
|
315
|
+
cost: AgentRuntime.createDefaultCost(),
|
|
305
316
|
// Default values
|
|
306
317
|
createdAt: now,
|
|
307
318
|
lastModified: now,
|
|
308
319
|
messages: [],
|
|
309
320
|
status: 'idle',
|
|
310
321
|
stepCount: 0,
|
|
311
|
-
usage:
|
|
322
|
+
usage: AgentRuntime.createDefaultUsage(),
|
|
312
323
|
// User provided values override defaults
|
|
313
324
|
...partialState,
|
|
314
325
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelUsage } from '@lobechat/types';
|
|
2
|
+
|
|
1
3
|
import type { FinishReason } from './event';
|
|
2
4
|
import { AgentState, ToolRegistry, ToolsCalling } from './state';
|
|
3
5
|
import type { Cost, CostCalculationContext, Usage } from './usage';
|
|
@@ -26,6 +28,8 @@ export interface AgentRuntimeContext {
|
|
|
26
28
|
status: AgentState['status'];
|
|
27
29
|
stepCount: number;
|
|
28
30
|
};
|
|
31
|
+
/** Usage statistics from the current step (if applicable) */
|
|
32
|
+
stepUsage?: ModelUsage;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Pricing } from 'model-bank';
|
|
1
2
|
import anthropicChatModels from 'model-bank/anthropic';
|
|
2
3
|
import googleChatModels from 'model-bank/google';
|
|
3
4
|
import lobehubChatModels from 'model-bank/lobehub';
|
|
@@ -157,13 +158,13 @@ describe('computeChatPricing', () => {
|
|
|
157
158
|
// Verify cached tokens (over 200k threshold, use higher tier rate)
|
|
158
159
|
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
|
|
159
160
|
expect(cached?.quantity).toBe(253_891);
|
|
160
|
-
expect(cached?.credits).
|
|
161
|
+
expect(cached?.credits).toBe(158_682); // ceil(158681.875) = 158682
|
|
161
162
|
expect(cached?.segments).toEqual([{ quantity: 253_891, rate: 0.625, credits: 158_681.875 }]);
|
|
162
163
|
|
|
163
164
|
// Verify input cache miss tokens (calculated as totalInputTokens - inputCachedTokens = 4275)
|
|
164
165
|
const input = breakdown.find((item) => item.unit.name === 'textInput');
|
|
165
166
|
expect(input?.quantity).toBe(4_275); // 258_166 - 253_891 = 4_275 (cache miss)
|
|
166
|
-
expect(input?.credits).
|
|
167
|
+
expect(input?.credits).toBe(5_344); // ceil(5343.75) = 5344
|
|
167
168
|
expect(input?.segments).toEqual([{ quantity: 4_275, rate: 1.25, credits: 5_343.75 }]);
|
|
168
169
|
|
|
169
170
|
// Verify output tokens include reasoning tokens (under 200k threshold, use lower tier rate)
|
|
@@ -173,7 +174,7 @@ describe('computeChatPricing', () => {
|
|
|
173
174
|
expect(output?.segments).toEqual([{ quantity: 3_063, rate: 10, credits: 30_630 }]);
|
|
174
175
|
|
|
175
176
|
// Verify corrected totals (no double counting of cached tokens)
|
|
176
|
-
expect(totalCredits).toBe(194_656); //
|
|
177
|
+
expect(totalCredits).toBe(194_656); // 158682 + 5344 + 30630 = 194656
|
|
177
178
|
expect(totalCost).toBeCloseTo(0.194656, 6); // 194656 credits = $0.194656
|
|
178
179
|
});
|
|
179
180
|
|
|
@@ -274,13 +275,13 @@ describe('computeChatPricing', () => {
|
|
|
274
275
|
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
|
|
275
276
|
expect(cached?.quantity).toBe(257_955);
|
|
276
277
|
|
|
277
|
-
expect(cached?.credits).
|
|
278
|
+
expect(cached?.credits).toBe(161_222); // ceil(161221.875) = 161222
|
|
278
279
|
expect(cached?.segments).toEqual([{ quantity: 257_955, rate: 0.625, credits: 161_221.875 }]);
|
|
279
280
|
|
|
280
281
|
// Verify input cache miss tokens (under 200k tier, use lower rate)
|
|
281
282
|
const input = breakdown.find((item) => item.unit.name === 'textInput');
|
|
282
283
|
expect(input?.quantity).toBe(5_005);
|
|
283
|
-
expect(input?.credits).
|
|
284
|
+
expect(input?.credits).toBe(6_257); // ceil(6256.25) = 6257
|
|
284
285
|
expect(input?.segments).toEqual([{ quantity: 5_005, rate: 1.25, credits: 6_256.25 }]);
|
|
285
286
|
|
|
286
287
|
// Verify output tokens (under 200k threshold, use lower tier rate)
|
|
@@ -290,7 +291,7 @@ describe('computeChatPricing', () => {
|
|
|
290
291
|
expect(output?.segments).toEqual([{ quantity: 1_744, rate: 10, credits: 17_440 }]);
|
|
291
292
|
|
|
292
293
|
// Verify totals match actual billing log
|
|
293
|
-
expect(totalCredits).toBe(184_919); //
|
|
294
|
+
expect(totalCredits).toBe(184_919); // 161222 + 6257 + 17440 = 184919
|
|
294
295
|
expect(totalCost).toBeCloseTo(0.184919, 6); // 184919 credits = $0.184919
|
|
295
296
|
});
|
|
296
297
|
});
|
|
@@ -468,16 +469,16 @@ describe('computeChatPricing', () => {
|
|
|
468
469
|
// Verify cached tokens (discounted rate)
|
|
469
470
|
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
|
|
470
471
|
expect(cached?.quantity).toBe(1183);
|
|
471
|
-
expect(cached?.credits).
|
|
472
|
+
expect(cached?.credits).toBe(355); // 354.9 rounded = 355
|
|
472
473
|
|
|
473
474
|
// Verify cache write tokens
|
|
474
475
|
const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
|
|
475
476
|
expect(cacheWrite?.quantity).toBe(458);
|
|
476
477
|
expect(cacheWrite?.lookupKey).toBe('5m');
|
|
477
|
-
expect(cacheWrite?.credits).
|
|
478
|
+
expect(cacheWrite?.credits).toBe(1_718); // 1717.5 rounded = 1718
|
|
478
479
|
|
|
479
480
|
// Verify totals match the actual billing log
|
|
480
|
-
expect(totalCredits).toBe(9_915); //
|
|
481
|
+
expect(totalCredits).toBe(9_915); // 12 + 7830 + 355 + 1718 = 9915
|
|
481
482
|
expect(totalCost).toBeCloseTo(0.009915, 6); // 9915 credits = $0.009915
|
|
482
483
|
});
|
|
483
484
|
|
|
@@ -516,15 +517,15 @@ describe('computeChatPricing', () => {
|
|
|
516
517
|
// Verify cached tokens (discounted rate)
|
|
517
518
|
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
|
|
518
519
|
expect(cached?.quantity).toBe(3021);
|
|
519
|
-
expect(cached?.credits).
|
|
520
|
+
expect(cached?.credits).toBe(907); // ceil(906.3) = 907
|
|
520
521
|
|
|
521
522
|
// Verify cache write tokens (fixed strategy in lobehub model)
|
|
522
523
|
const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
|
|
523
524
|
expect(cacheWrite?.quantity).toBe(1697);
|
|
524
|
-
expect(cacheWrite?.credits).
|
|
525
|
+
expect(cacheWrite?.credits).toBe(6_364); // ceil(6363.75) = 6364
|
|
525
526
|
|
|
526
527
|
// Verify totals match the actual billing log
|
|
527
|
-
expect(totalCredits).toBe(49_916); //
|
|
528
|
+
expect(totalCredits).toBe(49_916); // 30 + 42615 + 907 + 6364 = 49916
|
|
528
529
|
expect(totalCost).toBeCloseTo(0.049916, 6); // 49916 credits = $0.049916
|
|
529
530
|
});
|
|
530
531
|
});
|
|
@@ -682,4 +683,196 @@ describe('computeChatPricing', () => {
|
|
|
682
683
|
expect(result?.totalCost).toBe(0);
|
|
683
684
|
});
|
|
684
685
|
});
|
|
686
|
+
|
|
687
|
+
describe('Currency Conversion', () => {
|
|
688
|
+
describe('DeepSeek (CNY pricing)', () => {
|
|
689
|
+
it('converts CNY to USD for deepseek-chat without cache', () => {
|
|
690
|
+
// DeepSeek pricing in CNY
|
|
691
|
+
const pricing = {
|
|
692
|
+
currency: 'CNY',
|
|
693
|
+
units: [
|
|
694
|
+
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
695
|
+
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
|
696
|
+
],
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const usage: ModelTokensUsage = {
|
|
700
|
+
inputCacheMissTokens: 1000,
|
|
701
|
+
inputTextTokens: 1000,
|
|
702
|
+
outputTextTokens: 500,
|
|
703
|
+
totalInputTokens: 1000,
|
|
704
|
+
totalOutputTokens: 500,
|
|
705
|
+
totalTokens: 1500,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// Use fixed exchange rate for testing
|
|
709
|
+
const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
|
|
710
|
+
expect(result).toBeDefined();
|
|
711
|
+
expect(result?.issues).toHaveLength(0);
|
|
712
|
+
|
|
713
|
+
const { breakdown, totalCost, totalCredits } = result!;
|
|
714
|
+
expect(breakdown).toHaveLength(2); // Input and output
|
|
715
|
+
|
|
716
|
+
// Verify input tokens
|
|
717
|
+
// 1000 tokens * 2 CNY/M = 2000 raw CNY-credits
|
|
718
|
+
// 2000 / 5 = 400 USD-credits
|
|
719
|
+
const input = breakdown.find((item) => item.unit.name === 'textInput');
|
|
720
|
+
expect(input?.quantity).toBe(1000);
|
|
721
|
+
expect(input?.credits).toBe(400); // USD credits
|
|
722
|
+
|
|
723
|
+
// Verify output tokens
|
|
724
|
+
// 500 tokens * 3 CNY/M = 1500 raw CNY-credits
|
|
725
|
+
// 1500 / 5 = 300 USD-credits
|
|
726
|
+
const output = breakdown.find((item) => item.unit.name === 'textOutput');
|
|
727
|
+
expect(output?.quantity).toBe(500);
|
|
728
|
+
expect(output?.credits).toBe(300); // USD credits
|
|
729
|
+
|
|
730
|
+
// Verify totals with CNY to USD conversion
|
|
731
|
+
// Total USD credits = 400 + 300 = 700
|
|
732
|
+
// totalCredits = ceil(700) = 700
|
|
733
|
+
expect(totalCredits).toBe(700);
|
|
734
|
+
|
|
735
|
+
// totalCost = 700 / 1_000_000 = 0.0007 USD
|
|
736
|
+
expect(totalCost).toBeCloseTo(0.0007, 6);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('converts CNY to USD for deepseek-chat with cache tokens', () => {
|
|
740
|
+
const pricing = {
|
|
741
|
+
currency: 'CNY',
|
|
742
|
+
units: [
|
|
743
|
+
{ name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
|
|
744
|
+
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
745
|
+
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
|
746
|
+
],
|
|
747
|
+
} satisfies Pricing;
|
|
748
|
+
|
|
749
|
+
const usage: ModelTokensUsage = {
|
|
750
|
+
inputCacheMissTokens: 785,
|
|
751
|
+
inputCachedTokens: 2752,
|
|
752
|
+
inputTextTokens: 3537,
|
|
753
|
+
outputTextTokens: 77,
|
|
754
|
+
totalInputTokens: 3537,
|
|
755
|
+
totalOutputTokens: 77,
|
|
756
|
+
totalTokens: 3614,
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const result = computeChatCost(pricing, usage, { usdToCnyRate: 5 });
|
|
760
|
+
expect(result).toBeDefined();
|
|
761
|
+
expect(result?.issues).toHaveLength(0);
|
|
762
|
+
|
|
763
|
+
const { breakdown, totalCost, totalCredits } = result!;
|
|
764
|
+
expect(breakdown).toHaveLength(3); // Cache read, input, and output
|
|
765
|
+
|
|
766
|
+
// Verify cache miss tokens
|
|
767
|
+
// 785 tokens * 2 CNY/M = 1570 raw CNY-credits
|
|
768
|
+
// 1570 / 5 = 314 USD-credits
|
|
769
|
+
const input = breakdown.find((item) => item.unit.name === 'textInput');
|
|
770
|
+
expect(input?.quantity).toBe(785);
|
|
771
|
+
expect(input?.credits).toBe(314); // USD credits
|
|
772
|
+
|
|
773
|
+
// Verify cached tokens
|
|
774
|
+
// 2752 tokens * 0.2 CNY/M = 550.4 raw CNY-credits
|
|
775
|
+
// 550.4 / 5 = 110.08 -> ceil(110.08) = 111 USD-credits
|
|
776
|
+
const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
|
|
777
|
+
expect(cached?.quantity).toBe(2752);
|
|
778
|
+
expect(cached?.credits).toBe(111); // USD credits
|
|
779
|
+
|
|
780
|
+
// Verify output tokens
|
|
781
|
+
// 77 tokens * 3 CNY/M = 231 raw CNY-credits
|
|
782
|
+
// 231 / 5 = 46.2 -> ceil(46.2) = 47 USD-credits
|
|
783
|
+
const output = breakdown.find((item) => item.unit.name === 'textOutput');
|
|
784
|
+
expect(output?.quantity).toBe(77);
|
|
785
|
+
expect(output?.credits).toBe(47); // USD credits
|
|
786
|
+
|
|
787
|
+
// Verify totals with CNY to USD conversion
|
|
788
|
+
// Total USD credits = 314 + 111 + 47 = 472
|
|
789
|
+
expect(totalCredits).toBe(472);
|
|
790
|
+
|
|
791
|
+
// totalCost = 472 / 1_000_000 = 0.000472 USD
|
|
792
|
+
expect(totalCost).toBe(0.000472);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('converts CNY to USD for large token usage', () => {
|
|
796
|
+
const pricing = {
|
|
797
|
+
currency: 'CNY',
|
|
798
|
+
units: [
|
|
799
|
+
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
800
|
+
{ name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
|
|
801
|
+
],
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const usage: ModelTokensUsage = {
|
|
805
|
+
inputTextTokens: 1_000_000, // 1M input tokens
|
|
806
|
+
outputTextTokens: 500_000, // 500K output tokens
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
|
|
810
|
+
expect(result).toBeDefined();
|
|
811
|
+
|
|
812
|
+
const { totalCost, totalCredits } = result!;
|
|
813
|
+
|
|
814
|
+
// Input: 1M * 2 CNY = 2M CNY-credits = 2M / 5 = 400000 USD-credits
|
|
815
|
+
// Output: 500K * 3 CNY = 1.5M CNY-credits = 1.5M / 5 = 300000 USD-credits
|
|
816
|
+
// Total: 700000 USD-credits
|
|
817
|
+
expect(totalCredits).toBe(700_000);
|
|
818
|
+
|
|
819
|
+
// totalCost = 700000 / 1_000_000 = 0.7 USD
|
|
820
|
+
expect(totalCost).toBe(0.7);
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe('USD pricing (no conversion)', () => {
|
|
825
|
+
it('does not convert USD pricing', () => {
|
|
826
|
+
const pricing = {
|
|
827
|
+
currency: 'USD',
|
|
828
|
+
units: [
|
|
829
|
+
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
830
|
+
{ name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
|
|
831
|
+
],
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const usage: ModelTokensUsage = {
|
|
835
|
+
inputTextTokens: 1000,
|
|
836
|
+
outputTextTokens: 500,
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
const result = computeChatCost(pricing as any, usage);
|
|
840
|
+
expect(result).toBeDefined();
|
|
841
|
+
|
|
842
|
+
const { totalCost, totalCredits } = result!;
|
|
843
|
+
|
|
844
|
+
// Input: 1000 * 2 = 2000 USD-credits
|
|
845
|
+
// Output: 500 * 8 = 4000 USD-credits
|
|
846
|
+
// Total: 6000 USD-credits
|
|
847
|
+
expect(totalCredits).toBe(6000);
|
|
848
|
+
|
|
849
|
+
// totalCost = 6000 / 1_000_000 = 0.006 USD
|
|
850
|
+
expect(totalCost).toBeCloseTo(0.006, 6);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it('defaults to USD when currency is not specified', () => {
|
|
854
|
+
const pricing = {
|
|
855
|
+
// No currency field
|
|
856
|
+
units: [
|
|
857
|
+
{ name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
|
|
858
|
+
{ name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
|
|
859
|
+
],
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const usage: ModelTokensUsage = {
|
|
863
|
+
inputTextTokens: 1000,
|
|
864
|
+
outputTextTokens: 500,
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const result = computeChatCost(pricing as any, usage);
|
|
868
|
+
expect(result).toBeDefined();
|
|
869
|
+
|
|
870
|
+
const { totalCost, totalCredits } = result!;
|
|
871
|
+
|
|
872
|
+
// Should be treated as USD (no conversion)
|
|
873
|
+
expect(totalCredits).toBe(6000);
|
|
874
|
+
expect(totalCost).toBeCloseTo(0.006, 6);
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
});
|
|
685
878
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
|
2
|
-
import { CREDITS_PER_DOLLAR } from '@lobechat/const/currency';
|
|
2
|
+
import { CREDITS_PER_DOLLAR, USD_TO_CNY } from '@lobechat/const/currency';
|
|
3
3
|
import debug from 'debug';
|
|
4
4
|
import {
|
|
5
5
|
FixedPricingUnit,
|
|
@@ -17,6 +17,7 @@ const log = debug('lobe-cost:computeChatPricing');
|
|
|
17
17
|
export interface PricingUnitBreakdown {
|
|
18
18
|
cost: number;
|
|
19
19
|
credits: number;
|
|
20
|
+
currency: string | 'USD' | 'CNY';
|
|
20
21
|
/**
|
|
21
22
|
* For lookup strategies we expose the resolved key.
|
|
22
23
|
*/
|
|
@@ -39,6 +40,11 @@ export interface ComputeChatCostOptions {
|
|
|
39
40
|
* Input parameters used by lookup strategies (e.g. ttl, thinkingMode).
|
|
40
41
|
*/
|
|
41
42
|
lookupParams?: Record<string, string | number | boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Exchange rate for CNY to USD conversion. Defaults to USD_TO_CNY constant.
|
|
45
|
+
* Useful for testing with fixed exchange rates.
|
|
46
|
+
*/
|
|
47
|
+
usdToCnyRate?: number;
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export interface PricingComputationResult {
|
|
@@ -98,6 +104,27 @@ const UNIT_QUANTITY_RESOLVERS: Partial<Record<PricingUnitName, UnitQuantityResol
|
|
|
98
104
|
audioOutput: (usage) => usage.outputAudioTokens,
|
|
99
105
|
};
|
|
100
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Convert currency-specific credits to USD credits and ceil to integer
|
|
109
|
+
* @param credits - Credits in the original currency
|
|
110
|
+
* @param currency - The currency of the credits ('USD' or 'CNY')
|
|
111
|
+
* @param usdToCnyRate - Exchange rate for CNY to USD conversion (defaults to USD_TO_CNY constant)
|
|
112
|
+
* @returns USD-equivalent credits (ceiled to integer)
|
|
113
|
+
*/
|
|
114
|
+
const toUSDCredits = (
|
|
115
|
+
credits: number,
|
|
116
|
+
currency: string = 'USD',
|
|
117
|
+
usdToCnyRate = USD_TO_CNY,
|
|
118
|
+
): number => {
|
|
119
|
+
const usdCredits = currency === 'CNY' ? credits / usdToCnyRate : credits;
|
|
120
|
+
return Math.ceil(usdCredits);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Convert credits to USD dollar amount
|
|
125
|
+
* @param credits - USD credits
|
|
126
|
+
* @returns USD dollar amount
|
|
127
|
+
*/
|
|
101
128
|
const creditsToUSD = (credits: number) => credits / CREDITS_PER_DOLLAR;
|
|
102
129
|
|
|
103
130
|
/**
|
|
@@ -221,6 +248,8 @@ export const computeChatCost = (
|
|
|
221
248
|
|
|
222
249
|
const breakdown: PricingUnitBreakdown[] = [];
|
|
223
250
|
const issues: PricingComputationIssue[] = [];
|
|
251
|
+
const currency = pricing.currency || 'USD';
|
|
252
|
+
const usdToCnyRate = options?.usdToCnyRate ?? USD_TO_CNY;
|
|
224
253
|
|
|
225
254
|
for (const unit of pricing.units) {
|
|
226
255
|
const quantity = resolveQuantity(unit, usage);
|
|
@@ -231,11 +260,13 @@ export const computeChatCost = (
|
|
|
231
260
|
throw new Error(`Unsupported chat pricing unit: ${unit.unit}`);
|
|
232
261
|
|
|
233
262
|
const fixedUnit = unit as FixedPricingUnit;
|
|
234
|
-
const
|
|
263
|
+
const rawCredits = computeFixedCredits(fixedUnit, quantity);
|
|
264
|
+
const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
|
|
235
265
|
breakdown.push({
|
|
236
|
-
cost: creditsToUSD(
|
|
237
|
-
credits,
|
|
266
|
+
cost: creditsToUSD(usdCredits),
|
|
267
|
+
credits: usdCredits,
|
|
238
268
|
quantity,
|
|
269
|
+
currency,
|
|
239
270
|
unit,
|
|
240
271
|
});
|
|
241
272
|
continue;
|
|
@@ -243,11 +274,13 @@ export const computeChatCost = (
|
|
|
243
274
|
|
|
244
275
|
if (unit.strategy === 'tiered') {
|
|
245
276
|
const tieredUnit = unit as TieredPricingUnit;
|
|
246
|
-
const { credits, segments } = computeTieredCredits(tieredUnit, quantity);
|
|
277
|
+
const { credits: rawCredits, segments } = computeTieredCredits(tieredUnit, quantity);
|
|
278
|
+
const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
|
|
247
279
|
breakdown.push({
|
|
248
|
-
cost: creditsToUSD(
|
|
249
|
-
credits,
|
|
280
|
+
cost: creditsToUSD(usdCredits),
|
|
281
|
+
credits: usdCredits,
|
|
250
282
|
quantity,
|
|
283
|
+
currency,
|
|
251
284
|
segments,
|
|
252
285
|
unit,
|
|
253
286
|
});
|
|
@@ -257,18 +290,20 @@ export const computeChatCost = (
|
|
|
257
290
|
if (unit.strategy === 'lookup') {
|
|
258
291
|
const lookupUnit = unit as LookupPricingUnit;
|
|
259
292
|
const {
|
|
260
|
-
credits,
|
|
293
|
+
credits: rawCredits,
|
|
261
294
|
key,
|
|
262
295
|
issues: lookupIssue,
|
|
263
296
|
} = computeLookupCredits(lookupUnit, quantity, options);
|
|
264
297
|
|
|
265
298
|
if (lookupIssue) issues.push(lookupIssue);
|
|
266
299
|
|
|
300
|
+
const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
|
|
267
301
|
breakdown.push({
|
|
268
|
-
cost: creditsToUSD(
|
|
269
|
-
credits,
|
|
302
|
+
cost: creditsToUSD(usdCredits),
|
|
303
|
+
credits: usdCredits,
|
|
270
304
|
lookupKey: key,
|
|
271
305
|
quantity,
|
|
306
|
+
currency,
|
|
272
307
|
unit,
|
|
273
308
|
});
|
|
274
309
|
continue;
|
|
@@ -277,9 +312,10 @@ export const computeChatCost = (
|
|
|
277
312
|
issues.push({ reason: 'Unsupported pricing strategy', unit });
|
|
278
313
|
}
|
|
279
314
|
|
|
315
|
+
// Sum up USD credits from all breakdown items
|
|
280
316
|
const rawTotalCredits = breakdown.reduce((sum, item) => sum + item.credits, 0);
|
|
281
317
|
const totalCredits = Math.ceil(rawTotalCredits);
|
|
282
|
-
// !: totalCredits has been uniformly rounded up to integer credits, divided by CREDITS_PER_DOLLAR naturally retains only 6 decimal places, no additional processing needed
|
|
318
|
+
// !: totalCredits has been uniformly rounded up to integer USD credits, divided by CREDITS_PER_DOLLAR naturally retains only 6 decimal places, no additional processing needed
|
|
283
319
|
const totalCost = creditsToUSD(totalCredits);
|
|
284
320
|
|
|
285
321
|
log(`computeChatPricing breakdown: ${JSON.stringify(breakdown, null, 2)}`);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
describe('Desktop Routes', () => {
|
|
6
|
+
const appRootDir = resolve(__dirname, '..');
|
|
7
|
+
|
|
8
|
+
const desktopRoutes = [
|
|
9
|
+
'(backend)/trpc/desktop/[trpc]/route.ts',
|
|
10
|
+
'desktop/devtools/page.tsx',
|
|
11
|
+
'desktop/layout.tsx',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it.each(desktopRoutes)('should have file: %s', (route) => {
|
|
15
|
+
const filePath = resolve(appRootDir, route);
|
|
16
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -4,7 +4,7 @@ import { createStyles } from 'antd-style';
|
|
|
4
4
|
import { isNull } from 'lodash-es';
|
|
5
5
|
import { FileBoxIcon } from 'lucide-react';
|
|
6
6
|
import { useRouter } from 'next/navigation';
|
|
7
|
-
import { memo, useEffect, useState } from 'react';
|
|
7
|
+
import { memo, useEffect, useRef, useState } from 'react';
|
|
8
8
|
import { useTranslation } from 'react-i18next';
|
|
9
9
|
import { Flexbox } from 'react-layout-kit';
|
|
10
10
|
|
|
@@ -304,9 +304,37 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
|
|
|
304
304
|
const isImage = fileType && IMAGE_TYPES.has(fileType);
|
|
305
305
|
const isMarkdown = isMarkdownFile(name, fileType);
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
308
|
+
const [isInView, setIsInView] = useState(false);
|
|
309
|
+
|
|
310
|
+
// Use Intersection Observer to detect when card enters viewport
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (!cardRef.current) return;
|
|
313
|
+
|
|
314
|
+
const observer = new IntersectionObserver(
|
|
315
|
+
(entries) => {
|
|
316
|
+
entries.forEach((entry) => {
|
|
317
|
+
if (entry.isIntersecting && !isInView) {
|
|
318
|
+
setIsInView(true);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
rootMargin: '50px', // Start loading slightly before entering viewport
|
|
324
|
+
threshold: 0.1,
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
observer.observe(cardRef.current);
|
|
329
|
+
|
|
330
|
+
return () => {
|
|
331
|
+
observer.disconnect();
|
|
332
|
+
};
|
|
333
|
+
}, [isInView]);
|
|
334
|
+
|
|
335
|
+
// Fetch markdown content only when in viewport
|
|
308
336
|
useEffect(() => {
|
|
309
|
-
if (isMarkdown && url) {
|
|
337
|
+
if (isMarkdown && url && isInView && !markdownContent) {
|
|
310
338
|
setIsLoadingMarkdown(true);
|
|
311
339
|
fetch(url)
|
|
312
340
|
.then((res) => res.text())
|
|
@@ -323,10 +351,10 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
|
|
|
323
351
|
setIsLoadingMarkdown(false);
|
|
324
352
|
});
|
|
325
353
|
}
|
|
326
|
-
}, [isMarkdown, url]);
|
|
354
|
+
}, [isMarkdown, url, isInView, markdownContent]);
|
|
327
355
|
|
|
328
356
|
return (
|
|
329
|
-
<div className={cx(styles.card, selected && styles.selected)}>
|
|
357
|
+
<div className={cx(styles.card, selected && styles.selected)} ref={cardRef}>
|
|
330
358
|
<div
|
|
331
359
|
className={cx('checkbox', styles.checkbox)}
|
|
332
360
|
onClick={(e) => {
|
|
@@ -355,26 +383,29 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
|
|
|
355
383
|
<FileIcon fileName={name} fileType={fileType} size={64} />
|
|
356
384
|
</div>
|
|
357
385
|
)}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
386
|
+
{isInView && (
|
|
387
|
+
<Image
|
|
388
|
+
alt={name}
|
|
389
|
+
loading="lazy"
|
|
390
|
+
onError={() => setImageLoaded(false)}
|
|
391
|
+
onLoad={() => setImageLoaded(true)}
|
|
392
|
+
preview={{
|
|
393
|
+
src: url,
|
|
394
|
+
}}
|
|
395
|
+
src={url}
|
|
396
|
+
style={{
|
|
397
|
+
display: 'block',
|
|
398
|
+
height: 'auto',
|
|
399
|
+
opacity: imageLoaded ? 1 : 0,
|
|
400
|
+
transition: 'opacity 0.3s',
|
|
401
|
+
width: '100%',
|
|
402
|
+
}}
|
|
403
|
+
wrapperStyle={{
|
|
404
|
+
display: 'block',
|
|
405
|
+
width: '100%',
|
|
406
|
+
}}
|
|
407
|
+
/>
|
|
408
|
+
)}
|
|
378
409
|
{/* Hover overlay */}
|
|
379
410
|
<div className={styles.hoverOverlay}>
|
|
380
411
|
<div className={styles.overlayTitle}>{name}</div>
|