Files
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

234 lines
7.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
sortModalities,
normalizeModalities,
calculatePricePerMillion,
calculateTokenPrices,
getProviderOrder,
} from '../../src/services/ai/helpers';
import type { RawOpenRouterModel } from '../../src/types/ai';
describe('sortModalities', () => {
it('sorts known modalities by predefined order', () => {
expect(sortModalities(['video', 'text', 'image'])).toEqual(['text', 'image', 'video']);
});
it('places unknown modalities after known ones, sorted alphabetically', () => {
expect(sortModalities(['transcription', 'text', 'audio'])).toEqual([
'text',
'audio',
'transcription',
]);
});
it('sorts multiple unknown modalities alphabetically among themselves', () => {
expect(sortModalities(['zebra', 'alpha', 'text'])).toEqual(['text', 'alpha', 'zebra']);
});
it('deduplicates entries', () => {
expect(sortModalities(['text', 'text', 'image', 'image'])).toEqual(['text', 'image']);
});
it('filters out empty and whitespace-only strings', () => {
expect(sortModalities(['', 'text', ' ', 'image'])).toEqual(['text', 'image']);
});
it('returns an empty array for empty input', () => {
expect(sortModalities([])).toEqual([]);
});
it('handles all known modalities in the correct canonical order', () => {
expect(sortModalities(['embeddings', 'file', 'video', 'audio', 'image', 'text'])).toEqual([
'text',
'image',
'audio',
'video',
'file',
'embeddings',
]);
});
it('handles a single modality', () => {
expect(sortModalities(['embeddings'])).toEqual(['embeddings']);
});
});
describe('normalizeModalities', () => {
it('delegates to sortModalities (dedup + sort)', () => {
expect(normalizeModalities(['image', 'text', 'image'])).toEqual(['text', 'image']);
});
});
describe('calculatePricePerMillion', () => {
it('converts per-token pricing to per-million-tokens', () => {
const result = calculatePricePerMillion({
prompt: '0.000001',
completion: '0.000002',
});
expect(result.inputPrice).toBe(1);
expect(result.outputPrice).toBe(2);
});
it('handles zero pricing', () => {
const result = calculatePricePerMillion({
prompt: '0',
completion: '0',
});
expect(result.inputPrice).toBe(0);
expect(result.outputPrice).toBe(0);
});
it('returns empty object when pricing is undefined', () => {
const result = calculatePricePerMillion(undefined as unknown as RawOpenRouterModel['pricing']);
expect(result).toEqual({});
});
it('treats non-numeric pricing strings as zero', () => {
const result = calculatePricePerMillion({
prompt: 'abc',
completion: '',
});
expect(result.inputPrice).toBe(0);
expect(result.outputPrice).toBe(0);
});
it('handles very small prices (embedding models)', () => {
const result = calculatePricePerMillion({
prompt: '0.00000002',
completion: '0',
});
expect(result.inputPrice).toBeCloseTo(0.02, 4);
expect(result.outputPrice).toBe(0);
});
it('ensures non-negative prices', () => {
const result = calculatePricePerMillion({
prompt: '0.000001',
completion: '0.000001',
});
expect(result.inputPrice).toBeGreaterThanOrEqual(0);
expect(result.outputPrice).toBeGreaterThanOrEqual(0);
});
});
describe('calculateTokenPrices', () => {
const textPricing = {
prompt: '0.000001',
completion: '0.000002',
};
it('returns token-priced labels for text→text models', () => {
const result = calculateTokenPrices(textPricing, ['text'], ['text']);
expect(result.inputPrice).toBe(1);
expect(result.outputPrice).toBe(2);
expect(result.inputPriceLabel).toBe('$1.0 / M tokens');
expect(result.outputPriceLabel).toBe('$2.0 / M tokens');
});
it('returns inputPrice but not outputPrice for embedding models', () => {
const result = calculateTokenPrices(textPricing, ['text'], ['embeddings']);
expect(result.inputPrice).toBe(1);
expect(result.outputPrice).toBeUndefined();
expect(result.inputPriceLabel).toBe('$1.0 / M tokens');
expect(result.outputPriceLabel).toBeUndefined();
});
it('returns undefined prices for image→image models', () => {
const result = calculateTokenPrices(textPricing, ['image'], ['image']);
expect(result.inputPrice).toBeUndefined();
expect(result.outputPrice).toBeUndefined();
expect(result.inputPriceLabel).toBeUndefined();
expect(result.outputPriceLabel).toBeUndefined();
});
it('returns inputPrice for file input modality', () => {
const result = calculateTokenPrices(textPricing, ['file'], ['text']);
expect(result.inputPrice).toBe(1);
expect(result.inputPriceLabel).toBe('$1.0 / M tokens');
});
it('returns "Free" label when price is zero', () => {
const result = calculateTokenPrices({ prompt: '0', completion: '0' }, ['text'], ['text']);
expect(result.inputPriceLabel).toBe('Free');
expect(result.outputPriceLabel).toBe('Free');
});
it('returns empty object when pricing is undefined', () => {
const result = calculateTokenPrices(
undefined as unknown as RawOpenRouterModel['pricing'],
['text'],
['text']
);
expect(result).toEqual({});
});
it('formats small prices with 4 decimal places', () => {
const result = calculateTokenPrices(
{ prompt: '0.000000005', completion: '0' },
['text'],
['text']
);
// 0.000000005 * 1_000_000 = 0.005
expect(result.inputPriceLabel).toBe('$0.0050 / M tokens');
});
it('formats medium prices with 2 decimal places', () => {
const result = calculateTokenPrices(
{ prompt: '0.0000005', completion: '0' },
['text'],
['text']
);
// 0.0000005 * 1_000_000 = 0.5
expect(result.inputPriceLabel).toBe('$0.50 / M tokens');
});
it('formats large prices with 1 decimal place', () => {
const result = calculateTokenPrices({ prompt: '0.00006', completion: '0' }, ['text'], ['text']);
// 0.00006 * 1_000_000 = 60
expect(result.inputPriceLabel).toBe('$60.0 / M tokens');
});
it('handles multimodal input (text + image) with text output', () => {
const result = calculateTokenPrices(textPricing, ['text', 'image'], ['text']);
expect(result.inputPrice).toBe(1);
expect(result.outputPrice).toBe(2);
});
it('handles audio-only model (no token pricing)', () => {
const result = calculateTokenPrices(
{ prompt: '0.111', completion: '0' },
['audio'],
['transcription']
);
expect(result.inputPrice).toBeUndefined();
expect(result.outputPrice).toBeUndefined();
});
});
describe('getProviderOrder', () => {
it('returns correct order for known providers', () => {
expect(getProviderOrder('openai/gpt-5.5')).toBe(1);
expect(getProviderOrder('anthropic/claude-sonnet-4.6')).toBe(2);
expect(getProviderOrder('google/gemini-2.5-pro')).toBe(3);
expect(getProviderOrder('amazon/nova-pro-v2')).toBe(4);
});
it('returns 999 for unknown providers', () => {
expect(getProviderOrder('mistral/mistral-large')).toBe(999);
expect(getProviderOrder('meta/llama-4')).toBe(999);
});
it('handles model IDs without a slash', () => {
expect(getProviderOrder('standalone-model')).toBe(999);
});
it('handles empty string', () => {
expect(getProviderOrder('')).toBe(999);
});
it('is case-insensitive for provider matching', () => {
expect(getProviderOrder('OpenAI/gpt-5.5')).toBe(1);
expect(getProviderOrder('GOOGLE/gemini-2.5-pro')).toBe(3);
});
});