@lobehub/chat 1.133.1 → 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.
- package/.cursor/rules/project-introduce.mdc +19 -25
- package/.cursor/rules/project-structure.mdc +102 -221
- package/.cursor/rules/{rules-attach.mdc → rules-index.mdc} +2 -11
- package/.cursor/rules/typescript.mdc +3 -53
- package/.vscode/settings.json +2 -1
- package/AGENTS.md +33 -54
- package/CHANGELOG.md +58 -0
- package/CLAUDE.md +1 -26
- package/changelog/v1.json +21 -0
- package/locales/ar/chat.json +5 -0
- package/locales/ar/image.json +7 -0
- package/locales/ar/models.json +2 -2
- package/locales/bg-BG/chat.json +5 -0
- package/locales/bg-BG/image.json +7 -0
- package/locales/de-DE/chat.json +5 -0
- package/locales/de-DE/image.json +7 -0
- package/locales/en-US/chat.json +5 -0
- package/locales/en-US/image.json +7 -0
- package/locales/es-ES/chat.json +5 -0
- package/locales/es-ES/image.json +7 -0
- package/locales/es-ES/tool.json +1 -1
- package/locales/fa-IR/chat.json +5 -0
- package/locales/fa-IR/image.json +7 -0
- package/locales/fa-IR/models.json +2 -2
- package/locales/fr-FR/chat.json +5 -0
- package/locales/fr-FR/image.json +7 -0
- package/locales/fr-FR/models.json +2 -2
- package/locales/it-IT/chat.json +5 -0
- package/locales/it-IT/image.json +7 -0
- package/locales/ja-JP/chat.json +5 -0
- package/locales/ja-JP/image.json +7 -0
- package/locales/ko-KR/chat.json +5 -0
- package/locales/ko-KR/image.json +7 -0
- package/locales/nl-NL/chat.json +5 -0
- package/locales/nl-NL/image.json +7 -0
- package/locales/pl-PL/chat.json +5 -0
- package/locales/pl-PL/image.json +7 -0
- package/locales/pt-BR/chat.json +5 -0
- package/locales/pt-BR/image.json +7 -0
- package/locales/ru-RU/chat.json +5 -0
- package/locales/ru-RU/image.json +7 -0
- package/locales/ru-RU/tool.json +1 -1
- package/locales/tr-TR/chat.json +5 -0
- package/locales/tr-TR/image.json +7 -0
- package/locales/tr-TR/models.json +2 -2
- package/locales/vi-VN/chat.json +5 -0
- package/locales/vi-VN/image.json +7 -0
- package/locales/zh-CN/chat.json +5 -0
- package/locales/zh-CN/image.json +7 -0
- package/locales/zh-TW/chat.json +5 -0
- package/locales/zh-TW/image.json +7 -0
- package/package.json +4 -5
- package/packages/const/package.json +4 -0
- package/packages/const/src/currency.ts +2 -0
- package/packages/const/src/index.ts +1 -0
- package/packages/model-bank/package.json +2 -1
- package/packages/model-bank/src/aiModels/google.ts +6 -0
- package/packages/model-bank/src/aiModels/openai.ts +6 -22
- package/packages/model-bank/src/standard-parameters/index.ts +56 -46
- package/packages/model-runtime/package.json +1 -0
- package/packages/model-runtime/src/core/RouterRuntime/createRuntime.ts +4 -2
- package/packages/model-runtime/src/core/openaiCompatibleFactory/createImage.ts +12 -2
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +16 -5
- package/packages/model-runtime/src/core/streams/anthropic.ts +25 -36
- package/packages/model-runtime/src/core/streams/google/google-ai.test.ts +1 -1
- package/packages/model-runtime/src/core/streams/google/index.ts +18 -42
- package/packages/model-runtime/src/core/streams/openai/openai.test.ts +7 -10
- package/packages/model-runtime/src/core/streams/openai/openai.ts +14 -11
- package/packages/model-runtime/src/core/streams/openai/responsesStream.ts +11 -5
- package/packages/model-runtime/src/core/streams/protocol.ts +25 -6
- package/packages/model-runtime/src/core/streams/qwen.ts +2 -2
- package/packages/model-runtime/src/core/streams/spark.ts +3 -3
- package/packages/model-runtime/src/core/streams/vertex-ai.test.ts +2 -2
- package/packages/model-runtime/src/core/streams/vertex-ai.ts +14 -23
- package/packages/model-runtime/src/core/usageConverters/anthropic.test.ts +99 -0
- package/packages/model-runtime/src/core/usageConverters/anthropic.ts +73 -0
- package/packages/model-runtime/src/core/usageConverters/google-ai.test.ts +88 -0
- package/packages/model-runtime/src/core/usageConverters/google-ai.ts +55 -0
- package/packages/model-runtime/src/core/usageConverters/index.ts +4 -0
- package/packages/model-runtime/src/core/usageConverters/openai.test.ts +429 -0
- package/packages/model-runtime/src/core/usageConverters/openai.ts +152 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +455 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.ts +293 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.test.ts +47 -0
- package/packages/model-runtime/src/core/usageConverters/utils/computeImageCost.ts +121 -0
- package/packages/model-runtime/src/core/usageConverters/utils/index.ts +11 -0
- package/packages/model-runtime/src/core/usageConverters/utils/withUsageCost.ts +19 -0
- package/packages/model-runtime/src/index.ts +2 -0
- package/packages/model-runtime/src/providers/anthropic/index.ts +48 -1
- package/packages/model-runtime/src/providers/google/createImage.ts +11 -2
- package/packages/model-runtime/src/providers/google/index.ts +8 -1
- package/packages/model-runtime/src/providers/openai/__snapshots__/index.test.ts.snap +7 -0
- package/packages/model-runtime/src/providers/zhipu/index.ts +3 -1
- package/packages/model-runtime/src/types/chat.ts +5 -3
- package/packages/model-runtime/src/types/image.ts +20 -9
- package/packages/model-runtime/src/utils/getModelPricing.ts +36 -0
- package/packages/obervability-otel/package.json +2 -2
- package/packages/ssrf-safe-fetch/index.test.ts +343 -0
- package/packages/ssrf-safe-fetch/index.ts +37 -0
- package/packages/ssrf-safe-fetch/package.json +17 -0
- package/packages/ssrf-safe-fetch/vitest.config.mts +10 -0
- package/packages/types/src/message/base.ts +43 -17
- package/packages/utils/src/client/apiKeyManager.test.ts +70 -0
- package/packages/utils/src/client/apiKeyManager.ts +41 -0
- package/packages/utils/src/client/index.ts +2 -0
- package/packages/utils/src/fetch/fetchSSE.ts +4 -4
- package/packages/utils/src/index.ts +1 -0
- package/packages/utils/src/toolManifest.ts +2 -1
- package/src/app/(backend)/webapi/proxy/route.ts +2 -13
- package/src/app/[variants]/(main)/chat/(workspace)/@conversation/default.tsx +2 -0
- package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatMinimap/index.tsx +335 -0
- package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/TopicPanel.tsx +4 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/QualitySelect.tsx +23 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +9 -0
- package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.test.ts +13 -13
- package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +1 -1
- package/src/features/Conversation/components/ChatItem/index.tsx +56 -2
- package/src/features/Conversation/components/VirtualizedList/VirtuosoContext.ts +88 -0
- package/src/features/Conversation/components/VirtualizedList/index.tsx +15 -1
- package/src/locales/default/chat.ts +5 -0
- package/src/locales/default/image.ts +7 -0
- package/src/server/modules/EdgeConfig/index.ts +1 -1
- package/src/server/routers/async/image.ts +9 -1
- package/src/services/_auth.ts +12 -12
- package/src/services/chat/contextEngineering.ts +2 -3
- package/.cursor/rules/backend-architecture.mdc +0 -176
- package/.cursor/rules/code-review.mdc +0 -58
- package/.cursor/rules/cursor-ux.mdc +0 -32
- package/.cursor/rules/define-database-model.mdc +0 -8
- package/.cursor/rules/system-role.mdc +0 -31
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
|
2
|
+
import { CREDITS_PER_DOLLAR } from '@lobechat/const/currency';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
import {
|
|
5
|
+
FixedPricingUnit,
|
|
6
|
+
LookupPricingUnit,
|
|
7
|
+
Pricing,
|
|
8
|
+
PricingUnit,
|
|
9
|
+
PricingUnitName,
|
|
10
|
+
TieredPricingUnit,
|
|
11
|
+
} from 'model-bank';
|
|
12
|
+
|
|
13
|
+
import { ModelTokensUsage } from '@/types/message';
|
|
14
|
+
|
|
15
|
+
const log = debug('lobe-cost:computeChatPricing');
|
|
16
|
+
|
|
17
|
+
export interface PricingUnitBreakdown {
|
|
18
|
+
cost: number;
|
|
19
|
+
credits: number;
|
|
20
|
+
/**
|
|
21
|
+
* For lookup strategies we expose the resolved key.
|
|
22
|
+
*/
|
|
23
|
+
lookupKey?: string;
|
|
24
|
+
quantity: number;
|
|
25
|
+
/**
|
|
26
|
+
* Extra details for tiered strategies to help consumers render ladders.
|
|
27
|
+
*/
|
|
28
|
+
segments?: Array<{ credits: number; quantity: number; rate: number }>;
|
|
29
|
+
unit: PricingUnit;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PricingComputationIssue {
|
|
33
|
+
reason: string;
|
|
34
|
+
unit: PricingUnit;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ComputeChatCostOptions {
|
|
38
|
+
/**
|
|
39
|
+
* Input parameters used by lookup strategies (e.g. ttl, thinkingMode).
|
|
40
|
+
*/
|
|
41
|
+
lookupParams?: Record<string, string | number | boolean>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PricingComputationResult {
|
|
45
|
+
breakdown: PricingUnitBreakdown[];
|
|
46
|
+
issues: PricingComputationIssue[];
|
|
47
|
+
totalCost: number;
|
|
48
|
+
totalCredits: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type UnitQuantityResolver = (usage: ModelTokensUsage) => number | undefined;
|
|
52
|
+
|
|
53
|
+
const UNIT_QUANTITY_RESOLVERS: Partial<Record<PricingUnitName, UnitQuantityResolver>> = {
|
|
54
|
+
textInput: (usage) => {
|
|
55
|
+
if (usage.inputCacheMissTokens !== undefined) {
|
|
56
|
+
return usage.inputCacheMissTokens;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof usage.inputCachedTokens === 'number' && typeof usage.totalInputTokens === 'number') {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Missing inputCacheMissTokens! You can set it by inputCacheMissTokens = totalInputTokens - inputCachedTokens',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return usage.inputTextTokens ?? usage.totalInputTokens;
|
|
66
|
+
},
|
|
67
|
+
textInput_cacheRead: (usage) => usage.inputCachedTokens,
|
|
68
|
+
textInput_cacheWrite: (usage) => usage.inputWriteCacheTokens,
|
|
69
|
+
// reasoning tokens cost within output tokens
|
|
70
|
+
textOutput: (usage) => {
|
|
71
|
+
const { outputTextTokens, totalOutputTokens, outputReasoningTokens = 0 } = usage;
|
|
72
|
+
const reasoningTokens = outputReasoningTokens;
|
|
73
|
+
|
|
74
|
+
if (typeof outputTextTokens === 'number') {
|
|
75
|
+
return outputTextTokens + reasoningTokens;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (typeof totalOutputTokens === 'number') {
|
|
79
|
+
return totalOutputTokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof usage.outputReasoningTokens === 'number') {
|
|
83
|
+
return usage.outputReasoningTokens;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return undefined;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
imageInput: (usage) => usage.inputImageTokens,
|
|
90
|
+
imageInput_cacheRead: () => undefined,
|
|
91
|
+
imageOutput: (usage) => usage.outputImageTokens,
|
|
92
|
+
|
|
93
|
+
imageGeneration: () => undefined,
|
|
94
|
+
|
|
95
|
+
audioInput: (usage) => usage.inputAudioTokens,
|
|
96
|
+
// TODO: Support this when ModelTokensUsage includes this data
|
|
97
|
+
audioInput_cacheRead: () => undefined,
|
|
98
|
+
audioOutput: (usage) => usage.outputAudioTokens,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const creditsToUSD = (credits: number) => credits / CREDITS_PER_DOLLAR;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Returns raw credits, which will be rounded up uniformly at the final aggregation stage.
|
|
105
|
+
*/
|
|
106
|
+
const computeFixedCredits = (unit: FixedPricingUnit, quantity: number) => quantity * unit.rate;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Google provider uses new pricing for entire input and output when exceeding threshold, not tiered calculation
|
|
110
|
+
* TODO: Some providers do use tiered calculation, such as Zhipu
|
|
111
|
+
*/
|
|
112
|
+
const computeTieredCredits = (
|
|
113
|
+
unit: TieredPricingUnit,
|
|
114
|
+
quantity: number,
|
|
115
|
+
): { credits: number; segments: Array<{ credits: number; quantity: number; rate: number }> } => {
|
|
116
|
+
if (quantity <= 0) return { credits: 0, segments: [] };
|
|
117
|
+
|
|
118
|
+
const segments: Array<{ credits: number; quantity: number; rate: number }> = [];
|
|
119
|
+
const tiers = unit.tiers ?? [];
|
|
120
|
+
if (tiers.length === 0) return { credits: 0, segments };
|
|
121
|
+
|
|
122
|
+
// Google and other providers charge the entire quantity at the new rate when exceeding threshold
|
|
123
|
+
const matchedTier =
|
|
124
|
+
tiers.find((tier) => {
|
|
125
|
+
const limit = tier.upTo === 'infinity' ? Number.POSITIVE_INFINITY : tier.upTo;
|
|
126
|
+
return quantity <= limit;
|
|
127
|
+
}) ?? tiers.at(-1);
|
|
128
|
+
|
|
129
|
+
if (!matchedTier) return { credits: 0, segments };
|
|
130
|
+
|
|
131
|
+
const credits = quantity * matchedTier.rate;
|
|
132
|
+
segments.push({ credits, quantity, rate: matchedTier.rate });
|
|
133
|
+
|
|
134
|
+
return { credits, segments };
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const resolveLookupKey = (
|
|
138
|
+
unit: LookupPricingUnit,
|
|
139
|
+
options: ComputeChatCostOptions | undefined,
|
|
140
|
+
): { key?: string; missingParams?: string[] } => {
|
|
141
|
+
if (!unit.lookup?.pricingParams?.length) return { key: undefined };
|
|
142
|
+
|
|
143
|
+
const missingParams: string[] = [];
|
|
144
|
+
const params = unit.lookup.pricingParams.map((param) => {
|
|
145
|
+
const source = options?.lookupParams?.[param];
|
|
146
|
+
if (source === undefined || source === null) {
|
|
147
|
+
missingParams.push(param);
|
|
148
|
+
return 'undefined';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (typeof source === 'boolean') return String(source);
|
|
152
|
+
return String(source);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (missingParams.length > 0) return { key: undefined, missingParams };
|
|
156
|
+
|
|
157
|
+
return { key: params.join('_') };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const computeLookupCredits = (
|
|
161
|
+
unit: LookupPricingUnit,
|
|
162
|
+
quantity: number,
|
|
163
|
+
options: ComputeChatCostOptions | undefined,
|
|
164
|
+
): { credits: number; issues?: PricingComputationIssue; key?: string } => {
|
|
165
|
+
const { key, missingParams } = resolveLookupKey(unit, options);
|
|
166
|
+
|
|
167
|
+
if (missingParams && missingParams.length > 0) {
|
|
168
|
+
return {
|
|
169
|
+
credits: 0,
|
|
170
|
+
issues: {
|
|
171
|
+
reason: `Missing lookup params: ${missingParams.join(', ')}`,
|
|
172
|
+
unit,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!key) {
|
|
178
|
+
return {
|
|
179
|
+
credits: 0,
|
|
180
|
+
issues: {
|
|
181
|
+
reason: 'Lookup key could not be resolved',
|
|
182
|
+
unit,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const lookupRate = unit.lookup.prices?.[key];
|
|
188
|
+
if (typeof lookupRate !== 'number') {
|
|
189
|
+
return {
|
|
190
|
+
credits: 0,
|
|
191
|
+
issues: {
|
|
192
|
+
reason: `Lookup price not found for key "${key}"`,
|
|
193
|
+
unit,
|
|
194
|
+
},
|
|
195
|
+
key,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
credits: quantity * lookupRate,
|
|
201
|
+
key,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const resolveQuantity = (unit: PricingUnit, usage: ModelTokensUsage) => {
|
|
206
|
+
const resolver = UNIT_QUANTITY_RESOLVERS[unit.name as PricingUnitName];
|
|
207
|
+
const quantity = resolver?.(usage);
|
|
208
|
+
return typeof quantity === 'number' ? quantity : undefined;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 1. Keep raw credits for each item (may be decimal)
|
|
213
|
+
* 2. Round up uniformly at the totals stage to prevent cost undercounting
|
|
214
|
+
*/
|
|
215
|
+
export const computeChatCost = (
|
|
216
|
+
pricing: Pricing | undefined,
|
|
217
|
+
usage: ModelTokensUsage,
|
|
218
|
+
options?: ComputeChatCostOptions,
|
|
219
|
+
): PricingComputationResult | undefined => {
|
|
220
|
+
if (!pricing) return undefined;
|
|
221
|
+
|
|
222
|
+
const breakdown: PricingUnitBreakdown[] = [];
|
|
223
|
+
const issues: PricingComputationIssue[] = [];
|
|
224
|
+
|
|
225
|
+
for (const unit of pricing.units) {
|
|
226
|
+
const quantity = resolveQuantity(unit, usage);
|
|
227
|
+
if (quantity === undefined) continue;
|
|
228
|
+
|
|
229
|
+
if (unit.strategy === 'fixed') {
|
|
230
|
+
if (unit.unit !== 'millionTokens')
|
|
231
|
+
throw new Error(`Unsupported chat pricing unit: ${unit.unit}`);
|
|
232
|
+
|
|
233
|
+
const fixedUnit = unit as FixedPricingUnit;
|
|
234
|
+
const credits = computeFixedCredits(fixedUnit, quantity);
|
|
235
|
+
breakdown.push({
|
|
236
|
+
cost: creditsToUSD(credits),
|
|
237
|
+
credits,
|
|
238
|
+
quantity,
|
|
239
|
+
unit,
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (unit.strategy === 'tiered') {
|
|
245
|
+
const tieredUnit = unit as TieredPricingUnit;
|
|
246
|
+
const { credits, segments } = computeTieredCredits(tieredUnit, quantity);
|
|
247
|
+
breakdown.push({
|
|
248
|
+
cost: creditsToUSD(credits),
|
|
249
|
+
credits,
|
|
250
|
+
quantity,
|
|
251
|
+
segments,
|
|
252
|
+
unit,
|
|
253
|
+
});
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (unit.strategy === 'lookup') {
|
|
258
|
+
const lookupUnit = unit as LookupPricingUnit;
|
|
259
|
+
const {
|
|
260
|
+
credits,
|
|
261
|
+
key,
|
|
262
|
+
issues: lookupIssue,
|
|
263
|
+
} = computeLookupCredits(lookupUnit, quantity, options);
|
|
264
|
+
|
|
265
|
+
if (lookupIssue) issues.push(lookupIssue);
|
|
266
|
+
|
|
267
|
+
breakdown.push({
|
|
268
|
+
cost: creditsToUSD(credits),
|
|
269
|
+
credits,
|
|
270
|
+
lookupKey: key,
|
|
271
|
+
quantity,
|
|
272
|
+
unit,
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
issues.push({ reason: 'Unsupported pricing strategy', unit });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rawTotalCredits = breakdown.reduce((sum, item) => sum + item.credits, 0);
|
|
281
|
+
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
|
|
283
|
+
const totalCost = creditsToUSD(totalCredits);
|
|
284
|
+
|
|
285
|
+
log(`computeChatPricing breakdown: ${JSON.stringify(breakdown, null, 2)}`);
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
breakdown,
|
|
289
|
+
issues,
|
|
290
|
+
totalCost,
|
|
291
|
+
totalCredits,
|
|
292
|
+
};
|
|
293
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Pricing } from 'model-bank';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { ImageGenerationParams, computeImageCost } from './computeImageCost';
|
|
5
|
+
|
|
6
|
+
describe('computeImageCost', () => {
|
|
7
|
+
it('should compute dall-e-3 lookup pricing correctly', () => {
|
|
8
|
+
// Arrange - Based on actual production logs
|
|
9
|
+
const pricing: Pricing = {
|
|
10
|
+
units: [
|
|
11
|
+
{
|
|
12
|
+
name: 'imageGeneration',
|
|
13
|
+
strategy: 'lookup',
|
|
14
|
+
unit: 'image',
|
|
15
|
+
lookup: {
|
|
16
|
+
pricingParams: ['quality', 'size'],
|
|
17
|
+
prices: {
|
|
18
|
+
standard_1024x1024: 0.04,
|
|
19
|
+
standard_1024x1792: 0.08,
|
|
20
|
+
standard_1792x1024: 0.08,
|
|
21
|
+
hd_1024x1024: 0.08,
|
|
22
|
+
hd_1024x1792: 0.12,
|
|
23
|
+
hd_1792x1024: 0.12,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const params: ImageGenerationParams = {
|
|
31
|
+
quality: 'standard',
|
|
32
|
+
size: '1024x1024',
|
|
33
|
+
prompt: '一条边牧',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Act
|
|
37
|
+
const result = computeImageCost(pricing, params, 1);
|
|
38
|
+
|
|
39
|
+
// Assert - Match the production log output
|
|
40
|
+
expect(result).toBeDefined();
|
|
41
|
+
expect(result?.totalCost).toBe(0.04);
|
|
42
|
+
expect(result?.totalCredits).toBe(40000); // $0.04 * 100000 credits per dollar
|
|
43
|
+
expect(result?.breakdown?.lookupKey).toBe('standard_1024x1024');
|
|
44
|
+
expect(result?.breakdown?.pricePerImage).toBe(0.04);
|
|
45
|
+
expect(result?.breakdown?.imageCount).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { CREDITS_PER_DOLLAR } from '@lobechat/const/currency';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
import { FixedPricingUnit, LookupPricingUnit, Pricing } from 'model-bank';
|
|
4
|
+
|
|
5
|
+
const log = debug('lobe-cost:computeImagePricing');
|
|
6
|
+
|
|
7
|
+
export interface ImageGenerationParams {
|
|
8
|
+
// Other possible parameters for future extensions
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
quality?: 'standard' | 'hd';
|
|
11
|
+
size?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ImageCostResult {
|
|
15
|
+
breakdown?: {
|
|
16
|
+
imageCount: number;
|
|
17
|
+
lookupKey?: string;
|
|
18
|
+
pricePerImage: number; // Price per image in USD
|
|
19
|
+
};
|
|
20
|
+
totalCost: number; // Total cost in USD
|
|
21
|
+
totalCredits: number; // Total credits (USD * CREDITS_PER_DOLLAR)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compute the cost for image generation based on pricing configuration
|
|
26
|
+
* @param pricing - The pricing configuration for the model
|
|
27
|
+
* @param params - Image generation parameters (quality, size, etc.)
|
|
28
|
+
* @param imageNum - Number of images to generate
|
|
29
|
+
* @returns ImageCostResult with total cost in USD and credits, or undefined if pricing not found
|
|
30
|
+
*/
|
|
31
|
+
export const computeImageCost = (
|
|
32
|
+
pricing: Pricing,
|
|
33
|
+
params: ImageGenerationParams,
|
|
34
|
+
imageNum: number,
|
|
35
|
+
): ImageCostResult | undefined => {
|
|
36
|
+
// Find imageGeneration pricing unit
|
|
37
|
+
const imageGenUnit = pricing.units.find((unit) => unit.name === 'imageGeneration');
|
|
38
|
+
if (!imageGenUnit) {
|
|
39
|
+
log('No imageGeneration unit found in pricing configuration');
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let pricePerImageInUSD = 0;
|
|
44
|
+
let lookupKey: string | undefined;
|
|
45
|
+
|
|
46
|
+
switch (imageGenUnit.strategy) {
|
|
47
|
+
case 'fixed': {
|
|
48
|
+
const fixedUnit = imageGenUnit as FixedPricingUnit;
|
|
49
|
+
if (fixedUnit.unit !== 'image') {
|
|
50
|
+
log(`Unsupported unit type for fixed pricing: ${fixedUnit.unit}`);
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
pricePerImageInUSD = fixedUnit.rate;
|
|
54
|
+
log(`Fixed pricing: $${pricePerImageInUSD} per image`);
|
|
55
|
+
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'lookup': {
|
|
59
|
+
const lookupUnit = imageGenUnit as LookupPricingUnit;
|
|
60
|
+
|
|
61
|
+
// Build lookup key from params
|
|
62
|
+
const lookupParams: string[] = [];
|
|
63
|
+
|
|
64
|
+
// Check required pricing params
|
|
65
|
+
if (lookupUnit.lookup?.pricingParams) {
|
|
66
|
+
for (const paramName of lookupUnit.lookup.pricingParams) {
|
|
67
|
+
const paramValue = params[paramName];
|
|
68
|
+
if (paramValue === undefined || paramValue === null) {
|
|
69
|
+
log(`Missing required lookup param: ${paramName}`);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
lookupParams.push(String(paramValue));
|
|
73
|
+
}
|
|
74
|
+
lookupKey = lookupParams.join('_');
|
|
75
|
+
} else {
|
|
76
|
+
log('No pricing params defined for lookup strategy');
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Find price for the lookup key
|
|
81
|
+
const lookupPrice = lookupUnit.lookup?.prices?.[lookupKey];
|
|
82
|
+
if (typeof lookupPrice !== 'number') {
|
|
83
|
+
log(`No price found for lookup key: ${lookupKey}`);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pricePerImageInUSD = lookupPrice;
|
|
88
|
+
log(`Lookup pricing for key "${lookupKey}": $${pricePerImageInUSD} per image`);
|
|
89
|
+
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case 'tiered': {
|
|
93
|
+
// TODO: Implement tiered pricing when needed
|
|
94
|
+
log('Tiered pricing strategy not yet implemented for image generation');
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
default: {
|
|
98
|
+
// @ts-expect-error - PricingUnit strategy may have unsupported values
|
|
99
|
+
log(`Unsupported pricing strategy: ${imageGenUnit.strategy}`);
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Calculate total cost in USD first, then convert to credits
|
|
105
|
+
const totalCost = pricePerImageInUSD * imageNum;
|
|
106
|
+
const totalCredits = Math.ceil(totalCost * CREDITS_PER_DOLLAR);
|
|
107
|
+
|
|
108
|
+
log(
|
|
109
|
+
`Image cost calculation: ${imageNum} images × $${pricePerImageInUSD} = $${totalCost} (${totalCredits} credits)`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
breakdown: {
|
|
114
|
+
imageCount: imageNum,
|
|
115
|
+
lookupKey,
|
|
116
|
+
pricePerImage: pricePerImageInUSD,
|
|
117
|
+
},
|
|
118
|
+
totalCost,
|
|
119
|
+
totalCredits,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export {
|
|
2
|
+
computeChatCost,
|
|
3
|
+
type ComputeChatCostOptions,
|
|
4
|
+
type PricingComputationResult,
|
|
5
|
+
} from './computeChatCost';
|
|
6
|
+
export {
|
|
7
|
+
computeImageCost,
|
|
8
|
+
type ImageCostResult,
|
|
9
|
+
type ImageGenerationParams,
|
|
10
|
+
} from './computeImageCost';
|
|
11
|
+
export { withUsageCost } from './withUsageCost';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Pricing } from 'model-bank';
|
|
2
|
+
|
|
3
|
+
import type { ModelUsage } from '@/types/message';
|
|
4
|
+
|
|
5
|
+
import { computeChatCost } from './computeChatCost';
|
|
6
|
+
import type { ComputeChatCostOptions } from './computeChatCost';
|
|
7
|
+
|
|
8
|
+
export const withUsageCost = (
|
|
9
|
+
usage: ModelUsage,
|
|
10
|
+
pricing?: Pricing,
|
|
11
|
+
options?: ComputeChatCostOptions,
|
|
12
|
+
): ModelUsage => {
|
|
13
|
+
if (!pricing) return usage;
|
|
14
|
+
|
|
15
|
+
const pricingResult = computeChatCost(pricing, usage, options);
|
|
16
|
+
if (!pricingResult) return usage;
|
|
17
|
+
|
|
18
|
+
return { ...usage, cost: pricingResult.totalCost };
|
|
19
|
+
};
|
|
@@ -2,6 +2,7 @@ export * from './core/BaseAI';
|
|
|
2
2
|
export { ModelRuntime } from './core/ModelRuntime';
|
|
3
3
|
export { createOpenAICompatibleRuntime } from './core/openaiCompatibleFactory';
|
|
4
4
|
export * from './core/RouterRuntime';
|
|
5
|
+
export * from './core/usageConverters';
|
|
5
6
|
export * from './helpers';
|
|
6
7
|
export { LobeAkashChatAI } from './providers/akashchat';
|
|
7
8
|
export { LobeAnthropicAI } from './providers/anthropic';
|
|
@@ -33,5 +34,6 @@ export * from './types';
|
|
|
33
34
|
export * from './types/error';
|
|
34
35
|
export { AgentRuntimeError } from './utils/createError';
|
|
35
36
|
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
|
|
37
|
+
export { getModelPricing } from './utils/getModelPricing';
|
|
36
38
|
export { pruneReasoningPayload } from './utils/openaiHelpers';
|
|
37
39
|
export { parseDataUri } from './utils/uriParser';
|
|
@@ -15,6 +15,7 @@ import { buildAnthropicMessages, buildAnthropicTools } from '../../utils/anthrop
|
|
|
15
15
|
import { AgentRuntimeError } from '../../utils/createError';
|
|
16
16
|
import { debugStream } from '../../utils/debugStream';
|
|
17
17
|
import { desensitizeUrl } from '../../utils/desensitizeUrl';
|
|
18
|
+
import { getModelPricing } from '../../utils/getModelPricing';
|
|
18
19
|
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
|
|
19
20
|
import { StreamingResponse } from '../../utils/response';
|
|
20
21
|
import { createAnthropicGenerateObject } from './generateObject';
|
|
@@ -38,6 +39,44 @@ const modelsWithTempAndTopPConflict = new Set([
|
|
|
38
39
|
]);
|
|
39
40
|
|
|
40
41
|
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
|
|
42
|
+
const DEFAULT_CACHE_TTL = '5m' as const;
|
|
43
|
+
|
|
44
|
+
type CacheTTL = Anthropic.Messages.CacheControlEphemeral['ttl'];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves cache TTL from Anthropic payload or request settings
|
|
48
|
+
* Returns the first valid TTL found in system messages or content blocks
|
|
49
|
+
*/
|
|
50
|
+
const resolveCacheTTL = (
|
|
51
|
+
requestPayload: ChatStreamPayload,
|
|
52
|
+
anthropicPayload: Anthropic.MessageCreateParams,
|
|
53
|
+
): CacheTTL | undefined => {
|
|
54
|
+
// Check system messages for cache TTL
|
|
55
|
+
if (Array.isArray(anthropicPayload.system)) {
|
|
56
|
+
for (const block of anthropicPayload.system) {
|
|
57
|
+
const ttl = block.cache_control?.ttl;
|
|
58
|
+
if (ttl) return ttl;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check message content blocks for cache TTL
|
|
63
|
+
for (const message of anthropicPayload.messages ?? []) {
|
|
64
|
+
if (!Array.isArray(message.content)) continue;
|
|
65
|
+
|
|
66
|
+
for (const block of message.content) {
|
|
67
|
+
// Message content blocks might have cache_control property
|
|
68
|
+
const ttl = ('cache_control' in block && block.cache_control?.ttl) as CacheTTL | undefined;
|
|
69
|
+
if (ttl) return ttl;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Use default TTL if context caching is enabled
|
|
74
|
+
if (requestPayload.enabledContextCaching) {
|
|
75
|
+
return DEFAULT_CACHE_TTL;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return undefined;
|
|
79
|
+
};
|
|
41
80
|
|
|
42
81
|
interface AnthropicAIParams extends ClientOptions {
|
|
43
82
|
id?: string;
|
|
@@ -103,8 +142,16 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
|
|
|
103
142
|
debugStream(debug.toReadableStream()).catch(console.error);
|
|
104
143
|
}
|
|
105
144
|
|
|
145
|
+
const pricing = await getModelPricing(payload.model, this.id);
|
|
146
|
+
const cacheTTL = resolveCacheTTL(payload, anthropicPayload);
|
|
147
|
+
const pricingOptions = cacheTTL ? { lookupParams: { ttl: cacheTTL } } : undefined;
|
|
148
|
+
|
|
106
149
|
return StreamingResponse(
|
|
107
|
-
AnthropicStream(prod, {
|
|
150
|
+
AnthropicStream(prod, {
|
|
151
|
+
callbacks: options?.callback,
|
|
152
|
+
inputStartAt,
|
|
153
|
+
payload: { model: payload.model, pricing, pricingOptions, provider: this.id },
|
|
154
|
+
}),
|
|
108
155
|
{
|
|
109
156
|
headers: options?.headers,
|
|
110
157
|
},
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Content, GoogleGenAI, Part } from '@google/genai';
|
|
2
2
|
|
|
3
|
+
import { convertGoogleAIUsage } from '../../core/usageConverters/google-ai';
|
|
3
4
|
import { CreateImagePayload, CreateImageResponse } from '../../types/image';
|
|
4
5
|
import { AgentRuntimeError } from '../../utils/createError';
|
|
6
|
+
import { getModelPricing } from '../../utils/getModelPricing';
|
|
5
7
|
import { parseGoogleErrorMessage } from '../../utils/googleErrorParser';
|
|
6
8
|
import { imageUrlToBase64 } from '../../utils/imageToBase64';
|
|
7
9
|
import { parseDataUri } from '../../utils/uriParser';
|
|
@@ -101,6 +103,7 @@ async function generateByImageModel(
|
|
|
101
103
|
async function generateImageByChatModel(
|
|
102
104
|
client: GoogleGenAI,
|
|
103
105
|
payload: CreateImagePayload,
|
|
106
|
+
provider: string,
|
|
104
107
|
): Promise<CreateImageResponse> {
|
|
105
108
|
const { model, params } = payload;
|
|
106
109
|
const actualModel = model.replace(':image', '');
|
|
@@ -146,7 +149,13 @@ async function generateImageByChatModel(
|
|
|
146
149
|
model: actualModel,
|
|
147
150
|
});
|
|
148
151
|
|
|
149
|
-
|
|
152
|
+
const imageResponse = extractImageFromResponse(response);
|
|
153
|
+
if (response.usageMetadata) {
|
|
154
|
+
const pricing = await getModelPricing(model, provider);
|
|
155
|
+
imageResponse.modelUsage = convertGoogleAIUsage(response.usageMetadata, pricing);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return imageResponse;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
/**
|
|
@@ -162,7 +171,7 @@ export async function createGoogleImage(
|
|
|
162
171
|
|
|
163
172
|
// Handle Gemini 2.5 Flash Image models that use generateContent
|
|
164
173
|
if (model.endsWith(':image')) {
|
|
165
|
-
return await generateImageByChatModel(client, payload);
|
|
174
|
+
return await generateImageByChatModel(client, payload, provider);
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
// Handle traditional Imagen models that use generateImages
|
|
@@ -27,6 +27,7 @@ import { AgentRuntimeErrorType } from '../../types/error';
|
|
|
27
27
|
import { CreateImagePayload, CreateImageResponse } from '../../types/image';
|
|
28
28
|
import { AgentRuntimeError } from '../../utils/createError';
|
|
29
29
|
import { debugStream } from '../../utils/debugStream';
|
|
30
|
+
import { getModelPricing } from '../../utils/getModelPricing';
|
|
30
31
|
import { parseGoogleErrorMessage } from '../../utils/googleErrorParser';
|
|
31
32
|
import { imageUrlToBase64 } from '../../utils/imageToBase64';
|
|
32
33
|
import { StreamingResponse } from '../../utils/response';
|
|
@@ -244,8 +245,14 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
// Convert the response into a friendly text-stream
|
|
248
|
+
const pricing = await getModelPricing(model, this.provider);
|
|
249
|
+
|
|
247
250
|
const Stream = this.isVertexAi ? VertexAIStream : GoogleGenerativeAIStream;
|
|
248
|
-
const stream = Stream(prod, {
|
|
251
|
+
const stream = Stream(prod, {
|
|
252
|
+
callbacks: options?.callback,
|
|
253
|
+
inputStartAt,
|
|
254
|
+
payload: { model, pricing, provider: this.provider },
|
|
255
|
+
});
|
|
249
256
|
|
|
250
257
|
// Respond with the stream
|
|
251
258
|
return StreamingResponse(stream, { headers: options?.headers });
|
|
@@ -268,6 +268,13 @@ exports[`LobeOpenAI > models > should get models 1`] = `
|
|
|
268
268
|
"prompt": {
|
|
269
269
|
"default": "",
|
|
270
270
|
},
|
|
271
|
+
"quality": {
|
|
272
|
+
"default": "standard",
|
|
273
|
+
"enum": [
|
|
274
|
+
"standard",
|
|
275
|
+
"hd",
|
|
276
|
+
],
|
|
277
|
+
},
|
|
271
278
|
"size": {
|
|
272
279
|
"default": "1024x1024",
|
|
273
280
|
"enum": [
|