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:
Archer
2026-03-30 10:05:42 +08:00
committed by GitHub
parent 03dd9c00a8
commit 3f4400a500
108 changed files with 3774 additions and 1469 deletions
+513
View File
@@ -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);
});
});