234 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|