mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-01 02:01:34 +08:00
V4.14.10 dev (#6674)
* feat: model config with brand-new price calculate machanism (#6616) * fix: image read and json error (Agent) (#6502) * fix: 1.image read 2.JSON parsing error * dataset cite and pause * perf: plancall second parse * add test --------- Co-authored-by: archer <545436317@qq.com> * master message * remove invalid code * wip: model config * feat: model config with brand-new price calculate machanism * merge main branch * ajust calculate way * ajust priceTiers resolve procession * perf: price config code * fix: default price * fix: test * fix: comment * fix test --------- Co-authored-by: YeYuheng <57035043+YYH211@users.noreply.github.com> Co-authored-by: archer <545436317@qq.com> * wip: fix modal UI (#6634) * wip: fix modal UI * fix: maxInputToken set * chore: add price unit for non llm models * chore: replace question mark icon with beta tag (#6672) * feat:rerank too long; fix:rerank ui(agent),embedding returns 0 (#6663) * feat:rerank too long; fix:rerank ui(agent),embedding returns 0 * rerank * fix:rerank function * perf: rerank code * fix rerank * perf: model price ui --------- Co-authored-by: archer <545436317@qq.com> * remove llmtype field * revert model init * fix: filed * fix: model select filter * perf: multiple selector render * remove invalid checker * remove invalid i18n * perf: model selector tip * perf: model selector tip * fix cr * limit pnpm version * fix: i18n * fix action * set default mintoken * update i18n * perf: usage push * fix:rerank model ui (#6677) * fix: tier match error * fix: testr --------- Co-authored-by: Ryo <whoeverimf5@gmail.com> Co-authored-by: YeYuheng <57035043+YYH211@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
calculateModelPrice,
|
||||
getRuntimeResolvedPriceTiers,
|
||||
sanitizeModelPriceTiers
|
||||
} from '@fastgpt/global/core/ai/pricing';
|
||||
|
||||
describe('sanitizeModelPriceTiers', () => {
|
||||
it('should return empty array for non-array input', () => {
|
||||
// @ts-ignore
|
||||
expect(sanitizeModelPriceTiers(null)).toEqual([]);
|
||||
// @ts-ignore
|
||||
expect(sanitizeModelPriceTiers(undefined)).toEqual([]);
|
||||
// @ts-ignore
|
||||
expect(sanitizeModelPriceTiers('invalid')).toEqual([]);
|
||||
// @ts-ignore
|
||||
expect(sanitizeModelPriceTiers(123)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty array', () => {
|
||||
expect(sanitizeModelPriceTiers([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should always push first tier with minInputTokens: 0 and prices', () => {
|
||||
const result = sanitizeModelPriceTiers([{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 }]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 30, inputPrice: 1, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should push first tier even without prices', () => {
|
||||
// @ts-ignore
|
||||
const result = sanitizeModelPriceTiers([{ maxInputTokens: 10 }]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 0, outputPrice: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should drop incomplete trailing rows without prices', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 30.8, inputPrice: 1, outputPrice: 2 },
|
||||
{
|
||||
maxInputTokens: undefined,
|
||||
// @ts-ignore
|
||||
inputPrice: undefined,
|
||||
// @ts-ignore
|
||||
outputPrice: undefined
|
||||
}
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 30.8, inputPrice: 1, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should include open-ended tier with valid prices', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 20, inputPrice: 2, outputPrice: 2 },
|
||||
{ inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 20, inputPrice: 2, outputPrice: 2 },
|
||||
{ minInputTokens: 20, inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve decimal maxInputTokens for subsequent tiers', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 20.9, inputPrice: 2, outputPrice: 2 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 20.9, inputPrice: 2, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip descending maxInputTokens for subsequent tiers', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 30, inputPrice: 2, outputPrice: 2 },
|
||||
{ maxInputTokens: 15, inputPrice: 3, outputPrice: 3 },
|
||||
{ inputPrice: 4, outputPrice: 4 }
|
||||
]);
|
||||
// 第三个梯度 maxInputTokens:15 <= 上一个有效梯度 30,被跳过
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 30, inputPrice: 2, outputPrice: 2 },
|
||||
{ minInputTokens: 30, inputPrice: 4, outputPrice: 4 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle negative maxInputTokens by converting to 0', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: -10, inputPrice: 1, outputPrice: 2 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 0, inputPrice: 1, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle NaN and Infinity in prices', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
// @ts-ignore
|
||||
{ maxInputTokens: 10, inputPrice: NaN, outputPrice: Infinity }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 0, outputPrice: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle invalid maxInputTokens types', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
// @ts-ignore
|
||||
{ maxInputTokens: 'invalid', inputPrice: 1, outputPrice: 2 },
|
||||
{ maxInputTokens: 20, inputPrice: 3, outputPrice: 4 }
|
||||
]);
|
||||
// 第一个梯度的 maxInputTokens 无效,被视为 undefined,但仍会被添加
|
||||
// 第二个梯度也会被添加,minInputTokens 为 0(因为 last.maxInputTokens ?? 0)
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: undefined, inputPrice: 1, outputPrice: 2 },
|
||||
{ minInputTokens: 0, maxInputTokens: 20, inputPrice: 3, outputPrice: 4 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle equal maxInputTokens (skip non-increasing)', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 10, inputPrice: 2, outputPrice: 2 },
|
||||
{ maxInputTokens: 20, inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
// 第二个梯度 maxInputTokens:10 <= 上一个 10,被跳过
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 20, inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle open-ended tier with only inputPrice', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
// @ts-ignore
|
||||
{ inputPrice: 2 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, inputPrice: 2, outputPrice: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle open-ended tier with only outputPrice', () => {
|
||||
const result = sanitizeModelPriceTiers([
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
// @ts-ignore
|
||||
{ outputPrice: 2 }
|
||||
]);
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, inputPrice: 0, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimeResolvedPriceTiers', () => {
|
||||
it('should resolve ranges from configured tiers', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 20, inputPrice: 2, outputPrice: 2 },
|
||||
{ inputPrice: 3, outputPrice: 3 }
|
||||
]
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 20, inputPrice: 2, outputPrice: 2 },
|
||||
{ minInputTokens: 20, inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return legacy input/output price tier', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 3
|
||||
});
|
||||
expect(result).toEqual([{ minInputTokens: 0, inputPrice: 1.5, outputPrice: 3 }]);
|
||||
});
|
||||
|
||||
it('should return comprehensive price as same input/output price', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
charsPointsPrice: 2
|
||||
});
|
||||
expect(result).toEqual([{ minInputTokens: 0, inputPrice: 2, outputPrice: 2 }]);
|
||||
});
|
||||
|
||||
it('should prioritize priceTiers over legacy fields', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
charsPointsPrice: 10,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6,
|
||||
priceTiers: [{ maxInputTokens: 100, inputPrice: 1, outputPrice: 2 }]
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 100, inputPrice: 1, outputPrice: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip invalid descending tiers when resolving ranges', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ maxInputTokens: 30, inputPrice: 2, outputPrice: 2 },
|
||||
{ maxInputTokens: 15, inputPrice: 99, outputPrice: 99 },
|
||||
{ inputPrice: 3, outputPrice: 3 }
|
||||
]
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ minInputTokens: 0, maxInputTokens: 10, inputPrice: 1, outputPrice: 1 },
|
||||
{ minInputTokens: 10, maxInputTokens: 30, inputPrice: 2, outputPrice: 2 },
|
||||
{ minInputTokens: 30, inputPrice: 3, outputPrice: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return default tier for undefined config', () => {
|
||||
// undefined config 会走 charsPointsPrice 逻辑,返回默认梯度
|
||||
expect(getRuntimeResolvedPriceTiers(undefined)).toEqual([
|
||||
{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return default tier for empty object config', () => {
|
||||
// 空对象 config 会走 charsPointsPrice 逻辑,返回默认梯度
|
||||
expect(getRuntimeResolvedPriceTiers({})).toEqual([
|
||||
{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle inputPrice of 0 (not use legacy mode)', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
inputPrice: 0,
|
||||
outputPrice: 5
|
||||
});
|
||||
// inputPrice 为 0,不满足 hasLegacyIOPrice 条件,走 charsPointsPrice 逻辑
|
||||
expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]);
|
||||
});
|
||||
|
||||
it('should handle charsPointsPrice of 0', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
charsPointsPrice: 0
|
||||
});
|
||||
expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]);
|
||||
});
|
||||
|
||||
it('should handle invalid price types', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
// @ts-ignore
|
||||
inputPrice: 'invalid',
|
||||
// @ts-ignore
|
||||
outputPrice: NaN
|
||||
});
|
||||
expect(result).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]);
|
||||
});
|
||||
|
||||
it('should handle empty priceTiers array', () => {
|
||||
const result = getRuntimeResolvedPriceTiers({
|
||||
priceTiers: []
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateModelPrice', () => {
|
||||
it('should calculate legacy comprehensive price', () => {
|
||||
const { totalPoints, matchedTier } = calculateModelPrice({
|
||||
config: { charsPointsPrice: 2 },
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500
|
||||
});
|
||||
expect(totalPoints).toBe(3);
|
||||
expect(matchedTier?.inputPrice).toBe(2);
|
||||
expect(matchedTier?.outputPrice).toBe(2);
|
||||
});
|
||||
|
||||
it('should keep legacy input/output pricing behavior', () => {
|
||||
const { totalPoints, matchedTier } = calculateModelPrice({
|
||||
config: { charsPointsPrice: 10, inputPrice: 1.5, outputPrice: 3 },
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500
|
||||
});
|
||||
expect(totalPoints).toBe(3);
|
||||
expect(matchedTier?.inputPrice).toBe(1.5);
|
||||
expect(matchedTier?.outputPrice).toBe(3);
|
||||
});
|
||||
|
||||
it('should match price tier by input token range', () => {
|
||||
const config = {
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 },
|
||||
{ maxInputTokens: 60, inputPrice: 3, outputPrice: 4 },
|
||||
{ inputPrice: 5, outputPrice: 6 }
|
||||
]
|
||||
};
|
||||
|
||||
// [0, 30K] → 第一梯度(左闭右闭)
|
||||
expect(calculateModelPrice({ config, inputTokens: 20000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 0,
|
||||
maxInputTokens: 30,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2
|
||||
});
|
||||
expect(calculateModelPrice({ config, inputTokens: 30000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 0,
|
||||
maxInputTokens: 30,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2
|
||||
});
|
||||
// (30K, 60K] → 第二梯度(左开右闭)
|
||||
expect(calculateModelPrice({ config, inputTokens: 30001 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 30,
|
||||
maxInputTokens: 60,
|
||||
inputPrice: 3,
|
||||
outputPrice: 4
|
||||
});
|
||||
expect(calculateModelPrice({ config, inputTokens: 60000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 30,
|
||||
maxInputTokens: 60,
|
||||
inputPrice: 3,
|
||||
outputPrice: 4
|
||||
});
|
||||
// (60K, ∞) → 第三梯度(左开右开)
|
||||
expect(calculateModelPrice({ config, inputTokens: 60001 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 60,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6
|
||||
});
|
||||
expect(calculateModelPrice({ config, inputTokens: 90000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 60,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate price with matched tier prices', () => {
|
||||
const { totalPoints } = calculateModelPrice({
|
||||
config: {
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 },
|
||||
{ maxInputTokens: 60, inputPrice: 3, outputPrice: 4 }
|
||||
]
|
||||
},
|
||||
inputTokens: 50000,
|
||||
outputTokens: 100000
|
||||
});
|
||||
// 50K tokens 匹配第二梯度 (30K, 60K]: 50 * 3 + 100 * 4 = 150 + 400 = 550
|
||||
expect(totalPoints).toBeCloseTo(550);
|
||||
});
|
||||
|
||||
it('should match exact tier boundaries correctly', () => {
|
||||
const config = {
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 },
|
||||
{ maxInputTokens: 60, inputPrice: 3, outputPrice: 4 },
|
||||
{ inputPrice: 5, outputPrice: 6 }
|
||||
]
|
||||
};
|
||||
|
||||
// 29.999K → [0, 30K]
|
||||
expect(calculateModelPrice({ config, inputTokens: 29999 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 0,
|
||||
maxInputTokens: 30,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2
|
||||
});
|
||||
// 30K → [0, 30K] (右闭)
|
||||
expect(calculateModelPrice({ config, inputTokens: 30000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 0,
|
||||
maxInputTokens: 30,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2
|
||||
});
|
||||
// 30.001K → (30K, 60K]
|
||||
expect(calculateModelPrice({ config, inputTokens: 30001 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 30,
|
||||
maxInputTokens: 60,
|
||||
inputPrice: 3,
|
||||
outputPrice: 4
|
||||
});
|
||||
// 60K → (30K, 60K] (右闭)
|
||||
expect(calculateModelPrice({ config, inputTokens: 60000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 30,
|
||||
maxInputTokens: 60,
|
||||
inputPrice: 3,
|
||||
outputPrice: 4
|
||||
});
|
||||
// 60.001K → (60K, ∞)
|
||||
expect(calculateModelPrice({ config, inputTokens: 60001 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 60,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6
|
||||
});
|
||||
expect(calculateModelPrice({ config, inputTokens: 10000000 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 60,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6
|
||||
});
|
||||
});
|
||||
|
||||
it('should fallback to first tier when input tokens are 0', () => {
|
||||
const config = {
|
||||
priceTiers: [{ maxInputTokens: 30, inputPrice: 1, outputPrice: 2 }]
|
||||
};
|
||||
// 单梯度时,0 tokens → 第一梯度
|
||||
expect(calculateModelPrice({ config, inputTokens: 0 }).matchedTier).toMatchObject({
|
||||
minInputTokens: 0,
|
||||
maxInputTokens: 30,
|
||||
inputPrice: 1,
|
||||
outputPrice: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('should prioritize price tiers over legacy fields', () => {
|
||||
const { matchedTier, totalPoints } = calculateModelPrice({
|
||||
config: {
|
||||
charsPointsPrice: 10,
|
||||
inputPrice: 5,
|
||||
outputPrice: 6,
|
||||
priceTiers: [{ maxInputTokens: 100, inputPrice: 1, outputPrice: 2 }]
|
||||
},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 1000
|
||||
});
|
||||
expect(matchedTier?.inputPrice).toBe(1);
|
||||
expect(matchedTier?.outputPrice).toBe(2);
|
||||
expect(totalPoints).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle custom multiple parameter', () => {
|
||||
const { totalPoints } = calculateModelPrice({
|
||||
config: {
|
||||
priceTiers: [{ maxInputTokens: 10, inputPrice: 2, outputPrice: 3 }]
|
||||
},
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2000,
|
||||
multiple: 100
|
||||
});
|
||||
// 5000/100 = 50, 2000/100 = 20
|
||||
// 50 * 2 + 20 * 3 = 100 + 60 = 160
|
||||
expect(totalPoints).toBe(160);
|
||||
});
|
||||
|
||||
it('should handle zero tokens', () => {
|
||||
const { totalPoints, matchedTier } = calculateModelPrice({
|
||||
config: {
|
||||
priceTiers: [{ maxInputTokens: 100, inputPrice: 1, outputPrice: 2 }]
|
||||
},
|
||||
inputTokens: 0,
|
||||
outputTokens: 0
|
||||
});
|
||||
expect(totalPoints).toBe(0);
|
||||
expect(matchedTier).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle undefined config', () => {
|
||||
const { totalPoints, matchedTier, tiers } = calculateModelPrice({
|
||||
config: undefined,
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500
|
||||
});
|
||||
// undefined config 会返回默认梯度
|
||||
expect(totalPoints).toBe(0);
|
||||
expect(matchedTier).toEqual({ minInputTokens: 0, inputPrice: 0, outputPrice: 0 });
|
||||
expect(tiers).toEqual([{ minInputTokens: 0, inputPrice: 0, outputPrice: 0 }]);
|
||||
});
|
||||
|
||||
it('should handle empty config', () => {
|
||||
const { totalPoints, matchedTier, tiers } = calculateModelPrice({
|
||||
config: {},
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500
|
||||
});
|
||||
expect(totalPoints).toBe(0);
|
||||
expect(matchedTier).toBeDefined();
|
||||
expect(tiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle negative tokens gracefully', () => {
|
||||
const { totalPoints } = calculateModelPrice({
|
||||
config: {
|
||||
priceTiers: [{ maxInputTokens: 100, inputPrice: 1, outputPrice: 2 }]
|
||||
},
|
||||
inputTokens: -1000,
|
||||
outputTokens: -500
|
||||
});
|
||||
// 负数 tokens 会导致负价格
|
||||
expect(totalPoints).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle very large token numbers', () => {
|
||||
const { totalPoints, matchedTier } = calculateModelPrice({
|
||||
config: {
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 100, inputPrice: 1, outputPrice: 2 },
|
||||
{ inputPrice: 0.5, outputPrice: 1 }
|
||||
]
|
||||
},
|
||||
inputTokens: 10000000,
|
||||
outputTokens: 5000000
|
||||
});
|
||||
// 10M tokens 匹配第二梯度
|
||||
expect(matchedTier?.minInputTokens).toBe(100);
|
||||
expect(totalPoints).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants';
|
||||
import type { RerankModelItemType } from '@fastgpt/global/core/ai/model.schema';
|
||||
|
||||
// hoisted:让 mock 实例可在 beforeEach 中重设
|
||||
const { mockCountPromptTokens, mockPOST } = vi.hoisted(() => ({
|
||||
mockCountPromptTokens: vi.fn(),
|
||||
mockPOST: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@fastgpt/service/common/string/tiktoken', () => ({
|
||||
countPromptTokens: mockCountPromptTokens
|
||||
}));
|
||||
|
||||
vi.mock('@fastgpt/service/common/api/serverRequest', () => ({
|
||||
POST: (...args: any[]) => mockPOST(...args)
|
||||
}));
|
||||
|
||||
// Mock text2Chunks:按 chunkSize 字符切分,保证测试确定性
|
||||
vi.mock('@fastgpt/service/worker/function', () => ({
|
||||
text2Chunks: vi.fn(async ({ text, chunkSize }: { text: string; chunkSize: number }) => {
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < text.length; i += chunkSize) {
|
||||
chunks.push(text.slice(i, i + chunkSize));
|
||||
}
|
||||
return { chunks };
|
||||
})
|
||||
}));
|
||||
|
||||
// import 放在 mock 之后
|
||||
const { reRankRecall } = await import('@fastgpt/service/core/ai/rerank/index');
|
||||
|
||||
const mockModel: RerankModelItemType = {
|
||||
provider: 'test',
|
||||
model: 'rerank-test',
|
||||
name: 'Test Rerank',
|
||||
type: ModelTypeEnum.rerank,
|
||||
maxToken: 8000
|
||||
};
|
||||
|
||||
describe('reRankRecall', () => {
|
||||
beforeEach(() => {
|
||||
mockPOST.mockReset();
|
||||
mockCountPromptTokens.mockReset();
|
||||
mockCountPromptTokens.mockImplementation(async (text: string) => text.length);
|
||||
});
|
||||
|
||||
// ── 基础场景 ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('正常场景:多文档返回正确 id 和 score', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [
|
||||
{ index: 1, relevance_score: 0.9 },
|
||||
{ index: 0, relevance_score: 0.5 }
|
||||
],
|
||||
meta: { tokens: { input_tokens: 20, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'query',
|
||||
documents: [
|
||||
{ id: 'doc1', text: 'hello' },
|
||||
{ id: 'doc2', text: 'world' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(result.inputTokens).toBe(20);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results.find((r) => r.id === 'doc2')?.score).toBe(0.9);
|
||||
expect(result.results.find((r) => r.id === 'doc1')?.score).toBe(0.5);
|
||||
});
|
||||
|
||||
it('单文档正常召回', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.75 }],
|
||||
meta: { tokens: { input_tokens: 10, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
expect(result.results).toEqual([{ id: 'doc1', score: 0.75 }]);
|
||||
expect(result.inputTokens).toBe(10);
|
||||
});
|
||||
|
||||
// ── 边界值 ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('documents 为空时直接返回空,不发请求', async () => {
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: []
|
||||
});
|
||||
|
||||
expect(result).toEqual({ results: [], inputTokens: 0 });
|
||||
expect(mockPOST).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('所有文档 text 为空或空白时,返回空结果,不发请求', async () => {
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [
|
||||
{ id: 'doc1', text: '' },
|
||||
{ id: 'doc2', text: ' ' }
|
||||
]
|
||||
});
|
||||
|
||||
expect(result).toEqual({ results: [], inputTokens: 0 });
|
||||
expect(mockPOST).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── 复杂场景:文档切分 ────────────────────────────────────────────────────
|
||||
|
||||
it('文档超过 token 预算时切分 chunks,取最高分聚合并返回原始 doc id', async () => {
|
||||
// maxToken=600, query='q'(length=1), docBudget=599
|
||||
// longText length=1100 > 599 → 被切分
|
||||
// chunkSize = floor((1100/1100)*599*0.9) = 539 → 3 chunks (indices 0,1,2)
|
||||
// doc2 'short' length=5 <= 599 → 不切分 (index 3)
|
||||
const longText = 'a'.repeat(1100);
|
||||
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
// API 按 score 降序返回
|
||||
results: [
|
||||
{ index: 0, relevance_score: 0.8 }, // doc1__chunk_0 → doc1
|
||||
{ index: 3, relevance_score: 0.6 }, // doc2
|
||||
{ index: 1, relevance_score: 0.3 }, // doc1__chunk_1 → doc1(已存在,跳过)
|
||||
{ index: 2, relevance_score: 0.1 } // doc1__chunk_2 → doc1(已存在,跳过)
|
||||
],
|
||||
meta: { tokens: { input_tokens: 30, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: { ...mockModel, maxToken: 600 },
|
||||
query: 'q',
|
||||
documents: [
|
||||
{ id: 'doc1', text: longText },
|
||||
{ id: 'doc2', text: 'short' }
|
||||
]
|
||||
});
|
||||
|
||||
// 返回的 id 应为原始 doc id,不含 __chunk_ 后缀
|
||||
expect(result.results.every((r) => !r.id.includes('__chunk_'))).toBe(true);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results.find((r) => r.id === 'doc1')?.score).toBe(0.8);
|
||||
expect(result.results.find((r) => r.id === 'doc2')?.score).toBe(0.6);
|
||||
});
|
||||
|
||||
it('同一文档多个 chunk,非最高分 chunk 排在前面时,仍取第一个(最高分)', async () => {
|
||||
// doc1 3个 chunks (indices 0,1,2);API 返回 chunk_1 分最高
|
||||
// maxToken=600, query='q'(1), docBudget=599, chunkSize=539
|
||||
const longText = 'b'.repeat(1100);
|
||||
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [
|
||||
{ index: 1, relevance_score: 0.95 }, // chunk_1 最高
|
||||
{ index: 0, relevance_score: 0.4 }, // chunk_0 跳过
|
||||
{ index: 2, relevance_score: 0.2 } // chunk_2 跳过
|
||||
],
|
||||
meta: { tokens: { input_tokens: 20, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: { ...mockModel, maxToken: 600 },
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: longText }]
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.results[0]).toEqual({ id: 'doc1', score: 0.95 });
|
||||
});
|
||||
|
||||
// ── inputTokens 计算 ──────────────────────────────────────────────────────
|
||||
|
||||
it('API 未返回 meta tokens 时,通过 countPromptTokens 估算', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.5 }]
|
||||
// 无 meta
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'test', // length=4
|
||||
documents: [{ id: 'doc1', text: 'hello' }] // text length=5
|
||||
});
|
||||
|
||||
// documentsTextArray.join('\n') = 'hello'(单元素无分隔符)+ 'test' = 'hellotest'(9)
|
||||
expect(result.inputTokens).toBe(9);
|
||||
});
|
||||
|
||||
it('API 返回 meta tokens 时直接使用', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.5 }],
|
||||
meta: { tokens: { input_tokens: 42, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
expect(result.inputTokens).toBe(42);
|
||||
});
|
||||
|
||||
// ── requestUrl / requestAuth ──────────────────────────────────────────────
|
||||
|
||||
it('有 requestUrl 和 requestAuth 时,使用自定义地址和认证头', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.5 }],
|
||||
meta: { tokens: { input_tokens: 5, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
await reRankRecall({
|
||||
model: {
|
||||
...mockModel,
|
||||
requestUrl: 'https://custom.rerank.io/rerank',
|
||||
requestAuth: 'secret-key'
|
||||
},
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
expect(mockPOST).toHaveBeenCalledWith(
|
||||
'https://custom.rerank.io/rerank',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer secret-key'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('未设置 requestUrl 时,使用 baseUrl/rerank', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.5 }],
|
||||
meta: { tokens: { input_tokens: 5, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
const url: string = mockPOST.mock.calls[0][0];
|
||||
expect(url.endsWith('/rerank')).toBe(true);
|
||||
});
|
||||
|
||||
// ── 异常场景 ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('model 为 undefined 时 reject', async () => {
|
||||
await expect(
|
||||
reRankRecall({
|
||||
model: undefined,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
})
|
||||
).rejects.toThrow('No rerank model');
|
||||
});
|
||||
|
||||
it('query 超过 maxToken 时 reject', async () => {
|
||||
// maxToken=5, query length=26 → docBudget = 5-26 = -21 ≤ 500 → reject
|
||||
await expect(
|
||||
reRankRecall({
|
||||
model: { ...mockModel, maxToken: 5 },
|
||||
query: 'this query is way too long',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
})
|
||||
).rejects.toThrow('Rerank query too long');
|
||||
});
|
||||
|
||||
it('docBudget === 500 时 reject(边界值)', async () => {
|
||||
// mockCountPromptTokens 按 text.length 计算
|
||||
// maxToken=501, query='q'(length=1) → docBudget = 501-1 = 500 ≤ 500 → reject
|
||||
await expect(
|
||||
reRankRecall({
|
||||
model: { ...mockModel, maxToken: 501 },
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
})
|
||||
).rejects.toThrow('Rerank query too long');
|
||||
});
|
||||
|
||||
it('docBudget === 501 时不因 query 过长 reject', async () => {
|
||||
// maxToken=502, query='q'(length=1) → docBudget = 502-1 = 501 > 500 → 正常发请求
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: [{ index: 0, relevance_score: 0.5 }],
|
||||
meta: { tokens: { input_tokens: 5, output_tokens: 0 } }
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: { ...mockModel, maxToken: 502 },
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(mockPOST).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('API 请求失败时,reject 并传递原始错误', async () => {
|
||||
mockPOST.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(
|
||||
reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
})
|
||||
).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('API 返回空 results 时,返回空 results', async () => {
|
||||
mockPOST.mockResolvedValueOnce({
|
||||
id: 'r1',
|
||||
results: []
|
||||
});
|
||||
|
||||
const result = await reRankRecall({
|
||||
model: mockModel,
|
||||
query: 'q',
|
||||
documents: [{ id: 'doc1', text: 'hello' }]
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(0);
|
||||
expect(mockPOST).toHaveBeenCalledOnce();
|
||||
// 空 results 时提前返回,inputTokens 固定为 0
|
||||
expect(result.inputTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -811,15 +811,12 @@ describe('getNodeErrResponse', () => {
|
||||
});
|
||||
|
||||
it('should pass through optional fields', () => {
|
||||
const usages = [{ totalPoints: 1, tokens: 100, moduleName: 'test' }] as any;
|
||||
const result = getNodeErrResponse({
|
||||
error: 'fail',
|
||||
nodeDispatchUsages: usages,
|
||||
runTimes: 3,
|
||||
newVariables: { a: 1 },
|
||||
system_memories: { mem: 'val' }
|
||||
});
|
||||
expect(result[DispatchNodeResponseKeyEnum.nodeDispatchUsages]).toBe(usages);
|
||||
expect(result[DispatchNodeResponseKeyEnum.runTimes]).toBe(3);
|
||||
expect(result[DispatchNodeResponseKeyEnum.newVariables]).toEqual({ a: 1 });
|
||||
expect(result[DispatchNodeResponseKeyEnum.memories]).toEqual({ mem: 'val' });
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { formatModelChars2Points } from '@fastgpt/service/support/wallet/usage/utils';
|
||||
|
||||
// mock findAIModel,避免依赖全局 model map
|
||||
const mockModels: Record<string, any> = {
|
||||
'gpt-4': {
|
||||
name: 'GPT-4',
|
||||
model: 'gpt-4',
|
||||
charsPointsPrice: 0,
|
||||
inputPrice: 3,
|
||||
outputPrice: 6
|
||||
},
|
||||
'gpt-3.5': {
|
||||
name: 'GPT-3.5',
|
||||
model: 'gpt-3.5',
|
||||
charsPointsPrice: 2
|
||||
},
|
||||
'tiered-model': {
|
||||
name: 'Tiered',
|
||||
model: 'tiered-model',
|
||||
priceTiers: [
|
||||
{ maxInputTokens: 1, inputPrice: 1, outputPrice: 2 },
|
||||
{ inputPrice: 5, outputPrice: 10 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
vi.mock('@fastgpt/service/core/ai/model', () => ({
|
||||
findAIModel: (model: string) => mockModels[model]
|
||||
}));
|
||||
|
||||
describe('formatModelChars2Points', () => {
|
||||
it('should return 0 points and empty name when model not found', () => {
|
||||
const result = formatModelChars2Points({ model: 'non-existent' });
|
||||
expect(result).toEqual({ totalPoints: 0, modelName: '' });
|
||||
});
|
||||
|
||||
it('should return 0 points and empty name when model is empty string', () => {
|
||||
const result = formatModelChars2Points({ model: '' });
|
||||
expect(result).toEqual({ totalPoints: 0, modelName: '' });
|
||||
});
|
||||
|
||||
it('should calculate points with legacy input/output pricing', () => {
|
||||
const result = formatModelChars2Points({
|
||||
model: 'gpt-4',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500
|
||||
});
|
||||
expect(result.modelName).toBe('GPT-4');
|
||||
// inputPrice:3 * (1000/1000) + outputPrice:6 * (500/1000) = 3 + 3 = 6
|
||||
expect(result.totalPoints).toBe(6);
|
||||
});
|
||||
|
||||
it('should calculate points with comprehensive price', () => {
|
||||
const result = formatModelChars2Points({
|
||||
model: 'gpt-3.5',
|
||||
inputTokens: 2000,
|
||||
outputTokens: 1000
|
||||
});
|
||||
expect(result.modelName).toBe('GPT-3.5');
|
||||
// charsPointsPrice:2 → inputPrice=outputPrice=2
|
||||
// 2 * (2000/1000) + 2 * (1000/1000) = 4 + 2 = 6
|
||||
expect(result.totalPoints).toBe(6);
|
||||
});
|
||||
|
||||
it('should use default 0 tokens when not provided', () => {
|
||||
const result = formatModelChars2Points({ model: 'gpt-4' });
|
||||
expect(result.modelName).toBe('GPT-4');
|
||||
expect(result.totalPoints).toBe(0);
|
||||
});
|
||||
|
||||
it('should support custom multiple parameter', () => {
|
||||
const result = formatModelChars2Points({
|
||||
model: 'gpt-4',
|
||||
inputTokens: 500,
|
||||
outputTokens: 500,
|
||||
multiple: 500
|
||||
});
|
||||
expect(result.modelName).toBe('GPT-4');
|
||||
// inputPrice:3 * (500/500) + outputPrice:6 * (500/500) = 3 + 6 = 9
|
||||
expect(result.totalPoints).toBe(9);
|
||||
});
|
||||
|
||||
it('should calculate points with price tiers', () => {
|
||||
const result = formatModelChars2Points({
|
||||
model: 'tiered-model',
|
||||
inputTokens: 2000,
|
||||
outputTokens: 100
|
||||
});
|
||||
expect(result.modelName).toBe('Tiered');
|
||||
// inputTokens:200 匹配第二梯度 (inputPrice:5, outputPrice:10)
|
||||
// 5 * (2000/1000) + 10 * (100/1000) = 10 + 1 = 11
|
||||
expect(result.totalPoints).toBe(11);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user